diff --git a/src/emit-c.ts b/src/emit-c.ts index c3b860f..64caf5c 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,150 @@ 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( + actions: ContentAction[], + helpers: Set, + contentVar: string, +): string[] { + const lines: string[] = []; + for (const action of actions) { + switch (action.type) { + case 'check_content_min_length': + 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': + 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 ${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: ${cStringEscape(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;'); + 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");'); + // 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 + 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(' } 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 + lines.push(' if (!content) {'); + lines.push(' SCHEMATA_EMIT_ERR(errs, n, max_errs, "content", "content is required");'); + if (contentKinds.length > 0) { + 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 — null-check tags/tag_lens before dispatch + 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;'); + 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 +589,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 +763,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 +780,12 @@ function emitHeaderFile( lines.push(''); } + // int64_t needed for generic API event function + if (api === 'generic') { + lines.push('#include '); + lines.push(''); + } + lines.push('#ifdef __cplusplus'); lines.push('extern "C" {'); lines.push('#endif'); @@ -645,6 +805,20 @@ function emitHeaderFile( lines.push('/* Dispatch: validate by kind number */'); for (const l of adapter.dispatchDecl()) lines.push(l); lines.push(''); + + // Event validation declaration + 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 +836,11 @@ function emitSourceFile( adapter: CApiAdapter, api: CApi, headerFileName: string, + contentPlans: Map, ): string { + // Pre-generate event function BEFORE helpers so helper references are registered + const eventFnCode = emitEventFunctionC(constrainedKinds, contentPlans, helpers, adapter, api); + const lines: string[] = [ '/* Auto-generated by @nostrability/schemata-codegen */', '/* Do not edit manually. */', @@ -697,6 +875,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'); @@ -705,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 de7b3dd..baf48ac 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': + 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': + 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; + 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(' }'); + } + + 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,10 @@ function emitCppFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const eventDispatchCode = emitEventDispatchCpp(constrainedKinds, contentPlans, helpers); + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -470,6 +572,7 @@ function emitCppFile( '#include ', '#include ', '#include ', + '#include ', ]; if (needsRegex) { @@ -484,6 +587,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 +616,11 @@ function emitCppFile( lines.push(' }'); lines.push('}'); lines.push(''); + if (eventDispatchCode) { + lines.push(eventDispatchCode); + lines.push(''); + } + lines.push('} // namespace schemata'); lines.push(''); @@ -512,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 ad450e1..b3ab5b0 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,131 @@ 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)) {'); + 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 && 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;'); + 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(' {'); + 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(' 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(' }'); + + 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 +458,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 +582,10 @@ function emitCSharpFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const eventDispatchCode = emitEventDispatchCSharp(constrainedKinds, contentPlans, helpers); + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -459,6 +595,7 @@ function emitCSharpFile( '// Runtime validators for Nostr event tag constraints', '', 'using System;', + 'using System.Collections;', 'using System.Collections.Generic;', 'using System.Linq;', ]; @@ -501,6 +638,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..6899f72 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,131 @@ 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})) {`); + 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 => dartString(v)).join(', '); + lines.push(` if (![${vals}].contains(content)) {`); + lines.push(` errors.add(ValidationError(path: 'content', message: ${dartString('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(' }'); + + 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(' final content = contentRaw;'); + lines.push(' switch (kind) {'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` case ${kindNumber}:`); + lines.push(...renderContentActionsDart(actions, helpers)); + lines.push(' break;'); + } + lines.push(' }'); + } + lines.push(' } else {'); + lines.push(" errors.add(ValidationError(path: 'content', message: 'content must be a string'));"); + 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('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitDartValidators(kindShapes: KindShape[]): string { @@ -338,7 +465,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 +589,10 @@ function emitDartFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const eventDispatchCode = emitEventDispatchDart(constrainedKinds, contentPlans, helpers); + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -490,6 +626,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..4313aa0 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,195 @@ 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': + 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': + 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; + 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('\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}'); + + // 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 {'); + 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('\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\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 + 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\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(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 {'); + 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\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}'); + 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('}'); + return lines.join('\n'); +} + // --- Main emitter --- export function emitGoValidators(kindShapes: KindShape[]): string { @@ -391,7 +582,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 +758,11 @@ 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 + const eventDispatchCode = emitEventDispatchGo(constrainedKinds, contentPlans, helpers); + const lines: string[] = [ '// Code generated by @nostrability/schemata-codegen. DO NOT EDIT.', '//', @@ -579,6 +783,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 +831,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..bf0f8a5 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,147 @@ 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.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.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; + 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 + 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(' 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 + 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 +525,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 +649,10 @@ function emitJavaFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const eventDispatchCode = emitEventDispatchJava(constrainedKinds, contentPlans, helpers); + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -545,6 +697,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..3c012a9 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,138 @@ 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.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.codePointCount(0, 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 kindRaw = event["kind"]'); + lines.push(' val kind: Int = when (kindRaw) {'); + lines.push(' is Int -> kindRaw'); + 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'); + lines.push(' }'); + 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) || (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(' 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(' }'); + + 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 +462,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 +586,10 @@ function emitKotlinFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const eventDispatchCode = emitEventDispatchKotlin(constrainedKinds, contentPlans, helpers); + const needsRegex = helpers.has('regex'); const lines: string[] = [ @@ -482,6 +625,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..572248e 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,124 @@ 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, '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, 'UTF-8') > ${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 => phpString(v)).join(', '); + lines.push(` if (!in_array($content, [${vals}], true)) {`); + lines.push(` $errors[] = new SchemataValidationError('content', ${phpString('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(' }'); + + 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(" $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(' }'); + + 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 +501,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 +685,10 @@ function emitPhpFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + const 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 + 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):'); + if (contentKinds.length > 0) { + lines.push(' content = _content'); + for (const [kindNumber, actions] of contentKinds) { + lines.push(` if kind == ${kindNumber}:`); + lines.push(...renderContentActionsPython(actions, helpers)); + } + } + lines.push(' pass'); + lines.push(' else:'); + lines.push(' errors.append(ValidationError(path="content", message="content must be a string"))'); + + // Tag dispatch + 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 +442,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 +554,11 @@ 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 + const eventDispatchCode = emitEventDispatchPython(constrainedKinds, contentPlans, helpers); + const needsRegex = helpers.has('_regex'); const lines: string[] = [ '# Auto-generated by @nostrability/schemata-codegen', @@ -495,6 +621,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..3119513 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,114 @@ 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 => rubyString(v)).join(', '); + lines.push(` errors << ValidationError.new('content', ${rubyString('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'); + + 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(' 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'); + + 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 +452,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 +576,10 @@ function emitRubyFile( fnBodies: string[], constrainedKinds: { kindNumber: number; nip: string }[], helpers: Set, + contentPlans: Map, ): string { + 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[] = [ '# frozen_string_literal: true', @@ -504,6 +623,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..b6f6405 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,169 @@ 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( + 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.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.chars().count() > ${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 ${rustStringEscape(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.push(ValidationError { path: "content", message: "content must be one of: ${rustStringEscape(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 +560,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 +686,13 @@ 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. + const eventDispatchCode = emitEventDispatchRust(constrainedKinds, contentPlans, helpers, adapter, api); + const lines: string[] = [ '// Auto-generated by @nostrability/schemata-codegen', '// Do not edit manually.', @@ -549,6 +726,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..3997526 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,139 @@ 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 + lines.push(' if let content = event["content"] as? String {'); + if (contentKinds.length > 0) { + 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 + 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 +468,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 +595,11 @@ 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 + const eventDispatchCode = emitEventDispatchSwift(constrainedKinds, contentPlans, helpers); + const needsFoundation = helpers.has('regex'); const lines: string[] = [ @@ -496,6 +644,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..e4832fa 100644 --- a/src/emit-validators.ts +++ b/src/emit-validators.ts @@ -313,16 +313,30 @@ 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 + 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) { @@ -332,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('}'); @@ -449,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-c.test.ts b/tests/emit-c.test.ts index d5d76e4..049b8b9 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('schemata_utf8_char_count')); + }); + + 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..17573e3 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('utf8_char_count(event.content) < 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..142cdea 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('utf8.RuneCountInString(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..7c8afe1 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('.codePointCount(0, content.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..7d73a87 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('.codePointCount(0, content.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..d37d347 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, 'UTF-8') < 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..3fb298c 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.chars().count() < 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(')); + }); +});