From 23ee60bc2069b23cf02720bf927c82e15a7064ed Mon Sep 17 00:00:00 2001 From: hyper Date: Tue, 31 Mar 2026 16:36:32 +0800 Subject: [PATCH] feat: support named members in tuples - Add NamedSchema interface with optional name property - Update TupleSchema.elements to use NamedSchema[] - Add parseNamedSchema() to support [x: number; y: number] syntax - Update validator to parse named members in tuple values - Fix schemaToTypeString in csv-loader for NamedSchema - Fix createValidator to handle NamedSchema.schema - Ensure single named element stays as tuple (not array) Co-authored-by: Qwen-Coder --- src/csv-loader/loader.ts | 4 +-- src/parser.ts | 68 ++++++++++++++++++++++++++++------------ src/test.ts | 5 +++ src/types.ts | 7 ++++- src/validator.ts | 22 +++++++++---- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/csv-loader/loader.ts b/src/csv-loader/loader.ts index f7e13c3..a422964 100644 --- a/src/csv-loader/loader.ts +++ b/src/csv-loader/loader.ts @@ -40,12 +40,12 @@ function schemaToTypeString(schema: Schema): string { return 'boolean'; case 'array': if (schema.element.type === 'tuple') { - const tupleElements = schema.element.elements.map(schemaToTypeString); + const tupleElements = schema.element.elements.map((el) => schemaToTypeString(el.schema)); return `[${tupleElements.join(', ')}]`; } return `${schemaToTypeString(schema.element)}[]`; case 'tuple': - const tupleElements = schema.elements.map(schemaToTypeString); + const tupleElements = schema.elements.map((el) => schemaToTypeString(el.schema)); return `[${tupleElements.join(', ')}]`; default: return 'unknown'; diff --git a/src/parser.ts b/src/parser.ts index 11012c3..166c4a2 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,4 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types'; +import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } from './types'; export class ParseError extends Error { constructor(message: string, public position?: number) { @@ -51,7 +51,7 @@ class Parser { parseSchema(): Schema { this.skipWhitespace(); - + if (this.consumeStr('string')) { if (this.consumeStr('[')) { this.skipWhitespace(); @@ -62,7 +62,7 @@ class Parser { } return { type: 'string' }; } - + if (this.consumeStr('number')) { if (this.consumeStr('[')) { this.skipWhitespace(); @@ -73,7 +73,7 @@ class Parser { } return { type: 'number' }; } - + if (this.consumeStr('boolean')) { if (this.consumeStr('[')) { this.skipWhitespace(); @@ -86,50 +86,50 @@ class Parser { } if (this.consumeStr('[')) { - const elements: Schema[] = []; + const elements: NamedSchema[] = []; this.skipWhitespace(); - + if (this.peek() === ']') { this.consume(); throw new ParseError('Empty array/tuple not allowed', this.pos); } - - elements.push(this.parseSchema()); + + elements.push(this.parseNamedSchema()); this.skipWhitespace(); - + if (this.consumeStr(';')) { - const remainingElements: Schema[] = []; + const remainingElements: NamedSchema[] = []; while (true) { this.skipWhitespace(); - remainingElements.push(this.parseSchema()); + remainingElements.push(this.parseNamedSchema()); this.skipWhitespace(); - + if (!this.consumeStr(';')) { break; } } elements.push(...remainingElements); } - + this.skipWhitespace(); - + if (!this.consumeStr(']')) { throw new ParseError('Expected ]', this.pos); } - + if (this.consumeStr('[')) { this.skipWhitespace(); if (!this.consumeStr(']')) { throw new ParseError('Expected ]', this.pos); } - if (elements.length === 1) { - return { type: 'array', element: elements[0] }; + if (elements.length === 1 && !elements[0].name) { + return { type: 'array', element: elements[0].schema }; } return { type: 'array', element: { type: 'tuple', elements } }; } - - if (elements.length === 1) { - return { type: 'array', element: elements[0] }; + + if (elements.length === 1 && !elements[0].name) { + return { type: 'array', element: elements[0].schema }; } return { type: 'tuple', elements }; } @@ -152,6 +152,34 @@ class Parser { throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos); } + + private parseNamedSchema(): NamedSchema { + this.skipWhitespace(); + + const startpos = this.pos; + let identifier = ''; + + while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { + identifier += this.consume(); + } + + if (identifier.length === 0) { + throw new ParseError('Expected schema or named schema', this.pos); + } + + this.skipWhitespace(); + + if (this.consumeStr(':')) { + this.skipWhitespace(); + const name = identifier; + const schema = this.parseSchema(); + return { name, schema }; + } else { + this.pos = startpos; + const schema = this.parseSchema(); + return { schema }; + } + } } export function parseSchema(schemaString: string): Schema { diff --git a/src/test.ts b/src/test.ts index 2e36d8d..5460a25 100644 --- a/src/test.ts +++ b/src/test.ts @@ -18,6 +18,11 @@ const testCases = [ { schema: 'string', value: 'hello\\[world', description: 'Escaped bracket' }, { schema: 'string', value: 'hello\\\\world', description: 'Escaped backslash' }, { schema: '[string; string]', value: 'hello\\;world; test', description: 'Tuple with escaped semicolon' }, + { schema: '[x: number; y: number]', value: '[x: 10; y: 20]', description: 'Named tuple' }, + { schema: '[x: number; y: number]', value: 'x: 10; y: 20', description: 'Named tuple without brackets' }, + { schema: '[name: string; age: number; active: boolean]', value: '[name: Alice; age: 30; active: true]', description: 'Named tuple with mixed types' }, + { schema: '[name: string; age: number]', value: 'name: Bob; age: 25', description: 'Named tuple without brackets' }, + { schema: '[point: [x: number; y: number]]', value: '[point: [x: 5; y: 10]]', description: 'Nested named tuple' }, ]; testCases.forEach(({ schema, value, description }) => { diff --git a/src/types.ts b/src/types.ts index 6d43cbb..c293c1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,14 @@ export interface PrimitiveSchema { type: SchemaType; } +export interface NamedSchema { + name?: string; + schema: Schema; +} + export interface TupleSchema { type: 'tuple'; - elements: Schema[]; + elements: NamedSchema[]; } export interface ArraySchema { diff --git a/src/validator.ts b/src/validator.ts index 1e4bffd..24f9de4 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,4 +1,4 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types'; +import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } from './types'; import { ParseError } from './parser'; class ValueParser { @@ -96,7 +96,7 @@ class ValueParser { private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] { let hasOpenBracket = false; - + if (this.peek() === '[') { this.consume(); hasOpenBracket = true; @@ -105,7 +105,7 @@ class ValueParser { } this.skipWhitespace(); - + if (this.peek() === ']' && hasOpenBracket) { this.consume(); return []; @@ -114,7 +114,17 @@ class ValueParser { const result: unknown[] = []; for (let i = 0; i < schema.elements.length; i++) { this.skipWhitespace(); - result.push(this.parseValue(schema.elements[i], false)); + const elementSchema = schema.elements[i]; + + if (elementSchema.name) { + this.skipWhitespace(); + if (!this.consumeStr(`${elementSchema.name}:`)) { + throw new ParseError(`Expected ${elementSchema.name}:`, this.pos); + } + this.skipWhitespace(); + } + + result.push(this.parseValue(elementSchema.schema, false)); this.skipWhitespace(); if (i < schema.elements.length - 1) { @@ -125,7 +135,7 @@ class ValueParser { } this.skipWhitespace(); - + if (hasOpenBracket) { if (!this.consumeStr(']')) { throw new ParseError('Expected ]', this.pos); @@ -216,7 +226,7 @@ export function createValidator(schema: Schema): (value: unknown) => boolean { if (!Array.isArray(value)) return false; if (value.length !== schema.elements.length) return false; return schema.elements.every((elementSchema, index) => - createValidator(elementSchema)(value[index]) + createValidator(elementSchema.schema)(value[index]) ); case 'array': if (!Array.isArray(value)) return false;