diff --git a/src/index.ts b/src/index.ts index 0aff876..87e89d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { parseSchema } from './parser'; import { parseValue, createValidator } from './validator'; -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, ParsedSchema } from './types'; +import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema } from './types'; import { ParseError } from './parser'; export function defineSchema(schemaString: string): ParsedSchema { @@ -15,4 +15,4 @@ export function defineSchema(schemaString: string): ParsedSchema { } export { parseSchema, parseValue, createValidator, ParseError }; -export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, ParsedSchema }; +export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema }; diff --git a/src/parser.ts b/src/parser.ts index 50daf03..082ef47 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,4 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema } from './types'; +import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; export class ParseError extends Error { constructor(message: string, public position?: number) { @@ -59,6 +59,65 @@ class Parser { parseSchema(): Schema { this.skipWhitespace(); + // Parse the first schema element + let schema = this.parseSchemaInternal(); + this.skipWhitespace(); + + // Check for union type (| symbol) + if (this.consumeStr('|')) { + const members: Schema[] = [schema]; + + while (true) { + this.skipWhitespace(); + const member = this.parseSchemaInternal(); + members.push(member); + this.skipWhitespace(); + + if (!this.consumeStr('|')) { + break; + } + } + + return { + type: 'union', + members + }; + } + + return schema; + } + + private parseSchemaInternal(): Schema { + this.skipWhitespace(); + + // Check for parentheses (grouping) + if (this.consumeStr('(')) { + this.skipWhitespace(); + const schema = this.parseSchema(); // Recursive call for nested unions + this.skipWhitespace(); + + if (!this.consumeStr(')')) { + throw new ParseError('Expected )', this.pos); + } + + // Check if there's an array suffix: (union)[] + this.skipWhitespace(); + if (this.consumeStr('[')) { + this.skipWhitespace(); + if (!this.consumeStr(']')) { + throw new ParseError('Expected ]', this.pos); + } + return { type: 'array', element: schema }; + } + + return schema; + } + + // Check for string literal syntax: "value" or 'value' + if (this.peek() === '"' || this.peek() === "'") { + return this.parseStringLiteralSchema(); + } + // Check for reference syntax: @tablename[] if (this.consumeStr('@')) { return this.parseReferenceSchema(); @@ -118,7 +177,7 @@ class Parser { } return { type: 'boolean' }; } - + if (this.consumeStr('[')) { const elements: NamedSchema[] = []; this.skipWhitespace(); @@ -167,12 +226,12 @@ class Parser { } return { type: 'tuple', elements }; } - + let identifier = ''; while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { identifier += this.consume(); } - + if (identifier.length > 0) { if (this.consumeStr('[')) { this.skipWhitespace(); @@ -183,10 +242,49 @@ class Parser { } return { type: 'string' }; } - + throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos); } + private parseStringLiteralSchema(): Schema { + const value = this.parseStringLiteral(); + return { + type: 'stringLiteral', + value + }; + } + + private parseStringLiteral(): string { + const quote = this.peek(); + if (quote !== '"' && quote !== "'") { + throw new ParseError('Expected string literal with quotes', this.pos); + } + this.consume(); // Consume opening quote + + let value = ''; + while (this.pos < this.input.length) { + const char = this.peek(); + + if (char === '\\') { + this.consume(); + const nextChar = this.consume(); + if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || + nextChar === '|' || nextChar === ';' || nextChar === '(' || nextChar === ')') { + value += nextChar; + } else { + value += '\\' + nextChar; + } + } else if (char === quote) { + this.consume(); // Consume closing quote + return value; + } else { + value += this.consume(); + } + } + + throw new ParseError('Unterminated string literal', this.pos); + } + private parseNamedSchema(): NamedSchema { this.skipWhitespace(); diff --git a/src/test.ts b/src/test.ts index 8779dc6..6894951 100644 --- a/src/test.ts +++ b/src/test.ts @@ -30,6 +30,36 @@ const testCases = [ { schema: '[point: [x: number; y: number]]', value: '[point: [x: 5; y: 10]]', description: 'Nested named tuple' }, ]; +console.log('=== Testing String Literals ===\n'); + +const stringLiteralCases = [ + { schema: '"hello"', value: '"hello"', description: 'Simple string literal' }, + { schema: "'world'", value: "'world'", description: 'Single quoted string literal' }, + { schema: '"on"', value: '"on"', description: 'String literal "on"' }, + { schema: '"off"', value: '"off"', description: 'String literal "off"' }, + { schema: '"hello;world"', value: '"hello;world"', description: 'String literal with semicolon' }, + { schema: '"hello\\"world"', value: '"hello\\"world"', description: 'String literal with escaped quote' }, +]; + +testCases.push(...stringLiteralCases); + +console.log('=== Testing Union Types (Enums) ===\n'); + +const unionCases = [ + { schema: '"on" | "off"', value: '"on"', description: 'Union: on' }, + { schema: '"on" | "off"', value: '"off"', description: 'Union: off' }, + { schema: '"pending" | "approved" | "rejected"', value: '"approved"', description: 'Union: approved' }, + { schema: '( "active" | "inactive" )', value: '"active"', description: 'Union with parentheses' }, + { schema: '[name: string; status: "active" | "inactive"]', value: '[myName; "active"]', description: 'Tuple with union field' }, + { schema: '("pending" | "approved" | "rejected")[]', value: '["pending"; "approved"; "rejected"]', description: 'Array of unions' }, + { schema: 'string | number', value: 'hello', description: 'Union: string' }, + { schema: 'string | number', value: '42', description: 'Union: number' }, + { schema: 'string | "special"', value: 'normal', description: 'Union: string type' }, + { schema: 'string | "special"', value: '"special"', description: 'Union: string literal' }, +]; + +testCases.push(...unionCases); + testCases.forEach(({ schema, value, description }) => { try { console.log(`Test: ${description}`); diff --git a/src/types.ts b/src/types.ts index 56053bb..7bcb530 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,7 +27,17 @@ export interface ReferenceSchema { isArray: boolean; } -export type Schema = PrimitiveSchema | TupleSchema | ArraySchema | ReferenceSchema; +export interface StringLiteralSchema { + type: 'stringLiteral'; + value: string; // The literal string value (without quotes) +} + +export interface UnionSchema { + type: 'union'; + members: Schema[]; // Union members +} + +export type Schema = PrimitiveSchema | TupleSchema | ArraySchema | ReferenceSchema | StringLiteralSchema | UnionSchema; export interface ParsedSchema { schema: Schema; diff --git a/src/validator.ts b/src/validator.ts index a47ca55..3d6023e 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,4 +1,4 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema } from './types'; +import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; import { ParseError } from './parser'; class ValueParser { @@ -45,6 +45,10 @@ class ValueParser { return this.parseFloatValue(); case 'boolean': return this.parseBooleanValue(); + case 'stringLiteral': + return this.parseStringLiteralValue(schema); + case 'union': + return this.parseUnionValue(schema); case 'tuple': return this.parseTupleValue(schema, allowOmitBrackets); case 'array': @@ -120,6 +124,66 @@ class ValueParser { throw new ParseError('Expected true or false', this.pos); } + private parseStringLiteralValue(schema: StringLiteralSchema): string { + const quote = this.peek(); + if (quote !== '"' && quote !== "'") { + throw new ParseError(`Expected string literal with quotes for value "${schema.value}"`, this.pos); + } + this.consume(); // Consume opening quote + + let value = ''; + while (this.pos < this.input.length) { + const char = this.peek(); + + if (char === '\\') { + this.consume(); + const nextChar = this.consume(); + if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || nextChar === ';') { + value += nextChar; + } else { + value += '\\' + nextChar; + } + } else if (char === quote) { + this.consume(); // Consume closing quote + + if (value !== schema.value) { + throw new ParseError( + `Invalid value '"${value}"'. Expected '"${schema.value}"'`, + this.pos + ); + } + + return value; + } else { + value += this.consume(); + } + } + + throw new ParseError('Unterminated string literal', this.pos); + } + + private parseUnionValue(schema: UnionSchema): unknown { + const savedPos = this.pos; + const errors: Error[] = []; + + // Try each union member until one succeeds + for (let i = 0; i < schema.members.length; i++) { + this.pos = savedPos; + try { + return this.parseValue(schema.members[i], false); + } catch (e) { + errors.push(e as Error); + // Continue to next member + } + } + + // If all members fail, throw a descriptive error + throw new ParseError( + `Value does not match any union member. Tried ${schema.members.length} alternatives.`, + this.pos + ); + } + private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] { let hasOpenBracket = false; @@ -309,6 +373,10 @@ export function createValidator(schema: Schema): (value: unknown) => boolean { return typeof value === 'number' && !isNaN(value); case 'boolean': return typeof value === 'boolean'; + case 'stringLiteral': + return typeof value === 'string' && value === schema.value; + case 'union': + return schema.members.some((member) => createValidator(member)(value)); case 'tuple': if (!Array.isArray(value)) return false; if (value.length !== schema.elements.length) return false;