Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 201 additions & 2 deletions src/emit-c.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
import type { KindShape } from './kind-types.js';
import {
planKindValidator,
planContentChecks,
type ValidatorAction,
type ContentAction,
type TagMatcher,
type ValueCheck,
type PositionCheck,
Expand Down Expand Up @@ -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<string>,
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<number, ContentAction[]>,
helpers: Set<string>,
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");');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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(' }');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(
Expand All @@ -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<number, ContentAction[]>();
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 };
}
Expand Down Expand Up @@ -611,6 +763,8 @@ function emitKindFunctionC(
function emitHeaderFile(
constrainedKinds: { kindNumber: number; nip: string }[],
adapter: CApiAdapter,
api: CApi,
contentPlans: Map<number, ContentAction[]>,
): string {
const lines: string[] = [
'/* Auto-generated by @nostrability/schemata-codegen */',
Expand All @@ -626,6 +780,12 @@ function emitHeaderFile(
lines.push('');
}

// int64_t needed for generic API event function
if (api === 'generic') {
lines.push('#include <stdint.h>');
lines.push('');
}

lines.push('#ifdef __cplusplus');
lines.push('extern "C" {');
lines.push('#endif');
Expand All @@ -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');
Expand All @@ -662,7 +836,11 @@ function emitSourceFile(
adapter: CApiAdapter,
api: CApi,
headerFileName: string,
contentPlans: Map<number, ContentAction[]>,
): 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. */',
Expand Down Expand Up @@ -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');
Expand All @@ -705,6 +889,21 @@ function emitSourceFile(
function emitHelperFunctions(helpers: Set<string>): 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;');
Expand Down
Loading
Loading