From 09ddb50e3b1ce62f87516b191b26c513a1998f10 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 1 Apr 2026 11:17:04 -0500 Subject: [PATCH 1/4] feat: add validateEvent() to all 12 language emitters Extends codegen from tag-only validation to full event validation. Each emitter now generates a validateEvent() function that checks: - Type guards (event shape, kind integer, content string, tags array) - Base fields (id=hex64, pubkey=hex64, sig=hex128, created_at>=0) - Content constraints (minLength, maxLength, pattern, enum per kind) - Tag dispatch (delegates to existing validateKindTags) Languages: TypeScript, C, Rust (3 API modes), Swift, Python, Go, Java, Kotlin, Dart, C#, C++, PHP, Ruby. 67 new tests across 13 test files (975 total). Co-Authored-By: Claude Opus 4.6 --- src/emit-c.ts | 174 +++++++++++++++++++++++++++++++- src/emit-cpp.ts | 123 ++++++++++++++++++++++- src/emit-csharp.ts | 137 ++++++++++++++++++++++++- src/emit-dart.ts | 148 ++++++++++++++++++++++++++- src/emit-go.ts | 182 +++++++++++++++++++++++++++++++++- src/emit-java.ts | 165 +++++++++++++++++++++++++++++- src/emit-kotlin.ts | 148 ++++++++++++++++++++++++++- src/emit-php.ts | 141 +++++++++++++++++++++++++- src/emit-python.ts | 138 +++++++++++++++++++++++++- src/emit-ruby.ts | 131 +++++++++++++++++++++++- src/emit-rust.ts | 176 +++++++++++++++++++++++++++++++- src/emit-swift.ts | 161 +++++++++++++++++++++++++++++- src/emit-validators.ts | 18 +++- tests/emit-c.test.ts | 48 +++++++++ tests/emit-cpp.test.ts | 40 ++++++++ tests/emit-csharp.test.ts | 39 ++++++++ tests/emit-dart.test.ts | 39 ++++++++ tests/emit-go.test.ts | 39 ++++++++ tests/emit-java.test.ts | 39 ++++++++ tests/emit-kotlin.test.ts | 39 ++++++++ tests/emit-php.test.ts | 39 ++++++++ tests/emit-python.test.ts | 39 ++++++++ tests/emit-ruby.test.ts | 39 ++++++++ tests/emit-rust.test.ts | 53 ++++++++++ tests/emit-swift.test.ts | 39 ++++++++ tests/emit-validators.test.ts | 40 ++++++++ 26 files changed, 2359 insertions(+), 15 deletions(-) diff --git a/src/emit-c.ts b/src/emit-c.ts index c3b860f..6b05a91 100644 --- a/src/emit-c.ts +++ b/src/emit-c.ts @@ -14,7 +14,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -421,6 +423,131 @@ function renderTagSearchC( return { code: lines.join('\n'), helpers }; } +// --- Content validation --- + +function renderContentActionsC( + actions: ContentAction[], + helpers: Set, + contentVar: string, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (strlen(${contentVar}) < ${action.min}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at least ${action.min} character(s)");`); + break; + case 'check_content_max_length': + lines.push(` if (strlen(${contentVar}) > ${action.max}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at most ${action.max} character(s)");`); + break; + case 'check_content_pattern': { + const r = renderPatternCheckC(action.native, contentVar); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must match pattern ${action.regex.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}");`); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `strcmp(${contentVar}, ${JSON.stringify(v)}) == 0`).join(' || '); + lines.push(` if (!(${checks})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be one of: ${action.values.join(', ')}");`); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventFunctionC( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, + adapter: CApiAdapter, + api: CApi, +): string { + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + const lines: string[] = []; + + if (api === 'nostrdb') { + lines.push('/* Validate an entire event: base fields + content + tags */'); + lines.push('int schemata_validate_event(const struct ndb_note *note,'); + lines.push(' struct schemata_error *errs, int max_errs) {'); + lines.push(' int n = 0;'); + // nostrdb stores raw bytes, not hex strings - just null-check + lines.push(' if (!ndb_note_id(note)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "id", "id is required");'); + lines.push(' if (!ndb_note_pubkey(note)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "pubkey", "pubkey is required");'); + // sig not available through ndb API typically, skip + lines.push(' if (ndb_note_created_at(note) < 0) SCHEMATA_EMIT_ERR(errs, n, max_errs, "created_at", "created_at must be a non-negative integer");'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' const char *_content = ndb_note_content(note);'); + lines.push(' if (!_content) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); + lines.push(' } else {'); + lines.push(` switch (${adapter.kindExpr}) {`); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsC(actions, helpers, '_content')); + lines.push(' break;'); + } + lines.push(' }'); + lines.push(' }'); + } + + // Tag dispatch + lines.push(' {'); + lines.push(' int _remaining = max_errs - n;'); + lines.push(' if (_remaining > 0) {'); + lines.push(' n += schemata_validate(note, errs + n, _remaining);'); + lines.push(' }'); + lines.push(' }'); + } else { + // Generic API + helpers.add('schemata_check_hex64'); + helpers.add('schemata_check_hex128'); + + lines.push('/* Validate an entire event: base fields + content + tags */'); + lines.push('int schemata_validate_event(int kind, const char *id, const char *pubkey,'); + lines.push(' const char *sig, int64_t created_at, const char *content,'); + lines.push(' const char *const *const *tags, const int *tag_lens, int num_tags,'); + lines.push(' struct schemata_error *errs, int max_errs) {'); + lines.push(' int n = 0;'); + + // Base field checks + lines.push(' if (!id || !schemata_check_hex64(id)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "id", "id must be a 64-char lowercase hex string");'); + lines.push(' if (!pubkey || !schemata_check_hex64(pubkey)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "pubkey", "pubkey must be a 64-char lowercase hex string");'); + lines.push(' if (!sig || !schemata_check_hex128(sig)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "sig", "sig must be a 128-char lowercase hex string");'); + lines.push(' if (created_at < 0) SCHEMATA_EMIT_ERR(errs, n, max_errs, "created_at", "created_at must be a non-negative integer");'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' if (!content) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); + lines.push(' } else {'); + lines.push(' switch (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsC(actions, helpers, 'content')); + lines.push(' break;'); + } + lines.push(' }'); + lines.push(' }'); + } + + // Tag dispatch + lines.push(' {'); + lines.push(' int _remaining = max_errs - n;'); + lines.push(' if (_remaining > 0) {'); + lines.push(' n += schemata_validate(kind, tags, tag_lens, num_tags, errs + n, _remaining);'); + lines.push(' }'); + lines.push(' }'); + } + + lines.push(' return n;'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitCValidators( @@ -443,8 +570,14 @@ export function emitCValidators( for (const h of helpers) allHelpers.add(h); } - const header = emitHeaderFile(constrainedKinds, adapter); - const source = emitSourceFile(fnBodies, constrainedKinds, allHelpers, adapter, api, headerFileName); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + const header = emitHeaderFile(constrainedKinds, adapter, api, contentPlans); + const source = emitSourceFile(fnBodies, constrainedKinds, allHelpers, adapter, api, headerFileName, contentPlans); return { header, source }; } @@ -611,6 +744,8 @@ function emitKindFunctionC( function emitHeaderFile( constrainedKinds: { kindNumber: number; nip: string }[], adapter: CApiAdapter, + api: CApi, + contentPlans: Map, ): string { const lines: string[] = [ '/* Auto-generated by @nostrability/schemata-codegen */', @@ -626,6 +761,12 @@ function emitHeaderFile( lines.push(''); } + // int64_t needed for generic API event function + if (api === 'generic' && (constrainedKinds.length > 0 || contentPlans.size > 0)) { + lines.push('#include '); + lines.push(''); + } + lines.push('#ifdef __cplusplus'); lines.push('extern "C" {'); lines.push('#endif'); @@ -645,6 +786,22 @@ function emitHeaderFile( lines.push('/* Dispatch: validate by kind number */'); for (const l of adapter.dispatchDecl()) lines.push(l); lines.push(''); + + // Event validation declaration + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + lines.push('/* Validate an entire event: base fields + content + tags */'); + if (api === 'nostrdb') { + lines.push('int schemata_validate_event(const struct ndb_note *note,'); + lines.push(' struct schemata_error *errs, int max_errs);'); + } else { + lines.push('int schemata_validate_event(int kind, const char *id, const char *pubkey,'); + lines.push(' const char *sig, int64_t created_at, const char *content,'); + lines.push(' const char *const *const *tags, const int *tag_lens, int num_tags,'); + lines.push(' struct schemata_error *errs, int max_errs);'); + } + lines.push(''); + } + lines.push('#ifdef __cplusplus'); lines.push('}'); lines.push('#endif'); @@ -662,7 +819,14 @@ function emitSourceFile( adapter: CApiAdapter, api: CApi, headerFileName: string, + contentPlans: Map, ): string { + // Pre-generate event function BEFORE helpers so helper references are registered + let eventFnCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventFnCode = emitEventFunctionC(constrainedKinds, contentPlans, helpers, adapter, api); + } + const lines: string[] = [ '/* Auto-generated by @nostrability/schemata-codegen */', '/* Do not edit manually. */', @@ -697,6 +861,12 @@ function emitSourceFile( lines.push(' default: return 0;'); lines.push(' }'); lines.push('}'); + + if (eventFnCode) { + lines.push(''); + lines.push(eventFnCode); + } + lines.push(''); return lines.join('\n'); diff --git a/src/emit-cpp.ts b/src/emit-cpp.ts index de7b3dd..beafadf 100644 --- a/src/emit-cpp.ts +++ b/src/emit-cpp.ts @@ -12,7 +12,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -320,6 +322,97 @@ function renderTagMatcherCpp( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsCpp( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (event.content.size() < ${action.min}) {`); + lines.push(` errors.push_back({"content", "content must be at least ${action.min} character(s)"});`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (event.content.size() > ${action.max}) {`); + lines.push(` errors.push_back({"content", "content must be at most ${action.max} character(s)"});`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckCpp(action.native, 'event.content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + lines.push(` errors.push_back({"content", "content must match pattern ${action.regex.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"});`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `event.content == ${JSON.stringify(v)}`).join(' || '); + lines.push(` if (!(${checks})) {`); + lines.push(` errors.push_back({"content", "content must be one of: ${action.values.join(', ')}"});`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchCpp( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('/// Validate an event\'s base fields, content constraints, and tag structure.'); + lines.push('inline std::vector validate_event(const SchemataEvent& event) {'); + lines.push(' std::vector errors;'); + + helpers.add('check_hex_64'); + helpers.add('check_hex_128'); + + lines.push(' if (!check_hex_64(event.id)) {'); + lines.push(' errors.push_back({"id", "id must be a 64-char lowercase hex string"});'); + lines.push(' }'); + lines.push(' if (!check_hex_64(event.pubkey)) {'); + lines.push(' errors.push_back({"pubkey", "pubkey must be a 64-char lowercase hex string"});'); + lines.push(' }'); + lines.push(' if (!check_hex_128(event.sig)) {'); + lines.push(' errors.push_back({"sig", "sig must be a 128-char lowercase hex string"});'); + lines.push(' }'); + lines.push(' if (event.created_at < 0) {'); + lines.push(' errors.push_back({"created_at", "created_at must be a non-negative integer"});'); + lines.push(' }'); + + if (contentKinds.length > 0) { + lines.push(' switch (event.kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}: {`); + lines.push(...renderContentActionsCpp(actions, helpers)); + lines.push(' break;'); + lines.push(' }'); + } + lines.push(' }'); + } + + if (sorted.length > 0) { + lines.push(' auto tag_errors = validate_kind_tags(event.kind, event.tags);'); + lines.push(' errors.insert(errors.end(), tag_errors.begin(), tag_errors.end());'); + } + + lines.push(' return errors;'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitCppValidators(kindShapes: KindShape[]): string { @@ -337,7 +430,13 @@ export function emitCppValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitCppFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitCppFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionCpp( @@ -455,7 +554,13 @@ function emitCppFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchCpp(constrainedKinds, contentPlans, helpers); + } + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -470,6 +575,7 @@ function emitCppFile( '#include ', '#include ', '#include ', + '#include ', ]; if (needsRegex) { @@ -484,6 +590,16 @@ function emitCppFile( lines.push(' const char* message;'); lines.push('};'); lines.push(''); + lines.push('struct SchemataEvent {'); + lines.push(' std::string id;'); + lines.push(' std::string pubkey;'); + lines.push(' std::string sig;'); + lines.push(' std::string content;'); + lines.push(' int64_t created_at;'); + lines.push(' int kind;'); + lines.push(' std::vector> tags;'); + lines.push('};'); + lines.push(''); lines.push(emitCppHelpers(helpers)); @@ -503,6 +619,11 @@ function emitCppFile( lines.push(' }'); lines.push('}'); lines.push(''); + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + lines.push('} // namespace schemata'); lines.push(''); diff --git a/src/emit-csharp.ts b/src/emit-csharp.ts index ad450e1..7c213d3 100644 --- a/src/emit-csharp.ts +++ b/src/emit-csharp.ts @@ -12,7 +12,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -314,6 +316,121 @@ function renderTagMatcherCSharp( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsCSharp( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (content.Length < ${action.min}) {`); + lines.push(` errors.Add(new ValidationError("content", "content must be at least ${action.min} character(s)"));`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (content.Length > ${action.max}) {`); + lines.push(` errors.Add(new ValidationError("content", "content must be at most ${action.max} character(s)"));`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckCSharp(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + lines.push(` errors.Add(new ValidationError("content", "content must match pattern " + ${JSON.stringify(action.regex)}));`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `content == ${JSON.stringify(v)}`).join(' || '); + lines.push(` if (!(${checks})) {`); + lines.push(` errors.Add(new ValidationError("content", "content must be one of: " + ${JSON.stringify(action.values.join(', '))}));`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchCSharp( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push(' /// Validate an event\'s base fields, content constraints, and tag structure.'); + lines.push(' public static List ValidateEvent(IDictionary ev) {'); + lines.push(' var errors = new List();'); + lines.push(' if (!ev.TryGetValue("kind", out var kindRaw) || kindRaw is not int kind) {'); + lines.push(' errors.Add(new ValidationError("kind", "kind must be an integer"));'); + lines.push(' return errors;'); + lines.push(' }'); + + helpers.add('CheckHex64'); + helpers.add('CheckHex128'); + + lines.push(' if (!ev.TryGetValue("id", out var idRaw) || idRaw is not string idStr || !CheckHex64(idStr)) {'); + lines.push(' errors.Add(new ValidationError("id", "id must be a 64-char lowercase hex string"));'); + lines.push(' }'); + lines.push(' if (!ev.TryGetValue("pubkey", out var pkRaw) || pkRaw is not string pkStr || !CheckHex64(pkStr)) {'); + lines.push(' errors.Add(new ValidationError("pubkey", "pubkey must be a 64-char lowercase hex string"));'); + lines.push(' }'); + lines.push(' if (!ev.TryGetValue("sig", out var sigRaw) || sigRaw is not string sigStr || !CheckHex128(sigStr)) {'); + lines.push(' errors.Add(new ValidationError("sig", "sig must be a 128-char lowercase hex string"));'); + lines.push(' }'); + lines.push(' if (!ev.TryGetValue("created_at", out var caRaw) || caRaw is not int caVal || caVal < 0) {'); + lines.push(' errors.Add(new ValidationError("created_at", "created_at must be a non-negative integer"));'); + lines.push(' }'); + + if (contentKinds.length > 0) { + lines.push(' if (!ev.ContainsKey("content")) {'); + lines.push(' errors.Add(new ValidationError("content", "content is required"));'); + lines.push(' } else if (ev.TryGetValue("content", out var contentRaw) && contentRaw is string content) {'); + lines.push(' switch (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}: {`); + lines.push(...renderContentActionsCSharp(actions, helpers)); + lines.push(' break;'); + lines.push(' }'); + } + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError("content", "content must be a string"));'); + lines.push(' }'); + } + + if (sorted.length > 0) { + lines.push(' if (!ev.ContainsKey("tags")) {'); + lines.push(' errors.Add(new ValidationError("tags", "tags is required"));'); + lines.push(' } else if (ev.TryGetValue("tags", out var tagsRaw) && tagsRaw is System.Collections.IList rawList) {'); + lines.push(' var tags = new List>();'); + lines.push(' for (var i = 0; i < rawList.Count; i++) {'); + lines.push(' if (rawList[i] is System.Collections.IList inner && inner.Cast().All(v => v is string)) {'); + lines.push(' tags.Add(inner.Cast().Select(v => (string)v!).ToList());'); + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError($"tags[{i}]", $"tags[{i}] must be a list of strings"));'); + lines.push(' tags.Add(new List());'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.AddRange(ValidateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError("tags", "tags must be a list"));'); + lines.push(' }'); + } + + lines.push(' return errors;'); + lines.push(' }'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitCSharpValidators(kindShapes: KindShape[]): string { @@ -331,7 +448,13 @@ export function emitCSharpValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitCSharpFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitCSharpFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionCSharp( @@ -449,7 +572,13 @@ function emitCSharpFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchCSharp(constrainedKinds, contentPlans, helpers); + } + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -459,6 +588,7 @@ function emitCSharpFile( '// Runtime validators for Nostr event tag constraints', '', 'using System;', + 'using System.Collections;', 'using System.Collections.Generic;', 'using System.Linq;', ]; @@ -501,6 +631,11 @@ function emitCSharpFile( lines.push(' };'); lines.push(' }'); + if (eventDispatchCode) { + lines.push(''); + lines.push(eventDispatchCode); + } + lines.push(' }'); lines.push('}'); lines.push(''); diff --git a/src/emit-dart.ts b/src/emit-dart.ts index eeda2e4..573a218 100644 --- a/src/emit-dart.ts +++ b/src/emit-dart.ts @@ -11,7 +11,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -321,6 +323,133 @@ function dartString(s: string): string { return `'${escaped}'`; } +// --- Content validation --- + +function renderContentActionsDart( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (content.length < ${action.min}) {`); + lines.push(` errors.add(ValidationError(path: 'content', message: 'content must be at least ${action.min} character(s)'));`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (content.length > ${action.max}) {`); + lines.push(` errors.add(ValidationError(path: 'content', message: 'content must be at most ${action.max} character(s)'));`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckDart(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + const escaped = action.regex.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$'); + lines.push(` errors.add(ValidationError(path: 'content', message: 'content must match pattern ${escaped}'));`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => `'${v}'`).join(', '); + lines.push(` if (![${vals}].contains(content)) {`); + lines.push(` errors.add(ValidationError(path: 'content', message: 'content must be one of: ${action.values.join(', ')}'));`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchDart( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('/// Validate an event\'s base fields, content constraints, and tag structure.'); + lines.push('List validateEvent(Map event) {'); + lines.push(' final errors = [];'); + lines.push(' final kind = event[\'kind\'];'); + lines.push(' if (kind is! int) {'); + lines.push(" errors.add(ValidationError(path: 'kind', message: 'kind must be an integer'));"); + lines.push(' return errors;'); + lines.push(' }'); + + helpers.add('_checkHex64'); + helpers.add('_checkHex128'); + + lines.push(' final id = event[\'id\'];'); + lines.push(' if (id is! String || !_checkHex64(id)) {'); + lines.push(" errors.add(ValidationError(path: 'id', message: 'id must be a 64-char lowercase hex string'));"); + lines.push(' }'); + lines.push(' final pubkey = event[\'pubkey\'];'); + lines.push(' if (pubkey is! String || !_checkHex64(pubkey)) {'); + lines.push(" errors.add(ValidationError(path: 'pubkey', message: 'pubkey must be a 64-char lowercase hex string'));"); + lines.push(' }'); + lines.push(' final sig = event[\'sig\'];'); + lines.push(' if (sig is! String || !_checkHex128(sig)) {'); + lines.push(" errors.add(ValidationError(path: 'sig', message: 'sig must be a 128-char lowercase hex string'));"); + lines.push(' }'); + lines.push(' final createdAt = event[\'created_at\'];'); + lines.push(' if (createdAt is! int || createdAt < 0) {'); + lines.push(" errors.add(ValidationError(path: 'created_at', message: 'created_at must be a non-negative integer'));"); + lines.push(' }'); + + if (contentKinds.length > 0) { + lines.push(' if (!event.containsKey(\'content\')) {'); + lines.push(" errors.add(ValidationError(path: 'content', message: 'content is required'));"); + lines.push(' } else {'); + lines.push(' final contentRaw = event[\'content\'];'); + lines.push(' if (contentRaw is String) {'); + lines.push(' final content = contentRaw;'); + lines.push(' switch (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsDart(actions, helpers)); + } + lines.push(' }'); + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'content', message: 'content must be a string'));"); + lines.push(' }'); + lines.push(' }'); + } + + if (sorted.length > 0) { + lines.push(' if (!event.containsKey(\'tags\')) {'); + lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags is required'));"); + lines.push(' } else {'); + lines.push(' final tagsRaw = event[\'tags\'];'); + lines.push(' if (tagsRaw is List) {'); + lines.push(' final tags = >[];'); + lines.push(' for (var i = 0; i < tagsRaw.length; i++) {'); + lines.push(' final t = tagsRaw[i];'); + lines.push(' if (t is List && t.every((v) => v is String)) {'); + lines.push(' tags.add(t.cast());'); + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'tags[\$i]', message: 'tags[\$i] must be a list of strings'));"); + lines.push(' tags.add([]);'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags must be a list'));"); + lines.push(' }'); + lines.push(' }'); + } + + lines.push(' return errors;'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitDartValidators(kindShapes: KindShape[]): string { @@ -338,7 +467,13 @@ export function emitDartValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitDartFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitDartFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionDart( @@ -456,7 +591,13 @@ function emitDartFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchDart(constrainedKinds, contentPlans, helpers); + } + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -490,6 +631,11 @@ function emitDartFile( lines.push('}'); lines.push(''); + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-go.ts b/src/emit-go.ts index 68c199c..b58cef8 100644 --- a/src/emit-go.ts +++ b/src/emit-go.ts @@ -17,7 +17,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -374,6 +376,159 @@ function renderTagMatcherCondition( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsGo( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(`\tif len(content) < ${action.min} {`); + lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be at least ${action.min} character(s)"})`); + lines.push('\t}'); + break; + case 'check_content_max_length': + lines.push(`\tif len(content) > ${action.max} {`); + lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be at most ${action.max} character(s)"})`); + lines.push('\t}'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckGo(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(`\tif !(${r.expr}) {`); + lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must match pattern " + ${JSON.stringify(action.regex)}})`); + lines.push('\t}'); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => JSON.stringify(v)); + const checks = vals.map(v => `content == ${v}`).join(' || '); + lines.push(`\tif !(${checks}) {`); + lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be one of: " + ${JSON.stringify(action.values.join(', '))}})`); + lines.push('\t}'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchGo( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('// ValidateEvent validates an event\'s base fields, content constraints, and tag structure.'); + lines.push('func ValidateEvent(event map[string]interface{}) []ValidationError {'); + lines.push('\tvar errors []ValidationError'); + lines.push('\tkindRaw, ok := event["kind"]'); + lines.push('\tif !ok {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "kind", Message: "kind must be an integer"})'); + lines.push('\t\treturn errors'); + lines.push('\t}'); + lines.push('\tkindFloat, ok := kindRaw.(float64)'); + lines.push('\tif !ok || kindFloat != float64(int(kindFloat)) {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "kind", Message: "kind must be an integer"})'); + lines.push('\t\treturn errors'); + lines.push('\t}'); + lines.push('\tkind := int(kindFloat)'); + + // Base field checks + helpers.add('checkHex64'); + helpers.add('checkHex128'); + + lines.push('\tif idRaw, ok := event["id"]; !ok {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "id", Message: "id must be a 64-char lowercase hex string"})'); + lines.push('\t} else if idStr, ok := idRaw.(string); !ok || !checkHex64(idStr) {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "id", Message: "id must be a 64-char lowercase hex string"})'); + lines.push('\t}'); + + lines.push('\tif pkRaw, ok := event["pubkey"]; !ok {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "pubkey", Message: "pubkey must be a 64-char lowercase hex string"})'); + lines.push('\t} else if pkStr, ok := pkRaw.(string); !ok || !checkHex64(pkStr) {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "pubkey", Message: "pubkey must be a 64-char lowercase hex string"})'); + lines.push('\t}'); + + lines.push('\tif sigRaw, ok := event["sig"]; !ok {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "sig", Message: "sig must be a 128-char lowercase hex string"})'); + lines.push('\t} else if sigStr, ok := sigRaw.(string); !ok || !checkHex128(sigStr) {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "sig", Message: "sig must be a 128-char lowercase hex string"})'); + lines.push('\t}'); + + lines.push('\tif caRaw, ok := event["created_at"]; !ok {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "created_at", Message: "created_at must be a non-negative integer"})'); + lines.push('\t} else if caFloat, ok := caRaw.(float64); !ok || caFloat != float64(int64(caFloat)) || caFloat < 0 {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "created_at", Message: "created_at must be a non-negative integer"})'); + lines.push('\t}'); + + // Content validation + if (contentKinds.length > 0) { + lines.push('\tcontentRaw, hasContent := event["content"]'); + lines.push('\tif !hasContent {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content is required"})'); + lines.push('\t} else if content, ok := contentRaw.(string); ok {'); + lines.push('\t\tswitch kind {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(`\t\tcase ${kindNumber}:`); + lines.push(...renderContentActionsGo(actions, helpers)); + } + lines.push('\t\t}'); + lines.push('\t} else {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be a string"})'); + lines.push('\t}'); + } + + // Tag dispatch + if (sorted.length > 0) { + lines.push('\ttagsRaw, hasTags := event["tags"]'); + lines.push('\tif !hasTags {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags is required"})'); + lines.push('\t} else if tagsSlice, ok := tagsRaw.([]interface{}); ok {'); + lines.push('\t\ttags := make([][]string, 0, len(tagsSlice))'); + lines.push('\t\tfor i, raw := range tagsSlice {'); + lines.push('\t\t\tif arr, ok := raw.([]interface{}); ok {'); + lines.push('\t\t\t\tvalid := true'); + lines.push('\t\t\t\tstrs := make([]string, len(arr))'); + lines.push('\t\t\t\tfor j, v := range arr {'); + lines.push('\t\t\t\t\tif s, ok := v.(string); ok {'); + lines.push('\t\t\t\t\t\tstrs[j] = s'); + lines.push('\t\t\t\t\t} else {'); + lines.push('\t\t\t\t\t\tvalid = false'); + lines.push('\t\t\t\t\t\tbreak'); + lines.push('\t\t\t\t\t}'); + lines.push('\t\t\t\t}'); + lines.push('\t\t\t\tif valid {'); + lines.push('\t\t\t\t\ttags = append(tags, strs)'); + lines.push('\t\t\t\t} else {'); + lines.push('\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); + lines.push('\t\t\t\t\ttags = append(tags, nil)'); + lines.push('\t\t\t\t}'); + lines.push('\t\t\t} else {'); + lines.push('\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); + lines.push('\t\t\t\ttags = append(tags, nil)'); + lines.push('\t\t\t}'); + lines.push('\t\t}'); + lines.push('\t\terrors = append(errors, ValidateKindTags(kind, tags)...)'); + lines.push('\t} else {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags must be an array"})'); + lines.push('\t}'); + helpers.add('fmt'); + } + + lines.push('\treturn errors'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitGoValidators(kindShapes: KindShape[]): string { @@ -391,7 +546,16 @@ export function emitGoValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitGoFile(fnBodies, constrainedKinds, allHelpers); + // Build content plans + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) { + contentPlans.set(shape.kindNumber, contentActions); + } + } + + return emitGoFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionGo( @@ -558,7 +722,14 @@ function emitGoFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + // Pre-generate event dispatch to collect helpers before emitting helper functions + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchGo(constrainedKinds, contentPlans, helpers); + } + const lines: string[] = [ '// Code generated by @nostrability/schemata-codegen. DO NOT EDIT.', '//', @@ -579,6 +750,9 @@ function emitGoFile( if (helpers.has('utf8')) { imports.push('"unicode/utf8"'); } + if (helpers.has('fmt')) { + imports.push('"fmt"'); + } if (imports.length > 0) { if (imports.length === 1) { lines.push(`import ${imports[0]}`); @@ -624,6 +798,12 @@ function emitGoFile( lines.push('}'); lines.push(''); + // Event validation + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-java.ts b/src/emit-java.ts index d23cb12..c42dbf4 100644 --- a/src/emit-java.ts +++ b/src/emit-java.ts @@ -18,7 +18,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -365,6 +367,149 @@ function renderTagMatcherJava( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsJava( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (content.length() < ${action.min}) {`); + lines.push(` errors.add(new ValidationError("content", "content must be at least ${action.min} character(s)"));`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (content.length() > ${action.max}) {`); + lines.push(` errors.add(new ValidationError("content", "content must be at most ${action.max} character(s)"));`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckJava(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + lines.push(` errors.add(new ValidationError("content", "content must match pattern " + ${JSON.stringify(action.regex)}));`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `content.equals(${JSON.stringify(v)})`).join(' || '); + lines.push(` if (!(${checks})) {`); + lines.push(` errors.add(new ValidationError("content", "content must be one of: " + ${JSON.stringify(action.values.join(', '))}));`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchJava( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push(' /** Validate an event\'s base fields, content constraints, and tag structure. */'); + lines.push(' @SuppressWarnings("unchecked")'); + lines.push(' public static List validateEvent(java.util.Map event) {'); + lines.push(' if (event == null) {'); + lines.push(' return List.of(new ValidationError("event", "event must be a non-null object"));'); + lines.push(' }'); + lines.push(' var errors = new ArrayList();'); + lines.push(' Object kindRaw = event.get("kind");'); + lines.push(' if (!(kindRaw instanceof Number) || ((Number) kindRaw).doubleValue() != ((Number) kindRaw).intValue()) {'); + lines.push(' errors.add(new ValidationError("kind", "kind must be an integer"));'); + lines.push(' return errors;'); + lines.push(' }'); + lines.push(' int kind = ((Number) kindRaw).intValue();'); + + // Base field checks + helpers.add('checkHex64'); + helpers.add('checkHex128'); + + lines.push(' Object idRaw = event.get("id");'); + lines.push(' if (!(idRaw instanceof String) || !checkHex64((String) idRaw)) {'); + lines.push(' errors.add(new ValidationError("id", "id must be a 64-char lowercase hex string"));'); + lines.push(' }'); + + lines.push(' Object pkRaw = event.get("pubkey");'); + lines.push(' if (!(pkRaw instanceof String) || !checkHex64((String) pkRaw)) {'); + lines.push(' errors.add(new ValidationError("pubkey", "pubkey must be a 64-char lowercase hex string"));'); + lines.push(' }'); + + lines.push(' Object sigRaw = event.get("sig");'); + lines.push(' if (!(sigRaw instanceof String) || !checkHex128((String) sigRaw)) {'); + lines.push(' errors.add(new ValidationError("sig", "sig must be a 128-char lowercase hex string"));'); + lines.push(' }'); + + lines.push(' Object caRaw = event.get("created_at");'); + lines.push(' if (!(caRaw instanceof Number) || ((Number) caRaw).doubleValue() != ((Number) caRaw).longValue() || ((Number) caRaw).longValue() < 0) {'); + lines.push(' errors.add(new ValidationError("created_at", "created_at must be a non-negative integer"));'); + lines.push(' }'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' Object contentRaw = event.get("content");'); + lines.push(' if (contentRaw == null && !event.containsKey("content")) {'); + lines.push(' errors.add(new ValidationError("content", "content is required"));'); + lines.push(' } else if (contentRaw instanceof String) {'); + lines.push(' String content = (String) contentRaw;'); + lines.push(' switch (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}: {`); + lines.push(...renderContentActionsJava(actions, helpers)); + lines.push(' break;'); + lines.push(' }'); + } + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("content", "content must be a string"));'); + lines.push(' }'); + } + + // Tag dispatch + if (sorted.length > 0) { + lines.push(' Object tagsRaw = event.get("tags");'); + lines.push(' if (tagsRaw == null && !event.containsKey("tags")) {'); + lines.push(' errors.add(new ValidationError("tags", "tags is required"));'); + lines.push(' } else if (tagsRaw instanceof List) {'); + lines.push(' var rawTags = (List) tagsRaw;'); + lines.push(' var tags = new ArrayList>(rawTags.size());'); + lines.push(' for (int i = 0; i < rawTags.size(); i++) {'); + lines.push(' Object t = rawTags.get(i);'); + lines.push(' if (t instanceof List) {'); + lines.push(' var arr = (List) t;'); + lines.push(' boolean valid = arr.stream().allMatch(v -> v instanceof String);'); + lines.push(' if (valid) {'); + lines.push(' tags.add((List) (List) arr);'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); + lines.push(' tags.add(List.of());'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); + lines.push(' tags.add(List.of());'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags", "tags must be a list"));'); + lines.push(' }'); + } + + lines.push(' return errors;'); + lines.push(' }'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitJavaValidators(kindShapes: KindShape[]): string { @@ -382,7 +527,13 @@ export function emitJavaValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitJavaFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitJavaFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionJava( @@ -500,7 +651,13 @@ function emitJavaFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchJava(constrainedKinds, contentPlans, helpers); + } + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -545,6 +702,12 @@ function emitJavaFile( lines.push(' default: return List.of();'); lines.push(' }'); lines.push(' }'); + + if (eventDispatchCode) { + lines.push(''); + lines.push(eventDispatchCode); + } + lines.push('}'); lines.push(''); diff --git a/src/emit-kotlin.ts b/src/emit-kotlin.ts index 8d3ac4a..1b099c6 100644 --- a/src/emit-kotlin.ts +++ b/src/emit-kotlin.ts @@ -11,7 +11,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -311,6 +313,133 @@ function renderTagMatcherKotlin( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsKotlin( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (content.length < ${action.min}) {`); + lines.push(` errors.add(ValidationError("content", "content must be at least ${action.min} character(s)"))`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (content.length > ${action.max}) {`); + lines.push(` errors.add(ValidationError("content", "content must be at most ${action.max} character(s)"))`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckKotlin(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + lines.push(` errors.add(ValidationError("content", "content must match pattern " + ${JSON.stringify(action.regex)}))`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `content == ${JSON.stringify(v)}`).join(' || '); + lines.push(` if (!(${checks})) {`); + lines.push(` errors.add(ValidationError("content", "content must be one of: " + ${JSON.stringify(action.values.join(', '))}))`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchKotlin( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('/** Validate an event\'s base fields, content constraints, and tag structure. */'); + lines.push('fun validateEvent(event: Map): List {'); + lines.push(' val errors = mutableListOf()'); + lines.push(' val kind = event["kind"]'); + lines.push(' if (kind !is Int) {'); + lines.push(' errors.add(ValidationError("kind", "kind must be an integer"))'); + lines.push(' return errors'); + lines.push(' }'); + + helpers.add('checkHex64'); + helpers.add('checkHex128'); + + lines.push(' val id = event["id"]'); + lines.push(' if (id !is String || !checkHex64(id)) {'); + lines.push(' errors.add(ValidationError("id", "id must be a 64-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' val pubkey = event["pubkey"]'); + lines.push(' if (pubkey !is String || !checkHex64(pubkey)) {'); + lines.push(' errors.add(ValidationError("pubkey", "pubkey must be a 64-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' val sig = event["sig"]'); + lines.push(' if (sig !is String || !checkHex128(sig)) {'); + lines.push(' errors.add(ValidationError("sig", "sig must be a 128-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' val createdAt = event["created_at"]'); + lines.push(' if (createdAt !is Int || createdAt < 0) {'); + lines.push(' errors.add(ValidationError("created_at", "created_at must be a non-negative integer"))'); + lines.push(' }'); + + if (contentKinds.length > 0) { + lines.push(' if (!event.containsKey("content")) {'); + lines.push(' errors.add(ValidationError("content", "content is required"))'); + lines.push(' } else {'); + lines.push(' val contentRaw = event["content"]'); + lines.push(' if (contentRaw is String) {'); + lines.push(' val content = contentRaw'); + lines.push(' when (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` ${kindNumber} -> {`); + lines.push(...renderContentActionsKotlin(actions, helpers)); + lines.push(' }'); + } + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("content", "content must be a string"))'); + lines.push(' }'); + lines.push(' }'); + } + + if (sorted.length > 0) { + lines.push(' if (!event.containsKey("tags")) {'); + lines.push(' errors.add(ValidationError("tags", "tags is required"))'); + lines.push(' } else {'); + lines.push(' val tagsRaw = event["tags"]'); + lines.push(' if (tagsRaw is List<*>) {'); + lines.push(' val tags = mutableListOf>()'); + lines.push(' for ((i, t) in tagsRaw.withIndex()) {'); + lines.push(' if (t is List<*> && t.all { it is String }) {'); + lines.push(' @Suppress("UNCHECKED_CAST")'); + lines.push(' tags.add(t as List)'); + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("tags[$i]", "tags[$i] must be a list of strings"))'); + lines.push(' tags.add(emptyList())'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags))'); + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("tags", "tags must be a list"))'); + lines.push(' }'); + lines.push(' }'); + } + + lines.push(' return errors'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitKotlinValidators(kindShapes: KindShape[]): string { @@ -328,7 +457,13 @@ export function emitKotlinValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitKotlinFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitKotlinFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionKotlin( @@ -446,7 +581,13 @@ function emitKotlinFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchKotlin(constrainedKinds, contentPlans, helpers); + } + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -482,6 +623,11 @@ function emitKotlinFile( lines.push('}'); lines.push(''); + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-php.ts b/src/emit-php.ts index 5d66ab7..eca9684 100644 --- a/src/emit-php.ts +++ b/src/emit-php.ts @@ -18,7 +18,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -364,6 +366,126 @@ function phpString(s: string): string { return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } +// --- Content validation --- + +function renderContentActionsPhp( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if (mb_strlen($content) < ${action.min}) {`); + lines.push(` $errors[] = new SchemataValidationError('content', 'content must be at least ${action.min} character(s)');`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if (mb_strlen($content) > ${action.max}) {`); + lines.push(` $errors[] = new SchemataValidationError('content', 'content must be at most ${action.max} character(s)');`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckPhp(action.native, '$content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if (!(${r.expr})) {`); + lines.push(` $errors[] = new SchemataValidationError('content', 'content must match pattern ${action.regex.replace(/'/g, "\\'")}');`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => `'${v}'`).join(', '); + lines.push(` if (!in_array($content, [${vals}], true)) {`); + lines.push(` $errors[] = new SchemataValidationError('content', 'content must be one of: ${action.values.join(', ')}');`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchPhp( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('/** Validate an event\'s base fields, content constraints, and tag structure. */'); + lines.push('function schemata_validate_event(array $event): array {'); + lines.push(' $errors = [];'); + lines.push(' $kind = $event[\'kind\'] ?? null;'); + lines.push(' if (!is_int($kind)) {'); + lines.push(" $errors[] = new SchemataValidationError('kind', 'kind must be an integer');"); + lines.push(' return $errors;'); + lines.push(' }'); + + helpers.add('schemata_check_hex64'); + helpers.add('schemata_check_hex128'); + + lines.push(" $id = $event['id'] ?? null;"); + lines.push(' if (!is_string($id) || !schemata_check_hex64($id)) {'); + lines.push(" $errors[] = new SchemataValidationError('id', 'id must be a 64-char lowercase hex string');"); + lines.push(' }'); + lines.push(" $pk = $event['pubkey'] ?? null;"); + lines.push(' if (!is_string($pk) || !schemata_check_hex64($pk)) {'); + lines.push(" $errors[] = new SchemataValidationError('pubkey', 'pubkey must be a 64-char lowercase hex string');"); + lines.push(' }'); + lines.push(" $sig = $event['sig'] ?? null;"); + lines.push(' if (!is_string($sig) || !schemata_check_hex128($sig)) {'); + lines.push(" $errors[] = new SchemataValidationError('sig', 'sig must be a 128-char lowercase hex string');"); + lines.push(' }'); + lines.push(" $ca = $event['created_at'] ?? null;"); + lines.push(' if (!is_int($ca) || $ca < 0) {'); + lines.push(" $errors[] = new SchemataValidationError('created_at', 'created_at must be a non-negative integer');"); + lines.push(' }'); + + if (contentKinds.length > 0) { + lines.push(" if (!array_key_exists('content', $event)) {"); + lines.push(" $errors[] = new SchemataValidationError('content', 'content is required');"); + lines.push(" } elseif (is_string($event['content'])) {"); + lines.push(" $content = $event['content'];"); + lines.push(' switch ($kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsPhp(actions, helpers)); + lines.push(' break;'); + } + lines.push(' }'); + lines.push(' } else {'); + lines.push(" $errors[] = new SchemataValidationError('content', 'content must be a string');"); + lines.push(' }'); + } + + if (sorted.length > 0) { + lines.push(" if (!array_key_exists('tags', $event)) {"); + lines.push(" $errors[] = new SchemataValidationError('tags', 'tags is required');"); + lines.push(" } elseif (is_array($event['tags'])) {"); + lines.push(' $tags = [];'); + lines.push(" foreach ($event['tags'] as $i => $t) {"); + lines.push(' if (is_array($t) && array_reduce($t, fn($carry, $v) => $carry && is_string($v), true)) {'); + lines.push(' $tags[] = array_values($t);'); + lines.push(' } else {'); + lines.push(' $errors[] = new SchemataValidationError("tags[$i]", "tags[$i] must be an array of strings");'); + lines.push(' $tags[] = [];'); + lines.push(' }'); + lines.push(' }'); + lines.push(' $errors = array_merge($errors, schemata_validate_kind_tags($kind, $tags));'); + lines.push(' } else {'); + lines.push(" $errors[] = new SchemataValidationError('tags', 'tags must be an array');"); + lines.push(' }'); + } + + lines.push(' return $errors;'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitPhpValidators(kindShapes: KindShape[]): string { @@ -381,7 +503,13 @@ export function emitPhpValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitPhpFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitPhpFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionPhp( @@ -559,7 +687,13 @@ function emitPhpFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchPhp(constrainedKinds, contentPlans, helpers); + } + const lines: string[] = [ ', +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if len(content) < ${action.min}:`); + lines.push(` errors.append(ValidationError(path="content", message="content must be at least ${action.min} character(s)"))`); + break; + case 'check_content_max_length': + lines.push(` if len(content) > ${action.max}:`); + lines.push(` errors.append(ValidationError(path="content", message="content must be at most ${action.max} character(s)"))`); + break; + case 'check_content_pattern': { + const r = renderPatternCheckPython(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if not (${r.expr}):`); + lines.push(` errors.append(ValidationError(path="content", message="content must match pattern " + ${JSON.stringify(action.regex)}))`); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => JSON.stringify(v)).join(', '); + lines.push(` if content not in [${vals}]:`); + lines.push(` errors.append(ValidationError(path="content", message="content must be one of: " + ${JSON.stringify(vals)}))`); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchPython( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push(''); + lines.push(''); + lines.push('def validate_event(event: dict[str, object]) -> list[ValidationError]:'); + lines.push(' """Validate an event\'s base fields, content constraints, and tag structure."""'); + lines.push(' if not isinstance(event, dict):'); + lines.push(' return [ValidationError(path="event", message="event must be a dict")]'); + lines.push(' errors: list[ValidationError] = []'); + lines.push(' kind = event.get("kind")'); + lines.push(' if not isinstance(kind, int) or isinstance(kind, bool):'); + lines.push(' errors.append(ValidationError(path="kind", message="kind must be an integer"))'); + lines.push(' return errors'); + + // Base field checks + helpers.add('_check_hex_64'); + helpers.add('_check_hex_128'); + + lines.push(' _id = event.get("id")'); + lines.push(' if not isinstance(_id, str) or not _check_hex_64(_id):'); + lines.push(' errors.append(ValidationError(path="id", message="id must be a 64-char lowercase hex string"))'); + lines.push(' _pk = event.get("pubkey")'); + lines.push(' if not isinstance(_pk, str) or not _check_hex_64(_pk):'); + lines.push(' errors.append(ValidationError(path="pubkey", message="pubkey must be a 64-char lowercase hex string"))'); + lines.push(' _sig = event.get("sig")'); + lines.push(' if not isinstance(_sig, str) or not _check_hex_128(_sig):'); + lines.push(' errors.append(ValidationError(path="sig", message="sig must be a 128-char lowercase hex string"))'); + lines.push(' _ca = event.get("created_at")'); + lines.push(' if not isinstance(_ca, int) or isinstance(_ca, bool) or _ca < 0:'); + lines.push(' errors.append(ValidationError(path="created_at", message="created_at must be a non-negative integer"))'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' _content = event.get("content")'); + lines.push(' if _content is None:'); + lines.push(' errors.append(ValidationError(path="content", message="content is required"))'); + lines.push(' elif isinstance(_content, str):'); + lines.push(' content = _content'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` if kind == ${kindNumber}:`); + lines.push(...renderContentActionsPython(actions, helpers)); + } + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path="content", message="content must be a string"))'); + } + + // Tag dispatch + if (sorted.length > 0) { + lines.push(' _tags = event.get("tags")'); + lines.push(' if _tags is None:'); + lines.push(' errors.append(ValidationError(path="tags", message="tags is required"))'); + lines.push(' elif isinstance(_tags, list):'); + lines.push(' tags: list[list[str]] = []'); + lines.push(' for i, t in enumerate(_tags):'); + lines.push(' if isinstance(t, list) and all(isinstance(v, str) for v in t):'); + lines.push(' tags.append(t)'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path=f"tags[{i}]", message=f"tags[{i}] must be a list of strings"))'); + lines.push(' tags.append([])'); + lines.push(' errors.extend(validate_kind_tags(kind, tags))'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path="tags", message="tags must be a list"))'); + } + + lines.push(' return errors'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitPythonValidators(kindShapes: KindShape[]): string { @@ -329,7 +443,16 @@ export function emitPythonValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitPythonFile(fnBodies, constrainedKinds, allHelpers); + // Build content plans + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) { + contentPlans.set(shape.kindNumber, contentActions); + } + } + + return emitPythonFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionPython( @@ -432,7 +555,14 @@ function emitPythonFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + // Pre-generate event dispatch to collect helpers before emitting helper functions + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchPython(constrainedKinds, contentPlans, helpers); + } + const needsRegex = helpers.has('_regex'); const lines: string[] = [ '# Auto-generated by @nostrability/schemata-codegen', @@ -495,6 +625,12 @@ function emitPythonFile( } lines.push(''); + // Event validation + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-ruby.ts b/src/emit-ruby.ts index 1393a73..817716a 100644 --- a/src/emit-ruby.ts +++ b/src/emit-ruby.ts @@ -17,7 +17,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -325,6 +327,116 @@ function rubyString(s: string): string { return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } +// --- Content validation --- + +function renderContentActionsRuby( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` errors << ValidationError.new('content', 'content must be at least ${action.min} character(s)') if content.length < ${action.min}`); + break; + case 'check_content_max_length': + lines.push(` errors << ValidationError.new('content', 'content must be at most ${action.max} character(s)') if content.length > ${action.max}`); + break; + case 'check_content_pattern': { + const r = renderPatternCheckRuby(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` errors << ValidationError.new('content', 'content must match pattern ${action.regex.replace(/'/g, "\\'")}') unless ${r.expr}`); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => `'${v}'`).join(', '); + lines.push(` errors << ValidationError.new('content', 'content must be one of: ${action.values.join(', ')}') unless [${vals}].include?(content)`); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchRuby( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push(' # Validate an event\'s base fields, content constraints, and tag structure.'); + lines.push(' def self.validate_event(event)'); + lines.push(' return [ValidationError.new(\'event\', \'event must be a Hash\')] unless event.is_a?(Hash)'); + lines.push(' errors = []'); + lines.push(' kind = event[\'kind\']'); + lines.push(' unless kind.is_a?(Integer)'); + lines.push(' errors << ValidationError.new(\'kind\', \'kind must be an integer\')'); + lines.push(' return errors'); + lines.push(' end'); + + helpers.add('check_hex_64'); + helpers.add('check_hex_128'); + + lines.push(' id = event[\'id\']'); + lines.push(' errors << ValidationError.new(\'id\', \'id must be a 64-char lowercase hex string\') unless id.is_a?(String) && check_hex_64(id)'); + lines.push(' pk = event[\'pubkey\']'); + lines.push(' errors << ValidationError.new(\'pubkey\', \'pubkey must be a 64-char lowercase hex string\') unless pk.is_a?(String) && check_hex_64(pk)'); + lines.push(' sig = event[\'sig\']'); + lines.push(' errors << ValidationError.new(\'sig\', \'sig must be a 128-char lowercase hex string\') unless sig.is_a?(String) && check_hex_128(sig)'); + lines.push(' ca = event[\'created_at\']'); + lines.push(' errors << ValidationError.new(\'created_at\', \'created_at must be a non-negative integer\') unless ca.is_a?(Integer) && ca >= 0'); + + if (contentKinds.length > 0) { + lines.push(" unless event.key?('content')"); + lines.push(" errors << ValidationError.new('content', 'content is required')"); + lines.push(' else'); + lines.push(" content_raw = event['content']"); + lines.push(' if content_raw.is_a?(String)'); + lines.push(' content = content_raw'); + lines.push(' case kind'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` when ${kindNumber}`); + lines.push(...renderContentActionsRuby(actions, helpers)); + } + lines.push(' end'); + lines.push(' else'); + lines.push(" errors << ValidationError.new('content', 'content must be a string')"); + lines.push(' end'); + lines.push(' end'); + } + + if (sorted.length > 0) { + lines.push(" unless event.key?('tags')"); + lines.push(" errors << ValidationError.new('tags', 'tags is required')"); + lines.push(' else'); + lines.push(" tags_raw = event['tags']"); + lines.push(' if tags_raw.is_a?(Array)'); + lines.push(' tags = []'); + lines.push(' tags_raw.each_with_index do |t, i|'); + lines.push(' if t.is_a?(Array) && t.all? { |v| v.is_a?(String) }'); + lines.push(' tags << t'); + lines.push(' else'); + lines.push(' errors << ValidationError.new("tags[#{i}]", "tags[#{i}] must be an array of strings")'); + lines.push(' tags << []'); + lines.push(' end'); + lines.push(' end'); + lines.push(' errors.concat(validate_kind_tags(kind, tags))'); + lines.push(' else'); + lines.push(" errors << ValidationError.new('tags', 'tags must be an array')"); + lines.push(' end'); + lines.push(' end'); + } + + lines.push(' errors'); + lines.push(' end'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitRubyValidators(kindShapes: KindShape[]): string { @@ -342,7 +454,13 @@ export function emitRubyValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitRubyFile(fnBodies, constrainedKinds, allHelpers); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitRubyFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionRuby( @@ -460,7 +578,13 @@ function emitRubyFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchRuby(constrainedKinds, contentPlans, helpers); + } + const needsSet = helpers.has('check_relay_url') || helpers.has('check_bech32') || helpers.has('check_a_tag') || helpers.has('check_ln_invoice') || helpers.has('ascii_ws') || helpers.has('check_package_id') || helpers.has('check_nostr_uri'); const lines: string[] = [ '# frozen_string_literal: true', @@ -504,6 +628,11 @@ function emitRubyFile( lines.push(' end'); lines.push(' end'); + if (eventDispatchCode) { + lines.push(''); + lines.push(eventDispatchCode); + } + lines.push('end'); lines.push(''); diff --git a/src/emit-rust.ts b/src/emit-rust.ts index f6c3054..516ffe1 100644 --- a/src/emit-rust.ts +++ b/src/emit-rust.ts @@ -15,7 +15,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -374,6 +376,158 @@ function renderTagMatcherRust( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsRust( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if content.len() < ${action.min} {`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must be at least ${action.min} character(s)" });`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if content.len() > ${action.max} {`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must be at most ${action.max} character(s)" });`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckRust(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if !(${r.expr}) {`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must match pattern ${action.regex.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" });`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const checks = action.values.map(v => `content == ${JSON.stringify(v)}`).join(' || '); + lines.push(` if !(${checks}) {`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must be one of: ${action.values.join(', ')}" });`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchRust( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, + adapter: RustApiAdapter, + api: RustApi, +): string { + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + const lines: string[] = []; + + if (api === 'generic') { + // Generic: struct-based event with string fields + helpers.add('check_hex_64'); + helpers.add('check_hex_128'); + + lines.push('/// A Nostr event for validation.'); + lines.push('pub struct SchemataEvent<\'a> {'); + lines.push(" pub id: &'a str,"); + lines.push(" pub pubkey: &'a str,"); + lines.push(" pub sig: &'a str,"); + lines.push(" pub content: &'a str,"); + lines.push(' pub created_at: i64,'); + lines.push(' pub kind: u32,'); + lines.push(" pub tags: &'a [&'a [&'a str]],"); + lines.push('}'); + lines.push(''); + lines.push("/// Validate an event's base fields, content constraints, and tag structure."); + lines.push("pub fn validate_event(event: &SchemataEvent<'_>) -> Vec {"); + lines.push(' let mut errors = Vec::new();'); + + // Base field checks + lines.push(' if !check_hex_64(event.id) {'); + lines.push(' errors.push(ValidationError { path: "id", message: "id must be a 64-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if !check_hex_64(event.pubkey) {'); + lines.push(' errors.push(ValidationError { path: "pubkey", message: "pubkey must be a 64-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if !check_hex_128(event.sig) {'); + lines.push(' errors.push(ValidationError { path: "sig", message: "sig must be a 128-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if event.created_at < 0 {'); + lines.push(' errors.push(ValidationError { path: "created_at", message: "created_at must be a non-negative integer" });'); + lines.push(' }'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' let content = event.content;'); + lines.push(' match event.kind {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` ${kindNumber} => {`); + lines.push(...renderContentActionsRust(actions, helpers)); + lines.push(' }'); + } + lines.push(' _ => {}'); + lines.push(' }'); + } + + // Tag dispatch + lines.push(' errors.extend(validate_kind_tags(event.kind, event.tags));'); + lines.push(' errors'); + lines.push('}'); + } else { + // For nostr and nostrdb APIs: type system already validates base fields + // Just add content validation + tag dispatch + if (api === 'nostr') { + lines.push("/// Validate an event's content constraints and tag structure."); + lines.push('pub fn validate_event(event: &Event) -> Vec {'); + lines.push(' let mut errors = Vec::new();'); + lines.push(' let kind = event.kind().as_u16() as u32;'); + + if (contentKinds.length > 0) { + lines.push(' let content = event.content().as_str();'); + lines.push(' match kind {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` ${kindNumber} => {`); + lines.push(...renderContentActionsRust(actions, helpers)); + lines.push(' }'); + } + lines.push(' _ => {}'); + lines.push(' }'); + } + + lines.push(' errors.extend(validate_kind_tags(kind, event.tags()));'); + } else { + // nostrdb + lines.push("/// Validate an event's content constraints and tag structure."); + lines.push('pub fn validate_event(note: &Note) -> Vec {'); + lines.push(' let mut errors = Vec::new();'); + lines.push(' let kind = note.kind() as u32;'); + + if (contentKinds.length > 0) { + lines.push(' let content = note.content();'); + lines.push(' match kind {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` ${kindNumber} => {`); + lines.push(...renderContentActionsRust(actions, helpers)); + lines.push(' }'); + } + lines.push(' _ => {}'); + lines.push(' }'); + } + + lines.push(' errors.extend(validate_kind_tags(kind, note.tags()));'); + } + lines.push(' errors'); + lines.push('}'); + } + + return lines.join('\n'); +} + // --- Main emitter --- export function emitRustValidators( @@ -395,7 +549,13 @@ export function emitRustValidators( for (const h of helpers) allHelpers.add(h); } - return emitRustFile(fnBodies, constrainedKinds, allHelpers, adapter); + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) contentPlans.set(shape.kindNumber, contentActions); + } + + return emitRustFile(fnBodies, constrainedKinds, allHelpers, adapter, contentPlans, api); } function emitKindFunctionRust( @@ -515,7 +675,16 @@ function emitRustFile( constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, adapter: RustApiAdapter, + contentPlans: Map, + api: RustApi, ): string { + // Pre-generate event dispatch so any helpers it needs (e.g. check_hex_128) + // are registered before helper emission. + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchRust(constrainedKinds, contentPlans, helpers, adapter, api); + } + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -549,6 +718,11 @@ function emitRustFile( lines.push('}'); lines.push(''); + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-swift.ts b/src/emit-swift.ts index 932687b..671a9a1 100644 --- a/src/emit-swift.ts +++ b/src/emit-swift.ts @@ -14,7 +14,9 @@ import type { KindShape } from './kind-types.js'; import { planKindValidator, + planContentChecks, type ValidatorAction, + type ContentAction, type TagMatcher, type ValueCheck, type PositionCheck, @@ -316,6 +318,141 @@ function renderTagMatcherSwift( return checks.join(' && '); } +// --- Content validation --- + +function renderContentActionsSwift( + actions: ContentAction[], + helpers: Set, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + lines.push(` if content.count < ${action.min} {`); + lines.push(` errors.append(ValidationError(path: "content", message: "content must be at least ${action.min} character(s)"))`); + lines.push(' }'); + break; + case 'check_content_max_length': + lines.push(` if content.count > ${action.max} {`); + lines.push(` errors.append(ValidationError(path: "content", message: "content must be at most ${action.max} character(s)"))`); + lines.push(' }'); + break; + case 'check_content_pattern': { + const r = renderPatternCheckSwift(action.native, 'content'); + for (const h of r.helpers) helpers.add(h); + lines.push(` if !(${r.expr}) {`); + lines.push(` errors.append(ValidationError(path: "content", message: "content must match pattern " + ${JSON.stringify(action.regex)}))`); + lines.push(' }'); + break; + } + case 'check_content_enum': { + const vals = action.values.map(v => JSON.stringify(v)).join(', '); + lines.push(` if ![${vals}].contains(content) {`); + lines.push(` errors.append(ValidationError(path: "content", message: "content must be one of: " + ${JSON.stringify(vals)}))`); + lines.push(' }'); + break; + } + } + } + return lines; +} + +// --- Event validation --- + +function emitEventDispatchSwift( + constrainedKinds: { kindNumber: number; nip: string }[], + contentPlans: Map, + helpers: Set, +): string { + const sorted = [...constrainedKinds].sort((a, b) => a.kindNumber - b.kindNumber); + const contentKinds = [...contentPlans.entries()].sort((a, b) => a[0] - b[0]); + + const lines: string[] = []; + lines.push('/// Validate an event\'s base fields, content constraints, and tag structure.'); + lines.push('public func validateEvent(event: [String: Any]) -> [ValidationError] {'); + lines.push(' var errors: [ValidationError] = []'); + lines.push(' guard let kind = event["kind"] as? Int else {'); + lines.push(' errors.append(ValidationError(path: "kind", message: "kind must be an integer"))'); + lines.push(' return errors'); + lines.push(' }'); + + // Base field checks + lines.push(' if let id = event["id"] as? String {'); + lines.push(' if !checkHex64(id) {'); + lines.push(' errors.append(ValidationError(path: "id", message: "id must be a 64-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "id", message: "id must be a 64-char lowercase hex string"))'); + lines.push(' }'); + helpers.add('checkHex64'); + + lines.push(' if let pubkey = event["pubkey"] as? String {'); + lines.push(' if !checkHex64(pubkey) {'); + lines.push(' errors.append(ValidationError(path: "pubkey", message: "pubkey must be a 64-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "pubkey", message: "pubkey must be a 64-char lowercase hex string"))'); + lines.push(' }'); + + lines.push(' if let sig = event["sig"] as? String {'); + lines.push(' if !checkHex128(sig) {'); + lines.push(' errors.append(ValidationError(path: "sig", message: "sig must be a 128-char lowercase hex string"))'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "sig", message: "sig must be a 128-char lowercase hex string"))'); + lines.push(' }'); + helpers.add('checkHex128'); + + lines.push(' if let createdAt = event["created_at"] as? Int {'); + lines.push(' if createdAt < 0 {'); + lines.push(' errors.append(ValidationError(path: "created_at", message: "created_at must be a non-negative integer"))'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "created_at", message: "created_at must be a non-negative integer"))'); + lines.push(' }'); + + // Content validation + if (contentKinds.length > 0) { + lines.push(' if let content = event["content"] as? String {'); + lines.push(' switch kind {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsSwift(actions, helpers)); + } + lines.push(' default: break'); + lines.push(' }'); + lines.push(' } else if event["content"] != nil {'); + lines.push(' errors.append(ValidationError(path: "content", message: "content must be a string"))'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "content", message: "content is required"))'); + lines.push(' }'); + } + + // Tag dispatch + if (sorted.length > 0) { + lines.push(' if let rawTags = event["tags"] as? [[Any]] {'); + lines.push(' var tags: [[String]] = []'); + lines.push(' for (i, t) in rawTags.enumerated() {'); + lines.push(' if let st = t as? [String] {'); + lines.push(' tags.append(st)'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "tags[\\(i)]", message: "tags[\\(i)] must be an array of strings"))'); + lines.push(' tags.append([])'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.append(contentsOf: validateKindTags(kind: kind, tags: tags))'); + lines.push(' } else if event["tags"] != nil {'); + lines.push(' errors.append(ValidationError(path: "tags", message: "tags must be an array"))'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "tags", message: "tags is required"))'); + lines.push(' }'); + } + + lines.push(' return errors'); + lines.push('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitSwiftValidators(kindShapes: KindShape[]): string { @@ -333,7 +470,16 @@ export function emitSwiftValidators(kindShapes: KindShape[]): string { for (const h of helpers) allHelpers.add(h); } - return emitSwiftFile(fnBodies, constrainedKinds, allHelpers); + // Build content plans + const contentPlans = new Map(); + for (const shape of kindShapes) { + const contentActions = planContentChecks(shape); + if (contentActions) { + contentPlans.set(shape.kindNumber, contentActions); + } + } + + return emitSwiftFile(fnBodies, constrainedKinds, allHelpers, contentPlans); } function emitKindFunctionSwift( @@ -451,7 +597,14 @@ function emitSwiftFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + // Pre-generate event dispatch to collect helpers before emitting helper functions + let eventDispatchCode: string | undefined; + if (constrainedKinds.length > 0 || contentPlans.size > 0) { + eventDispatchCode = emitEventDispatchSwift(constrainedKinds, contentPlans, helpers); + } + const needsFoundation = helpers.has('regex'); const lines: string[] = [ @@ -496,6 +649,12 @@ function emitSwiftFile( lines.push('}'); lines.push(''); + // Event validation + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/emit-validators.ts b/src/emit-validators.ts index 07ab3f0..68e6950 100644 --- a/src/emit-validators.ts +++ b/src/emit-validators.ts @@ -313,11 +313,25 @@ function emitEventDispatch( lines.push(' }'); lines.push(' const errors: ValidationError[] = [];'); lines.push(' const kind = event.kind;'); - lines.push(' if (typeof kind !== "number") {'); - lines.push(' errors.push({ path: "kind", message: "kind must be a number" });'); + lines.push(' if (typeof kind !== "number" || !Number.isInteger(kind)) {'); + lines.push(' errors.push({ path: "kind", message: "kind must be an integer" });'); lines.push(' return errors;'); lines.push(' }'); + // Base field validation + lines.push(' if (typeof event.id !== "string" || !/^[a-f0-9]{64}$/.test(event.id as string)) {'); + lines.push(' errors.push({ path: "id", message: "id must be a 64-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if (typeof event.pubkey !== "string" || !/^[a-f0-9]{64}$/.test(event.pubkey as string)) {'); + lines.push(' errors.push({ path: "pubkey", message: "pubkey must be a 64-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if (typeof event.sig !== "string" || !/^[a-f0-9]{128}$/.test(event.sig as string)) {'); + lines.push(' errors.push({ path: "sig", message: "sig must be a 128-char lowercase hex string" });'); + lines.push(' }'); + lines.push(' if (typeof event.created_at !== "number" || !Number.isInteger(event.created_at as number) || (event.created_at as number) < 0) {'); + lines.push(' errors.push({ path: "created_at", message: "created_at must be a non-negative integer" });'); + lines.push(' }'); + // Content validation if (contentKinds.length > 0) { lines.push(' if (event.content === undefined) {'); diff --git a/tests/emit-c.test.ts b/tests/emit-c.test.ts index d5d76e4..224befa 100644 --- a/tests/emit-c.test.ts +++ b/tests/emit-c.test.ts @@ -73,6 +73,17 @@ const conditionalKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitCValidators (generic, default)', () => { it('generates header and source files', () => { const { header, source } = emitCValidators([kind9735]); @@ -181,3 +192,40 @@ describe('emitCValidators full run', () => { assert.ok(source.length > 500); }); }); + +describe('validateEvent (generic)', () => { + it('generates schemata_validate_event in header', () => { + const result = emitCValidators([kindWithContent]); + assert.ok(result.header.includes('schemata_validate_event')); + }); + + it('generates schemata_validate_event in source', () => { + const result = emitCValidators([kindWithContent]); + assert.ok(result.source.includes('schemata_validate_event')); + }); + + it('validates hex fields in generic mode', () => { + const result = emitCValidators([kindWithContent]); + assert.ok(result.source.includes('schemata_check_hex64')); + assert.ok(result.source.includes('schemata_check_hex128')); + }); + + it('validates content for constrained kinds', () => { + const result = emitCValidators([kindWithContent]); + assert.ok(result.source.includes('strlen')); + }); + + it('dispatches to schemata_validate', () => { + const result = emitCValidators([kindWithContent]); + assert.ok(result.source.includes('schemata_validate(')); + }); +}); + +describe('validateEvent (nostrdb)', () => { + it('generates schemata_validate_event in nostrdb mode', () => { + const result = emitCValidators([kindWithContent], 'nostrdb'); + assert.ok(result.source.includes('schemata_validate_event')); + assert.ok(result.source.includes('ndb_note_id')); + assert.ok(result.source.includes('ndb_note_content')); + }); +}); diff --git a/tests/emit-cpp.test.ts b/tests/emit-cpp.test.ts index 97bc065..6d58662 100644 --- a/tests/emit-cpp.test.ts +++ b/tests/emit-cpp.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitCppValidators', () => { it('generates struct ValidationError', () => { const output = emitCppValidators([kind9735]); @@ -159,3 +170,32 @@ describe('emitCppValidators', () => { assert.ok(output.includes('struct ValidationError')); }); }); + +describe('validate_event', () => { + it('contains validate_event function', () => { + const output = emitCppValidators([kindWithContent]); + assert.ok(output.includes('validate_event(')); + }); + + it('validates base fields', () => { + const output = emitCppValidators([kindWithContent]); + assert.ok(output.includes('check_hex_64')); + assert.ok(output.includes('check_hex_128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitCppValidators([kindWithContent]); + assert.ok(output.includes('.size() < 1')); + }); + + it('dispatches to validate_kind_tags', () => { + const output = emitCppValidators([kindWithContent]); + assert.ok(output.includes('validate_kind_tags(')); + }); + + it('includes hex128 helper', () => { + const output = emitCppValidators([kindWithContent]); + assert.ok(output.includes('struct SchemataEvent')); + assert.ok(output.includes('check_hex_128')); + }); +}); diff --git a/tests/emit-csharp.test.ts b/tests/emit-csharp.test.ts index 516a9cb..0a01565 100644 --- a/tests/emit-csharp.test.ts +++ b/tests/emit-csharp.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitCSharpValidators', () => { it('generates record ValidationError', () => { const output = emitCSharpValidators([kind9735]); @@ -152,3 +163,31 @@ describe('emitCSharpValidators full run', () => { assert.ok(output.includes('using System.Linq;')); }); }); + +describe('ValidateEvent', () => { + it('contains ValidateEvent function', () => { + const output = emitCSharpValidators([kindWithContent]); + assert.ok(output.includes('ValidateEvent(')); + }); + + it('validates base fields', () => { + const output = emitCSharpValidators([kindWithContent]); + assert.ok(output.includes('CheckHex64')); + assert.ok(output.includes('CheckHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitCSharpValidators([kindWithContent]); + assert.ok(output.includes('.Length < 1')); + }); + + it('dispatches to ValidateKindTags', () => { + const output = emitCSharpValidators([kindWithContent]); + assert.ok(output.includes('ValidateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitCSharpValidators([kindWithContent]); + assert.ok(output.includes('bool CheckHex128')); + }); +}); diff --git a/tests/emit-dart.test.ts b/tests/emit-dart.test.ts index 72da288..ba11eae 100644 --- a/tests/emit-dart.test.ts +++ b/tests/emit-dart.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitDartValidators', () => { it('generates ValidationError class', () => { const output = emitDartValidators([kind9735]); @@ -160,3 +171,31 @@ describe('emitDartValidators full run', () => { assert.ok(!output.includes('List validateKind1(')); }); }); + +describe('validateEvent', () => { + it('contains validateEvent function', () => { + const output = emitDartValidators([kindWithContent]); + assert.ok(output.includes('validateEvent(')); + }); + + it('validates base fields', () => { + const output = emitDartValidators([kindWithContent]); + assert.ok(output.includes('_checkHex64')); + assert.ok(output.includes('_checkHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitDartValidators([kindWithContent]); + assert.ok(output.includes('.length < 1')); + }); + + it('dispatches to validateKindTags', () => { + const output = emitDartValidators([kindWithContent]); + assert.ok(output.includes('validateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitDartValidators([kindWithContent]); + assert.ok(output.includes('bool _checkHex128')); + }); +}); diff --git a/tests/emit-go.test.ts b/tests/emit-go.test.ts index d9af28e..79a642c 100644 --- a/tests/emit-go.test.ts +++ b/tests/emit-go.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitGoValidators', () => { it('generates ValidationError struct', () => { const output = emitGoValidators([kind9735]); @@ -175,3 +186,31 @@ describe('emitGoValidators', () => { assert.ok(!output.includes('func ValidateKind1(')); }); }); + +describe('ValidateEvent', () => { + it('contains ValidateEvent function', () => { + const output = emitGoValidators([kindWithContent]); + assert.ok(output.includes('func ValidateEvent(')); + }); + + it('validates base fields', () => { + const output = emitGoValidators([kindWithContent]); + assert.ok(output.includes('checkHex64')); + assert.ok(output.includes('checkHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitGoValidators([kindWithContent]); + assert.ok(output.includes('len(content) < 1')); + }); + + it('dispatches to ValidateKindTags', () => { + const output = emitGoValidators([kindWithContent]); + assert.ok(output.includes('ValidateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitGoValidators([kindWithContent]); + assert.ok(output.includes('func checkHex128')); + }); +}); diff --git a/tests/emit-java.test.ts b/tests/emit-java.test.ts index 4f825f6..9e8d529 100644 --- a/tests/emit-java.test.ts +++ b/tests/emit-java.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitJavaValidators', () => { it('generates ValidationError record', () => { const output = emitJavaValidators([kind9735]); @@ -182,3 +193,31 @@ describe('emitJavaValidators', () => { assert.ok(!output.includes('public static List validateKind1(')); }); }); + +describe('validateEvent', () => { + it('contains validateEvent function', () => { + const output = emitJavaValidators([kindWithContent]); + assert.ok(output.includes('validateEvent(')); + }); + + it('validates base fields', () => { + const output = emitJavaValidators([kindWithContent]); + assert.ok(output.includes('checkHex64')); + assert.ok(output.includes('checkHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitJavaValidators([kindWithContent]); + assert.ok(output.includes('.length() < 1')); + }); + + it('dispatches to validateKindTags', () => { + const output = emitJavaValidators([kindWithContent]); + assert.ok(output.includes('validateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitJavaValidators([kindWithContent]); + assert.ok(output.includes('boolean checkHex128')); + }); +}); diff --git a/tests/emit-kotlin.test.ts b/tests/emit-kotlin.test.ts index c8cb58a..2168184 100644 --- a/tests/emit-kotlin.test.ts +++ b/tests/emit-kotlin.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitKotlinValidators', () => { it('generates data class ValidationError', () => { const output = emitKotlinValidators([kind9735]); @@ -159,3 +170,31 @@ describe('emitKotlinValidators', () => { assert.ok(!output.includes('import kotlin.text.Regex')); }); }); + +describe('validateEvent', () => { + it('contains validateEvent function', () => { + const output = emitKotlinValidators([kindWithContent]); + assert.ok(output.includes('fun validateEvent(')); + }); + + it('validates base fields', () => { + const output = emitKotlinValidators([kindWithContent]); + assert.ok(output.includes('checkHex64')); + assert.ok(output.includes('checkHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitKotlinValidators([kindWithContent]); + assert.ok(output.includes('.length < 1')); + }); + + it('dispatches to validateKindTags', () => { + const output = emitKotlinValidators([kindWithContent]); + assert.ok(output.includes('validateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitKotlinValidators([kindWithContent]); + assert.ok(output.includes('fun checkHex128')); + }); +}); diff --git a/tests/emit-php.test.ts b/tests/emit-php.test.ts index 7beee98..e24c707 100644 --- a/tests/emit-php.test.ts +++ b/tests/emit-php.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitPhpValidators', () => { it('output starts with { const output = emitPhpValidators([kind9735]); @@ -222,3 +233,31 @@ describe('emitPhpValidators full run', () => { assert.ok(output.includes('strlen($s) === 64')); }); }); + +describe('schemata_validate_event', () => { + it('contains schemata_validate_event function', () => { + const output = emitPhpValidators([kindWithContent]); + assert.ok(output.includes('function schemata_validate_event(')); + }); + + it('validates base fields', () => { + const output = emitPhpValidators([kindWithContent]); + assert.ok(output.includes('schemata_check_hex64')); + assert.ok(output.includes('schemata_check_hex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitPhpValidators([kindWithContent]); + assert.ok(output.includes('mb_strlen($content) < 1')); + }); + + it('dispatches to schemata_validate_kind_tags', () => { + const output = emitPhpValidators([kindWithContent]); + assert.ok(output.includes('schemata_validate_kind_tags(')); + }); + + it('includes hex128 helper', () => { + const output = emitPhpValidators([kindWithContent]); + assert.ok(output.includes('function schemata_check_hex128')); + }); +}); diff --git a/tests/emit-python.test.ts b/tests/emit-python.test.ts index b8938e2..371d921 100644 --- a/tests/emit-python.test.ts +++ b/tests/emit-python.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitPythonValidators', () => { it('generates ValidationError dataclass', () => { const output = emitPythonValidators([kind9735]); @@ -199,3 +210,31 @@ describe('emitPythonValidators full run', () => { assert.ok(output.includes('.get(kind)')); }); }); + +describe('validate_event', () => { + it('contains validate_event function', () => { + const output = emitPythonValidators([kindWithContent]); + assert.ok(output.includes('def validate_event(')); + }); + + it('validates base fields', () => { + const output = emitPythonValidators([kindWithContent]); + assert.ok(output.includes('_check_hex_64')); + assert.ok(output.includes('_check_hex_128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitPythonValidators([kindWithContent]); + assert.ok(output.includes('len(content) < 1')); + }); + + it('dispatches to validate_kind_tags', () => { + const output = emitPythonValidators([kindWithContent]); + assert.ok(output.includes('validate_kind_tags(')); + }); + + it('includes hex128 helper', () => { + const output = emitPythonValidators([kindWithContent]); + assert.ok(output.includes('def _check_hex_128')); + }); +}); diff --git a/tests/emit-ruby.test.ts b/tests/emit-ruby.test.ts index c235472..346e0de 100644 --- a/tests/emit-ruby.test.ts +++ b/tests/emit-ruby.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitRubyValidators', () => { it('generates ValidationError Struct', () => { const output = emitRubyValidators([kind9735]); @@ -181,3 +192,31 @@ describe('emitRubyValidators', () => { assert.ok(output.includes('else []')); }); }); + +describe('validate_event', () => { + it('contains validate_event function', () => { + const output = emitRubyValidators([kindWithContent]); + assert.ok(output.includes('def self.validate_event(')); + }); + + it('validates base fields', () => { + const output = emitRubyValidators([kindWithContent]); + assert.ok(output.includes('check_hex_64')); + assert.ok(output.includes('check_hex_128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitRubyValidators([kindWithContent]); + assert.ok(output.includes('.length < 1')); + }); + + it('dispatches to validate_kind_tags', () => { + const output = emitRubyValidators([kindWithContent]); + assert.ok(output.includes('validate_kind_tags(')); + }); + + it('includes hex128 helper', () => { + const output = emitRubyValidators([kindWithContent]); + assert.ok(output.includes('def self.check_hex_128')); + }); +}); diff --git a/tests/emit-rust.test.ts b/tests/emit-rust.test.ts index 4724457..01bcf6e 100644 --- a/tests/emit-rust.test.ts +++ b/tests/emit-rust.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitRustValidators (generic, default)', () => { it('generates ValidationError struct', () => { const output = emitRustValidators([kind9735]); @@ -183,3 +194,45 @@ describe('emitRustValidators full run', () => { assert.ok(!output.includes('pub fn validate_kind_1(')); }); }); + +describe('validateEvent (generic)', () => { + it('contains validate_event function', () => { + const output = emitRustValidators([kindWithContent]); + assert.ok(output.includes('fn validate_event(')); + }); + + it('generates SchemataEvent struct', () => { + const output = emitRustValidators([kindWithContent]); + assert.ok(output.includes('pub struct SchemataEvent')); + }); + + it('validates hex fields', () => { + const output = emitRustValidators([kindWithContent]); + assert.ok(output.includes('check_hex_64')); + assert.ok(output.includes('check_hex_128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitRustValidators([kindWithContent]); + assert.ok(output.includes('content.len() < 1')); + }); + + it('dispatches to validate_kind_tags', () => { + const output = emitRustValidators([kindWithContent]); + assert.ok(output.includes('validate_kind_tags(')); + }); +}); + +describe('validateEvent (nostr API)', () => { + it('generates validate_event for nostr API', () => { + const output = emitRustValidators([kindWithContent], 'nostr'); + assert.ok(output.includes('fn validate_event(event: &Event)')); + }); +}); + +describe('validateEvent (nostrdb API)', () => { + it('generates validate_event for nostrdb API', () => { + const output = emitRustValidators([kindWithContent], 'nostrdb'); + assert.ok(output.includes('fn validate_event(note: &Note)')); + }); +}); diff --git a/tests/emit-swift.test.ts b/tests/emit-swift.test.ts index 0e8a713..4d20280 100644 --- a/tests/emit-swift.test.ts +++ b/tests/emit-swift.test.ts @@ -77,6 +77,17 @@ const anyOfKind: KindShape = { category: 'conditional', }; +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + describe('emitSwiftValidators', () => { it('generates ValidationError struct', () => { const output = emitSwiftValidators([kind9735]); @@ -176,3 +187,31 @@ describe('emitSwiftValidators full run', () => { assert.ok(idx9735 < idx10002, 'kind 9735 should come before 10002'); }); }); + +describe('validateEvent', () => { + it('contains validateEvent function', () => { + const output = emitSwiftValidators([kindWithContent]); + assert.ok(output.includes('func validateEvent(')); + }); + + it('validates base fields', () => { + const output = emitSwiftValidators([kindWithContent]); + assert.ok(output.includes('checkHex64')); + assert.ok(output.includes('checkHex128')); + }); + + it('validates content for constrained kinds', () => { + const output = emitSwiftValidators([kindWithContent]); + assert.ok(output.includes('.count < 1')); + }); + + it('dispatches to validateKindTags', () => { + const output = emitSwiftValidators([kindWithContent]); + assert.ok(output.includes('validateKindTags(')); + }); + + it('includes hex128 helper', () => { + const output = emitSwiftValidators([kindWithContent]); + assert.ok(output.includes('func checkHex128')); + }); +}); diff --git a/tests/emit-validators.test.ts b/tests/emit-validators.test.ts index f4d0c83..3a008bd 100644 --- a/tests/emit-validators.test.ts +++ b/tests/emit-validators.test.ts @@ -267,3 +267,43 @@ describe('emitValidatorsFile', () => { assert.ok(output.includes('content must be one of')); }); }); + +const kindWithContent: KindShape = { + kindNumber: 13, + nip: 'nip-59', + requiredTags: [], + perItemConditionals: [], + arrayLevelConditionals: [], + anyOfTagGroups: [], + contentConstraints: { minLength: 1 }, + category: 'bare', +}; + +describe('validateEvent', () => { + it('contains validateEvent function', () => { + const output = emitValidatorsFile([imetaShape], [kindWithContent]); + assert.ok(output.includes('function validateEvent(')); + }); + + it('validates base fields', () => { + const output = emitValidatorsFile([], [kindWithContent]); + assert.ok(output.includes('[a-f0-9]{64}')); + assert.ok(output.includes('[a-f0-9]{128}')); + assert.ok(output.includes('64-char')); + assert.ok(output.includes('128-char')); + assert.ok(output.includes('id')); + assert.ok(output.includes('pubkey')); + assert.ok(output.includes('sig')); + assert.ok(output.includes('created_at')); + }); + + it('validates content for constrained kinds', () => { + const output = emitValidatorsFile([], [kindWithContent]); + assert.ok(output.includes('content.length < 1')); + }); + + it('dispatches to validateKindTags', () => { + const output = emitValidatorsFile([], [kindWithContent, kind9735]); + assert.ok(output.includes('validateKindTags(')); + }); +}); From 69cef89d214971279a88b17f60c6c20d8e2fd637 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 1 Apr 2026 13:05:42 -0500 Subject: [PATCH 2/4] fix: address review feedback on validateEvent across all emitters - Make content/tags presence checks unconditional (not gated by constraints) - Always emit validateEvent even when all kinds are bare - Go: accept int/int64/float64 for kind/created_at, [][]string for tags - C#: accept int and long for kind/created_at (Y2038-safe) - Kotlin: accept Int and Long for kind/created_at - Rust: content.chars().count() instead of content.len() (Unicode) - PHP: mb_strlen with explicit UTF-8 encoding, escape enum values - Ruby: escape enum values via rubyString() - C: null-check tags/tag_lens before dispatch Co-Authored-By: Claude Opus 4.6 --- src/emit-c.ts | 52 ++++++++-------- src/emit-cpp.ts | 11 +--- src/emit-csharp.ts | 69 +++++++++++---------- src/emit-dart.ts | 65 ++++++++++---------- src/emit-go.ts | 130 ++++++++++++++++++++++++---------------- src/emit-java.ts | 73 +++++++++++----------- src/emit-kotlin.ts | 79 ++++++++++++------------ src/emit-php.ts | 59 +++++++++--------- src/emit-python.ts | 48 +++++++-------- src/emit-ruby.ts | 67 ++++++++++----------- src/emit-rust.ts | 12 ++-- src/emit-swift.ts | 51 +++++++--------- src/emit-validators.ts | 64 +++++++++----------- tests/emit-php.test.ts | 2 +- tests/emit-rust.test.ts | 2 +- 15 files changed, 388 insertions(+), 396 deletions(-) diff --git a/src/emit-c.ts b/src/emit-c.ts index 6b05a91..e682179 100644 --- a/src/emit-c.ts +++ b/src/emit-c.ts @@ -479,10 +479,10 @@ function emitEventFunctionC( lines.push(' if (ndb_note_created_at(note) < 0) SCHEMATA_EMIT_ERR(errs, n, max_errs, "created_at", "created_at must be a non-negative integer");'); // Content validation + lines.push(' const char *_content = ndb_note_content(note);'); + lines.push(' if (!_content) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); if (contentKinds.length > 0) { - lines.push(' const char *_content = ndb_note_content(note);'); - lines.push(' if (!_content) {'); - lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); lines.push(' } else {'); lines.push(` switch (${adapter.kindExpr}) {`); for (const [kindNumber, actions] of contentKinds) { @@ -491,8 +491,8 @@ function emitEventFunctionC( lines.push(' break;'); } lines.push(' }'); - lines.push(' }'); } + lines.push(' }'); // Tag dispatch lines.push(' {'); @@ -520,9 +520,9 @@ function emitEventFunctionC( lines.push(' if (created_at < 0) SCHEMATA_EMIT_ERR(errs, n, max_errs, "created_at", "created_at must be a non-negative integer");'); // Content validation + lines.push(' if (!content) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); if (contentKinds.length > 0) { - lines.push(' if (!content) {'); - lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); lines.push(' } else {'); lines.push(' switch (kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -531,15 +531,20 @@ function emitEventFunctionC( lines.push(' break;'); } lines.push(' }'); - lines.push(' }'); } + lines.push(' }'); - // Tag dispatch - lines.push(' {'); + // Tag dispatch — null-check tags/tag_lens before dispatch + lines.push(' if (num_tags > 0 && tags && tag_lens) {'); lines.push(' int _remaining = max_errs - n;'); lines.push(' if (_remaining > 0) {'); lines.push(' n += schemata_validate(kind, tags, tag_lens, num_tags, errs + n, _remaining);'); lines.push(' }'); + lines.push(' } else if (num_tags == 0) {'); + lines.push(' int _remaining = max_errs - n;'); + lines.push(' if (_remaining > 0) {'); + lines.push(' n += schemata_validate(kind, tags, tag_lens, 0, errs + n, _remaining);'); + lines.push(' }'); lines.push(' }'); } @@ -762,7 +767,7 @@ function emitHeaderFile( } // int64_t needed for generic API event function - if (api === 'generic' && (constrainedKinds.length > 0 || contentPlans.size > 0)) { + if (api === 'generic') { lines.push('#include '); lines.push(''); } @@ -788,19 +793,17 @@ function emitHeaderFile( lines.push(''); // Event validation declaration - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - lines.push('/* Validate an entire event: base fields + content + tags */'); - if (api === 'nostrdb') { - lines.push('int schemata_validate_event(const struct ndb_note *note,'); - lines.push(' struct schemata_error *errs, int max_errs);'); - } else { - lines.push('int schemata_validate_event(int kind, const char *id, const char *pubkey,'); - lines.push(' const char *sig, int64_t created_at, const char *content,'); - lines.push(' const char *const *const *tags, const int *tag_lens, int num_tags,'); - lines.push(' struct schemata_error *errs, int max_errs);'); - } - lines.push(''); + lines.push('/* Validate an entire event: base fields + content + tags */'); + if (api === 'nostrdb') { + lines.push('int schemata_validate_event(const struct ndb_note *note,'); + lines.push(' struct schemata_error *errs, int max_errs);'); + } else { + lines.push('int schemata_validate_event(int kind, const char *id, const char *pubkey,'); + lines.push(' const char *sig, int64_t created_at, const char *content,'); + lines.push(' const char *const *const *tags, const int *tag_lens, int num_tags,'); + lines.push(' struct schemata_error *errs, int max_errs);'); } + lines.push(''); lines.push('#ifdef __cplusplus'); lines.push('}'); @@ -822,10 +825,7 @@ function emitSourceFile( contentPlans: Map, ): string { // Pre-generate event function BEFORE helpers so helper references are registered - let eventFnCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventFnCode = emitEventFunctionC(constrainedKinds, contentPlans, helpers, adapter, api); - } + const eventFnCode = emitEventFunctionC(constrainedKinds, contentPlans, helpers, adapter, api); const lines: string[] = [ '/* Auto-generated by @nostrability/schemata-codegen */', diff --git a/src/emit-cpp.ts b/src/emit-cpp.ts index beafadf..b1a304d 100644 --- a/src/emit-cpp.ts +++ b/src/emit-cpp.ts @@ -403,10 +403,8 @@ function emitEventDispatchCpp( lines.push(' }'); } - if (sorted.length > 0) { - lines.push(' auto tag_errors = validate_kind_tags(event.kind, event.tags);'); - lines.push(' errors.insert(errors.end(), tag_errors.begin(), tag_errors.end());'); - } + lines.push(' auto tag_errors = validate_kind_tags(event.kind, event.tags);'); + lines.push(' errors.insert(errors.end(), tag_errors.begin(), tag_errors.end());'); lines.push(' return errors;'); lines.push('}'); @@ -556,10 +554,7 @@ function emitCppFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchCpp(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchCpp(constrainedKinds, contentPlans, helpers); const needsRegex = helpers.has('regex'); diff --git a/src/emit-csharp.ts b/src/emit-csharp.ts index 7c213d3..9116d41 100644 --- a/src/emit-csharp.ts +++ b/src/emit-csharp.ts @@ -369,7 +369,14 @@ function emitEventDispatchCSharp( lines.push(' /// Validate an event\'s base fields, content constraints, and tag structure.'); lines.push(' public static List ValidateEvent(IDictionary ev) {'); lines.push(' var errors = new List();'); - lines.push(' if (!ev.TryGetValue("kind", out var kindRaw) || kindRaw is not int kind) {'); + lines.push(' if (!ev.TryGetValue("kind", out var kindRaw)) {'); + lines.push(' errors.Add(new ValidationError("kind", "kind must be an integer"));'); + lines.push(' return errors;'); + lines.push(' }'); + lines.push(' int kind;'); + lines.push(' if (kindRaw is int ki) kind = ki;'); + lines.push(' else if (kindRaw is long kl) kind = (int)kl;'); + lines.push(' else {'); lines.push(' errors.Add(new ValidationError("kind", "kind must be an integer"));'); lines.push(' return errors;'); lines.push(' }'); @@ -386,14 +393,19 @@ function emitEventDispatchCSharp( lines.push(' if (!ev.TryGetValue("sig", out var sigRaw) || sigRaw is not string sigStr || !CheckHex128(sigStr)) {'); lines.push(' errors.Add(new ValidationError("sig", "sig must be a 128-char lowercase hex string"));'); lines.push(' }'); - lines.push(' if (!ev.TryGetValue("created_at", out var caRaw) || caRaw is not int caVal || caVal < 0) {'); - lines.push(' errors.Add(new ValidationError("created_at", "created_at must be a non-negative integer"));'); + lines.push(' {'); + lines.push(' bool caOk = false;'); + lines.push(' if (ev.TryGetValue("created_at", out var caRaw)) {'); + lines.push(' if (caRaw is int ci) caOk = ci >= 0;'); + lines.push(' else if (caRaw is long cl) caOk = cl >= 0;'); + lines.push(' }'); + lines.push(' if (!caOk) errors.Add(new ValidationError("created_at", "created_at must be a non-negative integer"));'); lines.push(' }'); + lines.push(' if (!ev.ContainsKey("content")) {'); + lines.push(' errors.Add(new ValidationError("content", "content is required"));'); + lines.push(' } else if (ev.TryGetValue("content", out var contentRaw) && contentRaw is string content) {'); if (contentKinds.length > 0) { - lines.push(' if (!ev.ContainsKey("content")) {'); - lines.push(' errors.Add(new ValidationError("content", "content is required"));'); - lines.push(' } else if (ev.TryGetValue("content", out var contentRaw) && contentRaw is string content) {'); lines.push(' switch (kind) {'); for (const [kindNumber, actions] of contentKinds) { lines.push(` case ${kindNumber}: {`); @@ -402,29 +414,27 @@ function emitEventDispatchCSharp( lines.push(' }'); } lines.push(' }'); - lines.push(' } else {'); - lines.push(' errors.Add(new ValidationError("content", "content must be a string"));'); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError("content", "content must be a string"));'); + lines.push(' }'); - if (sorted.length > 0) { - lines.push(' if (!ev.ContainsKey("tags")) {'); - lines.push(' errors.Add(new ValidationError("tags", "tags is required"));'); - lines.push(' } else if (ev.TryGetValue("tags", out var tagsRaw) && tagsRaw is System.Collections.IList rawList) {'); - lines.push(' var tags = new List>();'); - lines.push(' for (var i = 0; i < rawList.Count; i++) {'); - lines.push(' if (rawList[i] is System.Collections.IList inner && inner.Cast().All(v => v is string)) {'); - lines.push(' tags.Add(inner.Cast().Select(v => (string)v!).ToList());'); - lines.push(' } else {'); - lines.push(' errors.Add(new ValidationError($"tags[{i}]", $"tags[{i}] must be a list of strings"));'); - lines.push(' tags.Add(new List());'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.AddRange(ValidateKindTags(kind, tags));'); - lines.push(' } else {'); - lines.push(' errors.Add(new ValidationError("tags", "tags must be a list"));'); - lines.push(' }'); - } + lines.push(' if (!ev.ContainsKey("tags")) {'); + lines.push(' errors.Add(new ValidationError("tags", "tags is required"));'); + lines.push(' } else if (ev.TryGetValue("tags", out var tagsRaw) && tagsRaw is System.Collections.IList rawList) {'); + lines.push(' var tags = new List>();'); + lines.push(' for (var i = 0; i < rawList.Count; i++) {'); + lines.push(' if (rawList[i] is System.Collections.IList inner && inner.Cast().All(v => v is string)) {'); + lines.push(' tags.Add(inner.Cast().Select(v => (string)v!).ToList());'); + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError($"tags[{i}]", $"tags[{i}] must be a list of strings"));'); + lines.push(' tags.Add(new List());'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.AddRange(ValidateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(' errors.Add(new ValidationError("tags", "tags must be a list"));'); + lines.push(' }'); lines.push(' return errors;'); lines.push(' }'); @@ -574,10 +584,7 @@ function emitCSharpFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchCSharp(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchCSharp(constrainedKinds, contentPlans, helpers); const needsRegex = helpers.has('regex'); diff --git a/src/emit-dart.ts b/src/emit-dart.ts index 573a218..0fd5d01 100644 --- a/src/emit-dart.ts +++ b/src/emit-dart.ts @@ -403,12 +403,12 @@ function emitEventDispatchDart( lines.push(" errors.add(ValidationError(path: 'created_at', message: 'created_at must be a non-negative integer'));"); lines.push(' }'); + lines.push(' if (!event.containsKey(\'content\')) {'); + lines.push(" errors.add(ValidationError(path: 'content', message: 'content is required'));"); + lines.push(' } else {'); + lines.push(' final contentRaw = event[\'content\'];'); + lines.push(' if (contentRaw is String) {'); if (contentKinds.length > 0) { - lines.push(' if (!event.containsKey(\'content\')) {'); - lines.push(" errors.add(ValidationError(path: 'content', message: 'content is required'));"); - lines.push(' } else {'); - lines.push(' final contentRaw = event[\'content\'];'); - lines.push(' if (contentRaw is String) {'); lines.push(' final content = contentRaw;'); lines.push(' switch (kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -416,34 +416,32 @@ function emitEventDispatchDart( lines.push(...renderContentActionsDart(actions, helpers)); } lines.push(' }'); - lines.push(' } else {'); - lines.push(" errors.add(ValidationError(path: 'content', message: 'content must be a string'));"); - lines.push(' }'); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'content', message: 'content must be a string'));"); + lines.push(' }'); + lines.push(' }'); - if (sorted.length > 0) { - lines.push(' if (!event.containsKey(\'tags\')) {'); - lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags is required'));"); - lines.push(' } else {'); - lines.push(' final tagsRaw = event[\'tags\'];'); - lines.push(' if (tagsRaw is List) {'); - lines.push(' final tags = >[];'); - lines.push(' for (var i = 0; i < tagsRaw.length; i++) {'); - lines.push(' final t = tagsRaw[i];'); - lines.push(' if (t is List && t.every((v) => v is String)) {'); - lines.push(' tags.add(t.cast());'); - lines.push(' } else {'); - lines.push(" errors.add(ValidationError(path: 'tags[\$i]', message: 'tags[\$i] must be a list of strings'));"); - lines.push(' tags.add([]);'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.addAll(validateKindTags(kind, tags));'); - lines.push(' } else {'); - lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags must be a list'));"); - lines.push(' }'); - lines.push(' }'); - } + lines.push(' if (!event.containsKey(\'tags\')) {'); + lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags is required'));"); + lines.push(' } else {'); + lines.push(' final tagsRaw = event[\'tags\'];'); + lines.push(' if (tagsRaw is List) {'); + lines.push(' final tags = >[];'); + lines.push(' for (var i = 0; i < tagsRaw.length; i++) {'); + lines.push(' final t = tagsRaw[i];'); + lines.push(' if (t is List && t.every((v) => v is String)) {'); + lines.push(' tags.add(t.cast());'); + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'tags[\$i]', message: 'tags[\$i] must be a list of strings'));"); + lines.push(' tags.add([]);'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'tags', message: 'tags must be a list'));"); + lines.push(' }'); + lines.push(' }'); lines.push(' return errors;'); lines.push('}'); @@ -593,10 +591,7 @@ function emitDartFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchDart(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchDart(constrainedKinds, contentPlans, helpers); const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', diff --git a/src/emit-go.ts b/src/emit-go.ts index b58cef8..c29d3af 100644 --- a/src/emit-go.ts +++ b/src/emit-go.ts @@ -435,12 +435,22 @@ function emitEventDispatchGo( lines.push('\t\terrors = append(errors, ValidationError{Path: "kind", Message: "kind must be an integer"})'); lines.push('\t\treturn errors'); lines.push('\t}'); - lines.push('\tkindFloat, ok := kindRaw.(float64)'); - lines.push('\tif !ok || kindFloat != float64(int(kindFloat)) {'); + lines.push('\tvar kind int'); + lines.push('\tswitch k := kindRaw.(type) {'); + lines.push('\tcase int:'); + lines.push('\t\tkind = k'); + lines.push('\tcase int64:'); + lines.push('\t\tkind = int(k)'); + lines.push('\tcase float64:'); + lines.push('\t\tif k != float64(int(k)) {'); + lines.push('\t\t\terrors = append(errors, ValidationError{Path: "kind", Message: "kind must be an integer"})'); + lines.push('\t\t\treturn errors'); + lines.push('\t\t}'); + lines.push('\t\tkind = int(k)'); + lines.push('\tdefault:'); lines.push('\t\terrors = append(errors, ValidationError{Path: "kind", Message: "kind must be an integer"})'); lines.push('\t\treturn errors'); lines.push('\t}'); - lines.push('\tkind := int(kindFloat)'); // Base field checks helpers.add('checkHex64'); @@ -466,63 +476,84 @@ function emitEventDispatchGo( lines.push('\tif caRaw, ok := event["created_at"]; !ok {'); lines.push('\t\terrors = append(errors, ValidationError{Path: "created_at", Message: "created_at must be a non-negative integer"})'); - lines.push('\t} else if caFloat, ok := caRaw.(float64); !ok || caFloat != float64(int64(caFloat)) || caFloat < 0 {'); - lines.push('\t\terrors = append(errors, ValidationError{Path: "created_at", Message: "created_at must be a non-negative integer"})'); + lines.push('\t} else {'); + lines.push('\t\tvar caOk bool'); + lines.push('\t\tswitch c := caRaw.(type) {'); + lines.push('\t\tcase int:'); + lines.push('\t\t\tcaOk = c >= 0'); + lines.push('\t\tcase int64:'); + lines.push('\t\t\tcaOk = c >= 0'); + lines.push('\t\tcase float64:'); + lines.push('\t\t\tcaOk = c == float64(int64(c)) && c >= 0'); + lines.push('\t\t}'); + lines.push('\t\tif !caOk {'); + lines.push('\t\t\terrors = append(errors, ValidationError{Path: "created_at", Message: "created_at must be a non-negative integer"})'); + lines.push('\t\t}'); lines.push('\t}'); // Content validation + lines.push('\tcontentRaw, hasContent := event["content"]'); + lines.push('\tif !hasContent {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content is required"})'); + lines.push('\t} else if content, ok := contentRaw.(string); ok {'); if (contentKinds.length > 0) { - lines.push('\tcontentRaw, hasContent := event["content"]'); - lines.push('\tif !hasContent {'); - lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content is required"})'); - lines.push('\t} else if content, ok := contentRaw.(string); ok {'); lines.push('\t\tswitch kind {'); for (const [kindNumber, actions] of contentKinds) { lines.push(`\t\tcase ${kindNumber}:`); lines.push(...renderContentActionsGo(actions, helpers)); } lines.push('\t\t}'); - lines.push('\t} else {'); - lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be a string"})'); - lines.push('\t}'); } + lines.push('\t\t_ = content'); + lines.push('\t} else {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be a string"})'); + lines.push('\t}'); // Tag dispatch - if (sorted.length > 0) { - lines.push('\ttagsRaw, hasTags := event["tags"]'); - lines.push('\tif !hasTags {'); - lines.push('\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags is required"})'); - lines.push('\t} else if tagsSlice, ok := tagsRaw.([]interface{}); ok {'); - lines.push('\t\ttags := make([][]string, 0, len(tagsSlice))'); - lines.push('\t\tfor i, raw := range tagsSlice {'); - lines.push('\t\t\tif arr, ok := raw.([]interface{}); ok {'); - lines.push('\t\t\t\tvalid := true'); - lines.push('\t\t\t\tstrs := make([]string, len(arr))'); - lines.push('\t\t\t\tfor j, v := range arr {'); - lines.push('\t\t\t\t\tif s, ok := v.(string); ok {'); - lines.push('\t\t\t\t\t\tstrs[j] = s'); - lines.push('\t\t\t\t\t} else {'); - lines.push('\t\t\t\t\t\tvalid = false'); - lines.push('\t\t\t\t\t\tbreak'); - lines.push('\t\t\t\t\t}'); - lines.push('\t\t\t\t}'); - lines.push('\t\t\t\tif valid {'); - lines.push('\t\t\t\t\ttags = append(tags, strs)'); - lines.push('\t\t\t\t} else {'); - lines.push('\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); - lines.push('\t\t\t\t\ttags = append(tags, nil)'); - lines.push('\t\t\t\t}'); - lines.push('\t\t\t} else {'); - lines.push('\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); - lines.push('\t\t\t\ttags = append(tags, nil)'); - lines.push('\t\t\t}'); - lines.push('\t\t}'); - lines.push('\t\terrors = append(errors, ValidateKindTags(kind, tags)...)'); - lines.push('\t} else {'); - lines.push('\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags must be an array"})'); - lines.push('\t}'); - helpers.add('fmt'); - } + lines.push('\ttagsRaw, hasTags := event["tags"]'); + lines.push('\tif !hasTags {'); + lines.push('\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags is required"})'); + lines.push('\t} else {'); + lines.push('\t\tvar tags [][]string'); + lines.push('\t\tvar tagsValid bool'); + lines.push('\t\tswitch tt := tagsRaw.(type) {'); + lines.push('\t\tcase [][]string:'); + lines.push('\t\t\ttags = tt'); + lines.push('\t\t\ttagsValid = true'); + lines.push('\t\tcase []interface{}:'); + lines.push('\t\t\ttagsValid = true'); + lines.push('\t\t\ttags = make([][]string, 0, len(tt))'); + lines.push('\t\t\tfor i, raw := range tt {'); + lines.push('\t\t\t\tif arr, ok := raw.([]interface{}); ok {'); + lines.push('\t\t\t\t\tvalid := true'); + lines.push('\t\t\t\t\tstrs := make([]string, len(arr))'); + lines.push('\t\t\t\t\tfor j, v := range arr {'); + lines.push('\t\t\t\t\t\tif s, ok := v.(string); ok {'); + lines.push('\t\t\t\t\t\t\tstrs[j] = s'); + lines.push('\t\t\t\t\t\t} else {'); + lines.push('\t\t\t\t\t\t\tvalid = false'); + lines.push('\t\t\t\t\t\t\tbreak'); + lines.push('\t\t\t\t\t\t}'); + lines.push('\t\t\t\t\t}'); + lines.push('\t\t\t\t\tif valid {'); + lines.push('\t\t\t\t\t\ttags = append(tags, strs)'); + lines.push('\t\t\t\t\t} else {'); + lines.push('\t\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); + lines.push('\t\t\t\t\t\ttags = append(tags, nil)'); + lines.push('\t\t\t\t\t}'); + lines.push('\t\t\t\t} else {'); + lines.push('\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); + lines.push('\t\t\t\t\ttags = append(tags, nil)'); + lines.push('\t\t\t\t}'); + lines.push('\t\t\t}'); + lines.push('\t\t}'); + lines.push('\t\tif tagsValid {'); + lines.push('\t\t\terrors = append(errors, ValidateKindTags(kind, tags)...)'); + lines.push('\t\t} else {'); + lines.push('\t\t\terrors = append(errors, ValidationError{Path: "tags", Message: "tags must be an array"})'); + lines.push('\t\t}'); + lines.push('\t}'); + helpers.add('fmt'); lines.push('\treturn errors'); lines.push('}'); @@ -725,10 +756,7 @@ function emitGoFile( contentPlans: Map, ): string { // Pre-generate event dispatch to collect helpers before emitting helper functions - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchGo(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchGo(constrainedKinds, contentPlans, helpers); const lines: string[] = [ '// Code generated by @nostrability/schemata-codegen. DO NOT EDIT.', diff --git a/src/emit-java.ts b/src/emit-java.ts index c42dbf4..af8c7b1 100644 --- a/src/emit-java.ts +++ b/src/emit-java.ts @@ -456,11 +456,11 @@ function emitEventDispatchJava( lines.push(' }'); // Content validation + lines.push(' Object contentRaw = event.get("content");'); + lines.push(' if (contentRaw == null && !event.containsKey("content")) {'); + lines.push(' errors.add(new ValidationError("content", "content is required"));'); + lines.push(' } else if (contentRaw instanceof String) {'); if (contentKinds.length > 0) { - lines.push(' Object contentRaw = event.get("content");'); - lines.push(' if (contentRaw == null && !event.containsKey("content")) {'); - lines.push(' errors.add(new ValidationError("content", "content is required"));'); - lines.push(' } else if (contentRaw instanceof String) {'); lines.push(' String content = (String) contentRaw;'); lines.push(' switch (kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -470,40 +470,38 @@ function emitEventDispatchJava( lines.push(' }'); } lines.push(' }'); - lines.push(' } else {'); - lines.push(' errors.add(new ValidationError("content", "content must be a string"));'); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("content", "content must be a string"));'); + lines.push(' }'); // Tag dispatch - if (sorted.length > 0) { - lines.push(' Object tagsRaw = event.get("tags");'); - lines.push(' if (tagsRaw == null && !event.containsKey("tags")) {'); - lines.push(' errors.add(new ValidationError("tags", "tags is required"));'); - lines.push(' } else if (tagsRaw instanceof List) {'); - lines.push(' var rawTags = (List) tagsRaw;'); - lines.push(' var tags = new ArrayList>(rawTags.size());'); - lines.push(' for (int i = 0; i < rawTags.size(); i++) {'); - lines.push(' Object t = rawTags.get(i);'); - lines.push(' if (t instanceof List) {'); - lines.push(' var arr = (List) t;'); - lines.push(' boolean valid = arr.stream().allMatch(v -> v instanceof String);'); - lines.push(' if (valid) {'); - lines.push(' tags.add((List) (List) arr);'); - lines.push(' } else {'); - lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); - lines.push(' tags.add(List.of());'); - lines.push(' }'); - lines.push(' } else {'); - lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); - lines.push(' tags.add(List.of());'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.addAll(validateKindTags(kind, tags));'); - lines.push(' } else {'); - lines.push(' errors.add(new ValidationError("tags", "tags must be a list"));'); - lines.push(' }'); - } + lines.push(' Object tagsRaw = event.get("tags");'); + lines.push(' if (tagsRaw == null && !event.containsKey("tags")) {'); + lines.push(' errors.add(new ValidationError("tags", "tags is required"));'); + lines.push(' } else if (tagsRaw instanceof List) {'); + lines.push(' var rawTags = (List) tagsRaw;'); + lines.push(' var tags = new ArrayList>(rawTags.size());'); + lines.push(' for (int i = 0; i < rawTags.size(); i++) {'); + lines.push(' Object t = rawTags.get(i);'); + lines.push(' if (t instanceof List) {'); + lines.push(' var arr = (List) t;'); + lines.push(' boolean valid = arr.stream().allMatch(v -> v instanceof String);'); + lines.push(' if (valid) {'); + lines.push(' tags.add((List) (List) arr);'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); + lines.push(' tags.add(List.of());'); + lines.push(' }'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags[" + i + "]", "tags[" + i + "] must be a list of strings"));'); + lines.push(' tags.add(List.of());'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(' errors.add(new ValidationError("tags", "tags must be a list"));'); + lines.push(' }'); lines.push(' return errors;'); lines.push(' }'); @@ -653,10 +651,7 @@ function emitJavaFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchJava(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchJava(constrainedKinds, contentPlans, helpers); const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', diff --git a/src/emit-kotlin.ts b/src/emit-kotlin.ts index 1b099c6..2503cb6 100644 --- a/src/emit-kotlin.ts +++ b/src/emit-kotlin.ts @@ -366,10 +366,14 @@ function emitEventDispatchKotlin( lines.push('/** Validate an event\'s base fields, content constraints, and tag structure. */'); lines.push('fun validateEvent(event: Map): List {'); lines.push(' val errors = mutableListOf()'); - lines.push(' val kind = event["kind"]'); - lines.push(' if (kind !is Int) {'); - lines.push(' errors.add(ValidationError("kind", "kind must be an integer"))'); - lines.push(' return errors'); + lines.push(' val kindRaw = event["kind"]'); + lines.push(' val kind: Int = when (kindRaw) {'); + lines.push(' is Int -> kindRaw'); + lines.push(' is Long -> kindRaw.toInt()'); + lines.push(' else -> {'); + lines.push(' errors.add(ValidationError("kind", "kind must be an integer"))'); + lines.push(' return errors'); + lines.push(' }'); lines.push(' }'); helpers.add('checkHex64'); @@ -388,16 +392,16 @@ function emitEventDispatchKotlin( lines.push(' errors.add(ValidationError("sig", "sig must be a 128-char lowercase hex string"))'); lines.push(' }'); lines.push(' val createdAt = event["created_at"]'); - lines.push(' if (createdAt !is Int || createdAt < 0) {'); + lines.push(' if (!((createdAt is Int && createdAt >= 0) || (createdAt is Long && createdAt >= 0L))) {'); lines.push(' errors.add(ValidationError("created_at", "created_at must be a non-negative integer"))'); lines.push(' }'); + lines.push(' if (!event.containsKey("content")) {'); + lines.push(' errors.add(ValidationError("content", "content is required"))'); + lines.push(' } else {'); + lines.push(' val contentRaw = event["content"]'); + lines.push(' if (contentRaw is String) {'); if (contentKinds.length > 0) { - lines.push(' if (!event.containsKey("content")) {'); - lines.push(' errors.add(ValidationError("content", "content is required"))'); - lines.push(' } else {'); - lines.push(' val contentRaw = event["content"]'); - lines.push(' if (contentRaw is String) {'); lines.push(' val content = contentRaw'); lines.push(' when (kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -406,34 +410,32 @@ function emitEventDispatchKotlin( lines.push(' }'); } lines.push(' }'); - lines.push(' } else {'); - lines.push(' errors.add(ValidationError("content", "content must be a string"))'); - lines.push(' }'); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("content", "content must be a string"))'); + lines.push(' }'); + lines.push(' }'); - if (sorted.length > 0) { - lines.push(' if (!event.containsKey("tags")) {'); - lines.push(' errors.add(ValidationError("tags", "tags is required"))'); - lines.push(' } else {'); - lines.push(' val tagsRaw = event["tags"]'); - lines.push(' if (tagsRaw is List<*>) {'); - lines.push(' val tags = mutableListOf>()'); - lines.push(' for ((i, t) in tagsRaw.withIndex()) {'); - lines.push(' if (t is List<*> && t.all { it is String }) {'); - lines.push(' @Suppress("UNCHECKED_CAST")'); - lines.push(' tags.add(t as List)'); - lines.push(' } else {'); - lines.push(' errors.add(ValidationError("tags[$i]", "tags[$i] must be a list of strings"))'); - lines.push(' tags.add(emptyList())'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.addAll(validateKindTags(kind, tags))'); - lines.push(' } else {'); - lines.push(' errors.add(ValidationError("tags", "tags must be a list"))'); - lines.push(' }'); - lines.push(' }'); - } + lines.push(' if (!event.containsKey("tags")) {'); + lines.push(' errors.add(ValidationError("tags", "tags is required"))'); + lines.push(' } else {'); + lines.push(' val tagsRaw = event["tags"]'); + lines.push(' if (tagsRaw is List<*>) {'); + lines.push(' val tags = mutableListOf>()'); + lines.push(' for ((i, t) in tagsRaw.withIndex()) {'); + lines.push(' if (t is List<*> && t.all { it is String }) {'); + lines.push(' @Suppress("UNCHECKED_CAST")'); + lines.push(' tags.add(t as List)'); + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("tags[$i]", "tags[$i] must be a list of strings"))'); + lines.push(' tags.add(emptyList())'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.addAll(validateKindTags(kind, tags))'); + lines.push(' } else {'); + lines.push(' errors.add(ValidationError("tags", "tags must be a list"))'); + lines.push(' }'); + lines.push(' }'); lines.push(' return errors'); lines.push('}'); @@ -583,10 +585,7 @@ function emitKotlinFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchKotlin(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchKotlin(constrainedKinds, contentPlans, helpers); const needsRegex = helpers.has('regex'); diff --git a/src/emit-php.ts b/src/emit-php.ts index eca9684..572248e 100644 --- a/src/emit-php.ts +++ b/src/emit-php.ts @@ -376,12 +376,12 @@ function renderContentActionsPhp( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if (mb_strlen($content) < ${action.min}) {`); + lines.push(` if (mb_strlen($content, 'UTF-8') < ${action.min}) {`); lines.push(` $errors[] = new SchemataValidationError('content', 'content must be at least ${action.min} character(s)');`); lines.push(' }'); break; case 'check_content_max_length': - lines.push(` if (mb_strlen($content) > ${action.max}) {`); + lines.push(` if (mb_strlen($content, 'UTF-8') > ${action.max}) {`); lines.push(` $errors[] = new SchemataValidationError('content', 'content must be at most ${action.max} character(s)');`); lines.push(' }'); break; @@ -394,9 +394,9 @@ function renderContentActionsPhp( break; } case 'check_content_enum': { - const vals = action.values.map(v => `'${v}'`).join(', '); + const vals = action.values.map(v => phpString(v)).join(', '); lines.push(` if (!in_array($content, [${vals}], true)) {`); - lines.push(` $errors[] = new SchemataValidationError('content', 'content must be one of: ${action.values.join(', ')}');`); + lines.push(` $errors[] = new SchemataValidationError('content', ${phpString('content must be one of: ' + action.values.join(', '))});`); lines.push(' }'); break; } @@ -445,10 +445,10 @@ function emitEventDispatchPhp( lines.push(" $errors[] = new SchemataValidationError('created_at', 'created_at must be a non-negative integer');"); lines.push(' }'); + lines.push(" if (!array_key_exists('content', $event)) {"); + lines.push(" $errors[] = new SchemataValidationError('content', 'content is required');"); + lines.push(" } elseif (is_string($event['content'])) {"); if (contentKinds.length > 0) { - lines.push(" if (!array_key_exists('content', $event)) {"); - lines.push(" $errors[] = new SchemataValidationError('content', 'content is required');"); - lines.push(" } elseif (is_string($event['content'])) {"); lines.push(" $content = $event['content'];"); lines.push(' switch ($kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -457,29 +457,27 @@ function emitEventDispatchPhp( lines.push(' break;'); } lines.push(' }'); - lines.push(' } else {'); - lines.push(" $errors[] = new SchemataValidationError('content', 'content must be a string');"); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(" $errors[] = new SchemataValidationError('content', 'content must be a string');"); + lines.push(' }'); - if (sorted.length > 0) { - lines.push(" if (!array_key_exists('tags', $event)) {"); - lines.push(" $errors[] = new SchemataValidationError('tags', 'tags is required');"); - lines.push(" } elseif (is_array($event['tags'])) {"); - lines.push(' $tags = [];'); - lines.push(" foreach ($event['tags'] as $i => $t) {"); - lines.push(' if (is_array($t) && array_reduce($t, fn($carry, $v) => $carry && is_string($v), true)) {'); - lines.push(' $tags[] = array_values($t);'); - lines.push(' } else {'); - lines.push(' $errors[] = new SchemataValidationError("tags[$i]", "tags[$i] must be an array of strings");'); - lines.push(' $tags[] = [];'); - lines.push(' }'); - lines.push(' }'); - lines.push(' $errors = array_merge($errors, schemata_validate_kind_tags($kind, $tags));'); - lines.push(' } else {'); - lines.push(" $errors[] = new SchemataValidationError('tags', 'tags must be an array');"); - lines.push(' }'); - } + lines.push(" if (!array_key_exists('tags', $event)) {"); + lines.push(" $errors[] = new SchemataValidationError('tags', 'tags is required');"); + lines.push(" } elseif (is_array($event['tags'])) {"); + lines.push(' $tags = [];'); + lines.push(" foreach ($event['tags'] as $i => $t) {"); + lines.push(' if (is_array($t) && array_reduce($t, fn($carry, $v) => $carry && is_string($v), true)) {'); + lines.push(' $tags[] = array_values($t);'); + lines.push(' } else {'); + lines.push(' $errors[] = new SchemataValidationError("tags[$i]", "tags[$i] must be an array of strings");'); + lines.push(' $tags[] = [];'); + lines.push(' }'); + lines.push(' }'); + lines.push(' $errors = array_merge($errors, schemata_validate_kind_tags($kind, $tags));'); + lines.push(' } else {'); + lines.push(" $errors[] = new SchemataValidationError('tags', 'tags must be an array');"); + lines.push(' }'); lines.push(' return $errors;'); lines.push('}'); @@ -689,10 +687,7 @@ function emitPhpFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchPhp(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchPhp(constrainedKinds, contentPlans, helpers); const lines: string[] = [ ' 0) { - lines.push(' _content = event.get("content")'); - lines.push(' if _content is None:'); - lines.push(' errors.append(ValidationError(path="content", message="content is required"))'); - lines.push(' elif isinstance(_content, str):'); lines.push(' content = _content'); for (const [kindNumber, actions] of contentKinds) { lines.push(` if kind == ${kindNumber}:`); lines.push(...renderContentActionsPython(actions, helpers)); } - lines.push(' else:'); - lines.push(' errors.append(ValidationError(path="content", message="content must be a string"))'); } + lines.push(' pass'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path="content", message="content must be a string"))'); // Tag dispatch - if (sorted.length > 0) { - lines.push(' _tags = event.get("tags")'); - lines.push(' if _tags is None:'); - lines.push(' errors.append(ValidationError(path="tags", message="tags is required"))'); - lines.push(' elif isinstance(_tags, list):'); - lines.push(' tags: list[list[str]] = []'); - lines.push(' for i, t in enumerate(_tags):'); - lines.push(' if isinstance(t, list) and all(isinstance(v, str) for v in t):'); - lines.push(' tags.append(t)'); - lines.push(' else:'); - lines.push(' errors.append(ValidationError(path=f"tags[{i}]", message=f"tags[{i}] must be a list of strings"))'); - lines.push(' tags.append([])'); - lines.push(' errors.extend(validate_kind_tags(kind, tags))'); - lines.push(' else:'); - lines.push(' errors.append(ValidationError(path="tags", message="tags must be a list"))'); - } + lines.push(' _tags = event.get("tags")'); + lines.push(' if _tags is None:'); + lines.push(' errors.append(ValidationError(path="tags", message="tags is required"))'); + lines.push(' elif isinstance(_tags, list):'); + lines.push(' tags: list[list[str]] = []'); + lines.push(' for i, t in enumerate(_tags):'); + lines.push(' if isinstance(t, list) and all(isinstance(v, str) for v in t):'); + lines.push(' tags.append(t)'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path=f"tags[{i}]", message=f"tags[{i}] must be a list of strings"))'); + lines.push(' tags.append([])'); + lines.push(' errors.extend(validate_kind_tags(kind, tags))'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path="tags", message="tags must be a list"))'); lines.push(' return errors'); return lines.join('\n'); @@ -558,10 +557,7 @@ function emitPythonFile( contentPlans: Map, ): string { // Pre-generate event dispatch to collect helpers before emitting helper functions - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchPython(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchPython(constrainedKinds, contentPlans, helpers); const needsRegex = helpers.has('_regex'); const lines: string[] = [ diff --git a/src/emit-ruby.ts b/src/emit-ruby.ts index 817716a..3119513 100644 --- a/src/emit-ruby.ts +++ b/src/emit-ruby.ts @@ -349,8 +349,8 @@ function renderContentActionsRuby( break; } case 'check_content_enum': { - const vals = action.values.map(v => `'${v}'`).join(', '); - lines.push(` errors << ValidationError.new('content', 'content must be one of: ${action.values.join(', ')}') unless [${vals}].include?(content)`); + const vals = action.values.map(v => rubyString(v)).join(', '); + lines.push(` errors << ValidationError.new('content', ${rubyString('content must be one of: ' + action.values.join(', '))}) unless [${vals}].include?(content)`); break; } } @@ -391,12 +391,12 @@ function emitEventDispatchRuby( lines.push(' ca = event[\'created_at\']'); lines.push(' errors << ValidationError.new(\'created_at\', \'created_at must be a non-negative integer\') unless ca.is_a?(Integer) && ca >= 0'); + lines.push(" unless event.key?('content')"); + lines.push(" errors << ValidationError.new('content', 'content is required')"); + lines.push(' else'); + lines.push(" content_raw = event['content']"); + lines.push(' if content_raw.is_a?(String)'); if (contentKinds.length > 0) { - lines.push(" unless event.key?('content')"); - lines.push(" errors << ValidationError.new('content', 'content is required')"); - lines.push(' else'); - lines.push(" content_raw = event['content']"); - lines.push(' if content_raw.is_a?(String)'); lines.push(' content = content_raw'); lines.push(' case kind'); for (const [kindNumber, actions] of contentKinds) { @@ -404,33 +404,31 @@ function emitEventDispatchRuby( lines.push(...renderContentActionsRuby(actions, helpers)); } lines.push(' end'); - lines.push(' else'); - lines.push(" errors << ValidationError.new('content', 'content must be a string')"); - lines.push(' end'); - lines.push(' end'); } + lines.push(' else'); + lines.push(" errors << ValidationError.new('content', 'content must be a string')"); + lines.push(' end'); + lines.push(' end'); - if (sorted.length > 0) { - lines.push(" unless event.key?('tags')"); - lines.push(" errors << ValidationError.new('tags', 'tags is required')"); - lines.push(' else'); - lines.push(" tags_raw = event['tags']"); - lines.push(' if tags_raw.is_a?(Array)'); - lines.push(' tags = []'); - lines.push(' tags_raw.each_with_index do |t, i|'); - lines.push(' if t.is_a?(Array) && t.all? { |v| v.is_a?(String) }'); - lines.push(' tags << t'); - lines.push(' else'); - lines.push(' errors << ValidationError.new("tags[#{i}]", "tags[#{i}] must be an array of strings")'); - lines.push(' tags << []'); - lines.push(' end'); - lines.push(' end'); - lines.push(' errors.concat(validate_kind_tags(kind, tags))'); - lines.push(' else'); - lines.push(" errors << ValidationError.new('tags', 'tags must be an array')"); - lines.push(' end'); - lines.push(' end'); - } + lines.push(" unless event.key?('tags')"); + lines.push(" errors << ValidationError.new('tags', 'tags is required')"); + lines.push(' else'); + lines.push(" tags_raw = event['tags']"); + lines.push(' if tags_raw.is_a?(Array)'); + lines.push(' tags = []'); + lines.push(' tags_raw.each_with_index do |t, i|'); + lines.push(' if t.is_a?(Array) && t.all? { |v| v.is_a?(String) }'); + lines.push(' tags << t'); + lines.push(' else'); + lines.push(' errors << ValidationError.new("tags[#{i}]", "tags[#{i}] must be an array of strings")'); + lines.push(' tags << []'); + lines.push(' end'); + lines.push(' end'); + lines.push(' errors.concat(validate_kind_tags(kind, tags))'); + lines.push(' else'); + lines.push(" errors << ValidationError.new('tags', 'tags must be an array')"); + lines.push(' end'); + lines.push(' end'); lines.push(' errors'); lines.push(' end'); @@ -580,10 +578,7 @@ function emitRubyFile( helpers: Set, contentPlans: Map, ): string { - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchRuby(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchRuby(constrainedKinds, contentPlans, helpers); const needsSet = helpers.has('check_relay_url') || helpers.has('check_bech32') || helpers.has('check_a_tag') || helpers.has('check_ln_invoice') || helpers.has('ascii_ws') || helpers.has('check_package_id') || helpers.has('check_nostr_uri'); const lines: string[] = [ diff --git a/src/emit-rust.ts b/src/emit-rust.ts index 516ffe1..8801380 100644 --- a/src/emit-rust.ts +++ b/src/emit-rust.ts @@ -386,12 +386,12 @@ function renderContentActionsRust( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if content.len() < ${action.min} {`); + lines.push(` if content.chars().count() < ${action.min} {`); lines.push(` errors.push(ValidationError { path: "content", message: "content must be at least ${action.min} character(s)" });`); lines.push(' }'); break; case 'check_content_max_length': - lines.push(` if content.len() > ${action.max} {`); + lines.push(` if content.chars().count() > ${action.max} {`); lines.push(` errors.push(ValidationError { path: "content", message: "content must be at most ${action.max} character(s)" });`); lines.push(' }'); break; @@ -405,8 +405,9 @@ function renderContentActionsRust( } case 'check_content_enum': { const checks = action.values.map(v => `content == ${JSON.stringify(v)}`).join(' || '); + const msgVals = action.values.join(', ').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); lines.push(` if !(${checks}) {`); - lines.push(` errors.push(ValidationError { path: "content", message: "content must be one of: ${action.values.join(', ')}" });`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must be one of: ${msgVals}" });`); lines.push(' }'); break; } @@ -680,10 +681,7 @@ function emitRustFile( ): string { // Pre-generate event dispatch so any helpers it needs (e.g. check_hex_128) // are registered before helper emission. - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchRust(constrainedKinds, contentPlans, helpers, adapter, api); - } + const eventDispatchCode = emitEventDispatchRust(constrainedKinds, contentPlans, helpers, adapter, api); const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', diff --git a/src/emit-swift.ts b/src/emit-swift.ts index 671a9a1..3997526 100644 --- a/src/emit-swift.ts +++ b/src/emit-swift.ts @@ -412,8 +412,8 @@ function emitEventDispatchSwift( lines.push(' }'); // Content validation + lines.push(' if let content = event["content"] as? String {'); if (contentKinds.length > 0) { - lines.push(' if let content = event["content"] as? String {'); lines.push(' switch kind {'); for (const [kindNumber, actions] of contentKinds) { lines.push(` case ${kindNumber}:`); @@ -421,32 +421,30 @@ function emitEventDispatchSwift( } lines.push(' default: break'); lines.push(' }'); - lines.push(' } else if event["content"] != nil {'); - lines.push(' errors.append(ValidationError(path: "content", message: "content must be a string"))'); - lines.push(' } else {'); - lines.push(' errors.append(ValidationError(path: "content", message: "content is required"))'); - lines.push(' }'); } + lines.push(' } else if event["content"] != nil {'); + lines.push(' errors.append(ValidationError(path: "content", message: "content must be a string"))'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "content", message: "content is required"))'); + lines.push(' }'); // Tag dispatch - if (sorted.length > 0) { - lines.push(' if let rawTags = event["tags"] as? [[Any]] {'); - lines.push(' var tags: [[String]] = []'); - lines.push(' for (i, t) in rawTags.enumerated() {'); - lines.push(' if let st = t as? [String] {'); - lines.push(' tags.append(st)'); - lines.push(' } else {'); - lines.push(' errors.append(ValidationError(path: "tags[\\(i)]", message: "tags[\\(i)] must be an array of strings"))'); - lines.push(' tags.append([])'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.append(contentsOf: validateKindTags(kind: kind, tags: tags))'); - lines.push(' } else if event["tags"] != nil {'); - lines.push(' errors.append(ValidationError(path: "tags", message: "tags must be an array"))'); - lines.push(' } else {'); - lines.push(' errors.append(ValidationError(path: "tags", message: "tags is required"))'); - lines.push(' }'); - } + lines.push(' if let rawTags = event["tags"] as? [[Any]] {'); + lines.push(' var tags: [[String]] = []'); + lines.push(' for (i, t) in rawTags.enumerated() {'); + lines.push(' if let st = t as? [String] {'); + lines.push(' tags.append(st)'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "tags[\\(i)]", message: "tags[\\(i)] must be an array of strings"))'); + lines.push(' tags.append([])'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.append(contentsOf: validateKindTags(kind: kind, tags: tags))'); + lines.push(' } else if event["tags"] != nil {'); + lines.push(' errors.append(ValidationError(path: "tags", message: "tags must be an array"))'); + lines.push(' } else {'); + lines.push(' errors.append(ValidationError(path: "tags", message: "tags is required"))'); + lines.push(' }'); lines.push(' return errors'); lines.push('}'); @@ -600,10 +598,7 @@ function emitSwiftFile( contentPlans: Map, ): string { // Pre-generate event dispatch to collect helpers before emitting helper functions - let eventDispatchCode: string | undefined; - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - eventDispatchCode = emitEventDispatchSwift(constrainedKinds, contentPlans, helpers); - } + const eventDispatchCode = emitEventDispatchSwift(constrainedKinds, contentPlans, helpers); const needsFoundation = helpers.has('regex'); diff --git a/src/emit-validators.ts b/src/emit-validators.ts index 68e6950..e4832fa 100644 --- a/src/emit-validators.ts +++ b/src/emit-validators.ts @@ -333,10 +333,10 @@ function emitEventDispatch( lines.push(' }'); // Content validation + lines.push(' if (event.content === undefined) {'); + lines.push(' errors.push({ path: "content", message: "content is required" });'); + lines.push(' } else if (typeof event.content === "string") {'); if (contentKinds.length > 0) { - lines.push(' if (event.content === undefined) {'); - lines.push(' errors.push({ path: "content", message: "content is required" });'); - lines.push(' } else if (typeof event.content === "string") {'); lines.push(' const content = event.content;'); lines.push(' switch (kind) {'); for (const [kindNumber, actions] of contentKinds) { @@ -346,31 +346,29 @@ function emitEventDispatch( lines.push(' }'); } lines.push(' }'); - lines.push(' } else {'); - lines.push(' errors.push({ path: "content", message: "content must be a string" });'); - lines.push(' }'); } + lines.push(' } else {'); + lines.push(' errors.push({ path: "content", message: "content must be a string" });'); + lines.push(' }'); // Tag dispatch — validate tag element types, then dispatch - if (sorted.length > 0) { - lines.push(' if (event.tags === undefined) {'); - lines.push(' errors.push({ path: "tags", message: "tags is required" });'); - lines.push(' } else if (Array.isArray(event.tags)) {'); - lines.push(' const tags: string[][] = [];'); - lines.push(' for (let i = 0; i < event.tags.length; i++) {'); - lines.push(' const t = event.tags[i];'); - lines.push(' if (!Array.isArray(t) || !t.every(v => typeof v === "string")) {'); - lines.push(' errors.push({ path: `tags[${i}]`, message: `tags[${i}] must be an array of strings` });'); - lines.push(' tags.push([]);'); - lines.push(' } else {'); - lines.push(' tags.push(t as string[]);'); - lines.push(' }'); - lines.push(' }'); - lines.push(' errors.push(...validateKindTags(kind, tags));'); - lines.push(' } else {'); - lines.push(' errors.push({ path: "tags", message: "tags must be an array" });'); - lines.push(' }'); - } + lines.push(' if (event.tags === undefined) {'); + lines.push(' errors.push({ path: "tags", message: "tags is required" });'); + lines.push(' } else if (Array.isArray(event.tags)) {'); + lines.push(' const tags: string[][] = [];'); + lines.push(' for (let i = 0; i < event.tags.length; i++) {'); + lines.push(' const t = event.tags[i];'); + lines.push(' if (!Array.isArray(t) || !t.every(v => typeof v === "string")) {'); + lines.push(' errors.push({ path: `tags[${i}]`, message: `tags[${i}] must be an array of strings` });'); + lines.push(' tags.push([]);'); + lines.push(' } else {'); + lines.push(' tags.push(t as string[]);'); + lines.push(' }'); + lines.push(' }'); + lines.push(' errors.push(...validateKindTags(kind, tags));'); + lines.push(' } else {'); + lines.push(' errors.push({ path: "tags", message: "tags must be an array" });'); + lines.push(' }'); lines.push(' return errors;'); lines.push('}'); @@ -463,18 +461,14 @@ export function emitValidatorsFile( } // Dispatch function - if (constrainedKinds.length > 0) { - parts.push('\n// === Dispatch ===\n'); - parts.push(emitDispatch(constrainedKinds)); - parts.push(''); - } + parts.push('\n// === Dispatch ===\n'); + parts.push(emitDispatch(constrainedKinds)); + parts.push(''); // Event dispatch (content + tag validation) - if (constrainedKinds.length > 0 || contentPlans.size > 0) { - parts.push('\n// === Event validation ===\n'); - parts.push(emitEventDispatch(constrainedKinds, contentPlans)); - parts.push(''); - } + parts.push('\n// === Event validation ===\n'); + parts.push(emitEventDispatch(constrainedKinds, contentPlans)); + parts.push(''); return parts.join('\n'); } diff --git a/tests/emit-php.test.ts b/tests/emit-php.test.ts index e24c707..d37d347 100644 --- a/tests/emit-php.test.ts +++ b/tests/emit-php.test.ts @@ -248,7 +248,7 @@ describe('schemata_validate_event', () => { it('validates content for constrained kinds', () => { const output = emitPhpValidators([kindWithContent]); - assert.ok(output.includes('mb_strlen($content) < 1')); + assert.ok(output.includes("mb_strlen($content, 'UTF-8') < 1")); }); it('dispatches to schemata_validate_kind_tags', () => { diff --git a/tests/emit-rust.test.ts b/tests/emit-rust.test.ts index 01bcf6e..3fb298c 100644 --- a/tests/emit-rust.test.ts +++ b/tests/emit-rust.test.ts @@ -214,7 +214,7 @@ describe('validateEvent (generic)', () => { it('validates content for constrained kinds', () => { const output = emitRustValidators([kindWithContent]); - assert.ok(output.includes('content.len() < 1')); + assert.ok(output.includes('content.chars().count() < 1')); }); it('dispatches to validate_kind_tags', () => { From 90dd57a84e7145c64210a4ea7e70f65b59d9de9c Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 1 Apr 2026 13:34:53 -0500 Subject: [PATCH 3/4] fix(go,c): handle native Go tag types, report null tags in C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go: inner tags may be []string (not just []interface{}) when the outer slice is []interface{} — use type switch to accept both shapes. C: when num_tags > 0 but tags/tag_lens pointers are NULL, emit an error instead of silently skipping tag validation. Co-Authored-By: Claude Opus 4.6 --- src/emit-c.ts | 9 +++------ src/emit-go.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/emit-c.ts b/src/emit-c.ts index e682179..b400635 100644 --- a/src/emit-c.ts +++ b/src/emit-c.ts @@ -535,16 +535,13 @@ function emitEventFunctionC( lines.push(' }'); // Tag dispatch — null-check tags/tag_lens before dispatch - lines.push(' if (num_tags > 0 && tags && tag_lens) {'); + lines.push(' if (num_tags > 0 && (!tags || !tag_lens)) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "tags", "tags is required but pointer is NULL");'); + lines.push(' } else {'); lines.push(' int _remaining = max_errs - n;'); lines.push(' if (_remaining > 0) {'); lines.push(' n += schemata_validate(kind, tags, tag_lens, num_tags, errs + n, _remaining);'); lines.push(' }'); - lines.push(' } else if (num_tags == 0) {'); - lines.push(' int _remaining = max_errs - n;'); - lines.push(' if (_remaining > 0) {'); - lines.push(' n += schemata_validate(kind, tags, tag_lens, 0, errs + n, _remaining);'); - lines.push(' }'); lines.push(' }'); } diff --git a/src/emit-go.ts b/src/emit-go.ts index c29d3af..afb95dc 100644 --- a/src/emit-go.ts +++ b/src/emit-go.ts @@ -524,10 +524,13 @@ function emitEventDispatchGo( lines.push('\t\t\ttagsValid = true'); lines.push('\t\t\ttags = make([][]string, 0, len(tt))'); lines.push('\t\t\tfor i, raw := range tt {'); - lines.push('\t\t\t\tif arr, ok := raw.([]interface{}); ok {'); + lines.push('\t\t\t\tswitch inner := raw.(type) {'); + lines.push('\t\t\t\tcase []string:'); + lines.push('\t\t\t\t\ttags = append(tags, inner)'); + lines.push('\t\t\t\tcase []interface{}:'); lines.push('\t\t\t\t\tvalid := true'); - lines.push('\t\t\t\t\tstrs := make([]string, len(arr))'); - lines.push('\t\t\t\t\tfor j, v := range arr {'); + lines.push('\t\t\t\t\tstrs := make([]string, len(inner))'); + lines.push('\t\t\t\t\tfor j, v := range inner {'); lines.push('\t\t\t\t\t\tif s, ok := v.(string); ok {'); lines.push('\t\t\t\t\t\t\tstrs[j] = s'); lines.push('\t\t\t\t\t\t} else {'); @@ -541,7 +544,7 @@ function emitEventDispatchGo( lines.push('\t\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); lines.push('\t\t\t\t\t\ttags = append(tags, nil)'); lines.push('\t\t\t\t\t}'); - lines.push('\t\t\t\t} else {'); + lines.push('\t\t\t\tdefault:'); lines.push('\t\t\t\t\terrors = append(errors, ValidationError{Path: fmt.Sprintf("tags[%d]", i), Message: fmt.Sprintf("tags[%d] must be an array of strings", i)})'); lines.push('\t\t\t\t\ttags = append(tags, nil)'); lines.push('\t\t\t\t}'); From 6d5d2a2a59af146cda90f533fd9432d75297b8b6 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Wed, 1 Apr 2026 13:53:06 -0500 Subject: [PATCH 4/4] fix: UTF-8 char counting, string escaping, bounds checks in validateEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C: strlen → schemata_utf8_char_count helper, cStringEscape for \n\r\t, nostrdb NULL note check, num_tags < 0 guard - C++: .size() → utf8_char_count helper - Go: len() → utf8.RuneCountInString() - Java: .length() → .codePointCount(0, content.length()) - Kotlin: .length → .codePointCount(), Long→Int bounds check - C#: Long→int bounds check (MinValue/MaxValue guard) - Dart: use dartString() for escaping, add break; in switch cases - Rust: nostr crate field access (event.kind not event.kind()), rustStringEscape for \n\r\t in content messages Co-Authored-By: Claude Opus 4.6 --- src/emit-c.ts | 42 ++++++++++++++++++++++++++++++++++----- src/emit-cpp.ts | 22 ++++++++++++++++++-- src/emit-csharp.ts | 2 +- src/emit-dart.ts | 8 ++++---- src/emit-go.ts | 6 ++++-- src/emit-java.ts | 4 ++-- src/emit-kotlin.ts | 9 ++++++--- src/emit-rust.ts | 22 ++++++++++++++------ tests/emit-c.test.ts | 2 +- tests/emit-cpp.test.ts | 2 +- tests/emit-go.test.ts | 2 +- tests/emit-java.test.ts | 2 +- tests/emit-kotlin.test.ts | 2 +- 13 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/emit-c.ts b/src/emit-c.ts index b400635..64caf5c 100644 --- a/src/emit-c.ts +++ b/src/emit-c.ts @@ -423,6 +423,17 @@ function renderTagSearchC( return { code: lines.join('\n'), helpers }; } +// --- C string escape helper --- + +function cStringEscape(s: string): string { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + // --- Content validation --- function renderContentActionsC( @@ -434,20 +445,22 @@ function renderContentActionsC( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if (strlen(${contentVar}) < ${action.min}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at least ${action.min} character(s)");`); + helpers.add('schemata_utf8_char_count'); + lines.push(` if (schemata_utf8_char_count(${contentVar}) < ${action.min}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at least ${action.min} character(s)");`); break; case 'check_content_max_length': - lines.push(` if (strlen(${contentVar}) > ${action.max}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at most ${action.max} character(s)");`); + helpers.add('schemata_utf8_char_count'); + lines.push(` if (schemata_utf8_char_count(${contentVar}) > ${action.max}) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be at most ${action.max} character(s)");`); break; case 'check_content_pattern': { const r = renderPatternCheckC(action.native, contentVar); for (const h of r.helpers) helpers.add(h); - lines.push(` if (!(${r.expr})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must match pattern ${action.regex.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}");`); + lines.push(` if (!(${r.expr})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must match pattern ${cStringEscape(action.regex)}");`); break; } case 'check_content_enum': { const checks = action.values.map(v => `strcmp(${contentVar}, ${JSON.stringify(v)}) == 0`).join(' || '); - lines.push(` if (!(${checks})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be one of: ${action.values.join(', ')}");`); + lines.push(` if (!(${checks})) SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content must be one of: ${cStringEscape(action.values.join(', '))}");`); break; } } @@ -472,6 +485,10 @@ function emitEventFunctionC( lines.push('int schemata_validate_event(const struct ndb_note *note,'); lines.push(' struct schemata_error *errs, int max_errs) {'); lines.push(' int n = 0;'); + lines.push(' if (!note) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "note", "note is required");'); + lines.push(' return n;'); + lines.push(' }'); // nostrdb stores raw bytes, not hex strings - just null-check lines.push(' if (!ndb_note_id(note)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "id", "id is required");'); lines.push(' if (!ndb_note_pubkey(note)) SCHEMATA_EMIT_ERR(errs, n, max_errs, "pubkey", "pubkey is required");'); @@ -535,7 +552,7 @@ function emitEventFunctionC( lines.push(' }'); // Tag dispatch — null-check tags/tag_lens before dispatch - lines.push(' if (num_tags > 0 && (!tags || !tag_lens)) {'); + lines.push(' if (num_tags < 0 || (num_tags > 0 && (!tags || !tag_lens))) {'); lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "tags", "tags is required but pointer is NULL");'); lines.push(' } else {'); lines.push(' int _remaining = max_errs - n;'); @@ -872,6 +889,21 @@ function emitSourceFile( function emitHelperFunctions(helpers: Set): string { const lines: string[] = []; + if (helpers.has('schemata_utf8_char_count')) { + lines.push('static size_t schemata_utf8_char_count(const char *s) {'); + lines.push(' size_t count = 0;'); + lines.push(' for (const unsigned char *p = (const unsigned char *)s; *p; ) {'); + lines.push(' if (*p < 0x80) p += 1;'); + lines.push(' else if ((*p & 0xE0) == 0xC0) p += 2;'); + lines.push(' else if ((*p & 0xF0) == 0xE0) p += 3;'); + lines.push(' else p += 4;'); + lines.push(' count++;'); + lines.push(' }'); + lines.push(' return count;'); + lines.push('}'); + lines.push(''); + } + if (helpers.has('schemata_starts_with')) { lines.push('static int schemata_starts_with(const char *s, const char *prefix) {'); lines.push(' return strncmp(s, prefix, strlen(prefix)) == 0;'); diff --git a/src/emit-cpp.ts b/src/emit-cpp.ts index b1a304d..baf48ac 100644 --- a/src/emit-cpp.ts +++ b/src/emit-cpp.ts @@ -332,12 +332,14 @@ function renderContentActionsCpp( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if (event.content.size() < ${action.min}) {`); + helpers.add('utf8_char_count'); + lines.push(` if (utf8_char_count(event.content) < ${action.min}) {`); lines.push(` errors.push_back({"content", "content must be at least ${action.min} character(s)"});`); lines.push(' }'); break; case 'check_content_max_length': - lines.push(` if (event.content.size() > ${action.max}) {`); + helpers.add('utf8_char_count'); + lines.push(` if (utf8_char_count(event.content) > ${action.max}) {`); lines.push(` errors.push_back({"content", "content must be at most ${action.max} character(s)"});`); lines.push(' }'); break; @@ -628,6 +630,22 @@ function emitCppFile( function emitCppHelpers(helpers: Set): string { const lines: string[] = []; + if (helpers.has('utf8_char_count')) { + lines.push('inline std::size_t utf8_char_count(const std::string& s) {'); + lines.push(' std::size_t count = 0;'); + lines.push(' for (std::size_t i = 0; i < s.size(); ) {'); + lines.push(' auto c = static_cast(s[i]);'); + lines.push(' if (c < 0x80) i += 1;'); + lines.push(' else if ((c & 0xE0) == 0xC0) i += 2;'); + lines.push(' else if ((c & 0xF0) == 0xE0) i += 3;'); + lines.push(' else i += 4;'); + lines.push(' count++;'); + lines.push(' }'); + lines.push(' return count;'); + lines.push('}'); + lines.push(''); + } + const hexLengths = new Set(); const hexMixedLengths = new Set(); for (const h of helpers) { diff --git a/src/emit-csharp.ts b/src/emit-csharp.ts index 9116d41..b3ab5b0 100644 --- a/src/emit-csharp.ts +++ b/src/emit-csharp.ts @@ -375,7 +375,7 @@ function emitEventDispatchCSharp( lines.push(' }'); lines.push(' int kind;'); lines.push(' if (kindRaw is int ki) kind = ki;'); - lines.push(' else if (kindRaw is long kl) kind = (int)kl;'); + lines.push(' else if (kindRaw is long kl && kl >= int.MinValue && kl <= int.MaxValue) kind = (int)kl;'); lines.push(' else {'); lines.push(' errors.Add(new ValidationError("kind", "kind must be an integer"));'); lines.push(' return errors;'); diff --git a/src/emit-dart.ts b/src/emit-dart.ts index 0fd5d01..6899f72 100644 --- a/src/emit-dart.ts +++ b/src/emit-dart.ts @@ -346,15 +346,14 @@ function renderContentActionsDart( const r = renderPatternCheckDart(action.native, 'content'); for (const h of r.helpers) helpers.add(h); lines.push(` if (!(${r.expr})) {`); - const escaped = action.regex.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$'); - lines.push(` errors.add(ValidationError(path: 'content', message: 'content must match pattern ${escaped}'));`); + lines.push(` errors.add(ValidationError(path: 'content', message: ${dartString('content must match pattern ' + action.regex)}));`); lines.push(' }'); break; } case 'check_content_enum': { - const vals = action.values.map(v => `'${v}'`).join(', '); + const vals = action.values.map(v => dartString(v)).join(', '); lines.push(` if (![${vals}].contains(content)) {`); - lines.push(` errors.add(ValidationError(path: 'content', message: 'content must be one of: ${action.values.join(', ')}'));`); + lines.push(` errors.add(ValidationError(path: 'content', message: ${dartString('content must be one of: ' + action.values.join(', '))}));`); lines.push(' }'); break; } @@ -414,6 +413,7 @@ function emitEventDispatchDart( for (const [kindNumber, actions] of contentKinds) { lines.push(` case ${kindNumber}:`); lines.push(...renderContentActionsDart(actions, helpers)); + lines.push(' break;'); } lines.push(' }'); } diff --git a/src/emit-go.ts b/src/emit-go.ts index afb95dc..4313aa0 100644 --- a/src/emit-go.ts +++ b/src/emit-go.ts @@ -386,12 +386,14 @@ function renderContentActionsGo( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(`\tif len(content) < ${action.min} {`); + helpers.add('utf8'); + lines.push(`\tif utf8.RuneCountInString(content) < ${action.min} {`); lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be at least ${action.min} character(s)"})`); lines.push('\t}'); break; case 'check_content_max_length': - lines.push(`\tif len(content) > ${action.max} {`); + helpers.add('utf8'); + lines.push(`\tif utf8.RuneCountInString(content) > ${action.max} {`); lines.push(`\t\terrors = append(errors, ValidationError{Path: "content", Message: "content must be at most ${action.max} character(s)"})`); lines.push('\t}'); break; diff --git a/src/emit-java.ts b/src/emit-java.ts index af8c7b1..bf0f8a5 100644 --- a/src/emit-java.ts +++ b/src/emit-java.ts @@ -377,12 +377,12 @@ function renderContentActionsJava( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if (content.length() < ${action.min}) {`); + lines.push(` if (content.codePointCount(0, content.length()) < ${action.min}) {`); lines.push(` errors.add(new ValidationError("content", "content must be at least ${action.min} character(s)"));`); lines.push(' }'); break; case 'check_content_max_length': - lines.push(` if (content.length() > ${action.max}) {`); + lines.push(` if (content.codePointCount(0, content.length()) > ${action.max}) {`); lines.push(` errors.add(new ValidationError("content", "content must be at most ${action.max} character(s)"));`); lines.push(' }'); break; diff --git a/src/emit-kotlin.ts b/src/emit-kotlin.ts index 2503cb6..3c012a9 100644 --- a/src/emit-kotlin.ts +++ b/src/emit-kotlin.ts @@ -323,12 +323,12 @@ function renderContentActionsKotlin( for (const action of actions) { switch (action.type) { case 'check_content_min_length': - lines.push(` if (content.length < ${action.min}) {`); + lines.push(` if (content.codePointCount(0, content.length) < ${action.min}) {`); lines.push(` errors.add(ValidationError("content", "content must be at least ${action.min} character(s)"))`); lines.push(' }'); break; case 'check_content_max_length': - lines.push(` if (content.length > ${action.max}) {`); + lines.push(` if (content.codePointCount(0, content.length) > ${action.max}) {`); lines.push(` errors.add(ValidationError("content", "content must be at most ${action.max} character(s)"))`); lines.push(' }'); break; @@ -369,7 +369,10 @@ function emitEventDispatchKotlin( lines.push(' val kindRaw = event["kind"]'); lines.push(' val kind: Int = when (kindRaw) {'); lines.push(' is Int -> kindRaw'); - lines.push(' is Long -> kindRaw.toInt()'); + lines.push(' is Long -> if (kindRaw in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) kindRaw.toInt() else {'); + lines.push(' errors.add(ValidationError("kind", "kind must be an integer"))'); + lines.push(' return errors'); + lines.push(' }'); lines.push(' else -> {'); lines.push(' errors.add(ValidationError("kind", "kind must be an integer"))'); lines.push(' return errors'); diff --git a/src/emit-rust.ts b/src/emit-rust.ts index 8801380..b6f6405 100644 --- a/src/emit-rust.ts +++ b/src/emit-rust.ts @@ -376,6 +376,17 @@ function renderTagMatcherRust( return checks.join(' && '); } +// --- Rust string escape helper --- + +function rustStringEscape(s: string): string { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + // --- Content validation --- function renderContentActionsRust( @@ -399,15 +410,14 @@ function renderContentActionsRust( const r = renderPatternCheckRust(action.native, 'content'); for (const h of r.helpers) helpers.add(h); lines.push(` if !(${r.expr}) {`); - lines.push(` errors.push(ValidationError { path: "content", message: "content must match pattern ${action.regex.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" });`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must match pattern ${rustStringEscape(action.regex)}" });`); lines.push(' }'); break; } case 'check_content_enum': { const checks = action.values.map(v => `content == ${JSON.stringify(v)}`).join(' || '); - const msgVals = action.values.join(', ').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); lines.push(` if !(${checks}) {`); - lines.push(` errors.push(ValidationError { path: "content", message: "content must be one of: ${msgVals}" });`); + lines.push(` errors.push(ValidationError { path: "content", message: "content must be one of: ${rustStringEscape(action.values.join(', '))}" });`); lines.push(' }'); break; } @@ -486,10 +496,10 @@ function emitEventDispatchRust( lines.push("/// Validate an event's content constraints and tag structure."); lines.push('pub fn validate_event(event: &Event) -> Vec {'); lines.push(' let mut errors = Vec::new();'); - lines.push(' let kind = event.kind().as_u16() as u32;'); + lines.push(' let kind = event.kind.as_u16() as u32;'); if (contentKinds.length > 0) { - lines.push(' let content = event.content().as_str();'); + lines.push(' let content = event.content.as_str();'); lines.push(' match kind {'); for (const [kindNumber, actions] of contentKinds) { lines.push(` ${kindNumber} => {`); @@ -500,7 +510,7 @@ function emitEventDispatchRust( lines.push(' }'); } - lines.push(' errors.extend(validate_kind_tags(kind, event.tags()));'); + lines.push(' errors.extend(validate_kind_tags(kind, &event.tags));'); } else { // nostrdb lines.push("/// Validate an event's content constraints and tag structure."); diff --git a/tests/emit-c.test.ts b/tests/emit-c.test.ts index 224befa..049b8b9 100644 --- a/tests/emit-c.test.ts +++ b/tests/emit-c.test.ts @@ -212,7 +212,7 @@ describe('validateEvent (generic)', () => { it('validates content for constrained kinds', () => { const result = emitCValidators([kindWithContent]); - assert.ok(result.source.includes('strlen')); + assert.ok(result.source.includes('schemata_utf8_char_count')); }); it('dispatches to schemata_validate', () => { diff --git a/tests/emit-cpp.test.ts b/tests/emit-cpp.test.ts index 6d58662..17573e3 100644 --- a/tests/emit-cpp.test.ts +++ b/tests/emit-cpp.test.ts @@ -185,7 +185,7 @@ describe('validate_event', () => { it('validates content for constrained kinds', () => { const output = emitCppValidators([kindWithContent]); - assert.ok(output.includes('.size() < 1')); + assert.ok(output.includes('utf8_char_count(event.content) < 1')); }); it('dispatches to validate_kind_tags', () => { diff --git a/tests/emit-go.test.ts b/tests/emit-go.test.ts index 79a642c..142cdea 100644 --- a/tests/emit-go.test.ts +++ b/tests/emit-go.test.ts @@ -201,7 +201,7 @@ describe('ValidateEvent', () => { it('validates content for constrained kinds', () => { const output = emitGoValidators([kindWithContent]); - assert.ok(output.includes('len(content) < 1')); + assert.ok(output.includes('utf8.RuneCountInString(content) < 1')); }); it('dispatches to ValidateKindTags', () => { diff --git a/tests/emit-java.test.ts b/tests/emit-java.test.ts index 9e8d529..7c8afe1 100644 --- a/tests/emit-java.test.ts +++ b/tests/emit-java.test.ts @@ -208,7 +208,7 @@ describe('validateEvent', () => { it('validates content for constrained kinds', () => { const output = emitJavaValidators([kindWithContent]); - assert.ok(output.includes('.length() < 1')); + assert.ok(output.includes('.codePointCount(0, content.length()) < 1')); }); it('dispatches to validateKindTags', () => { diff --git a/tests/emit-kotlin.test.ts b/tests/emit-kotlin.test.ts index 2168184..7d73a87 100644 --- a/tests/emit-kotlin.test.ts +++ b/tests/emit-kotlin.test.ts @@ -185,7 +185,7 @@ describe('validateEvent', () => { it('validates content for constrained kinds', () => { const output = emitKotlinValidators([kindWithContent]); - assert.ok(output.includes('.length < 1')); + assert.ok(output.includes('.codePointCount(0, content.length) < 1')); }); it('dispatches to validateKindTags', () => {