feat: union and string literals
This commit is contained in:
parent
d78ef75272
commit
14948fb5f6
|
|
@ -1,6 +1,6 @@
|
||||||
import { parseSchema } from './parser';
|
import { parseSchema } from './parser';
|
||||||
import { parseValue, createValidator } from './validator';
|
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';
|
import { ParseError } from './parser';
|
||||||
|
|
||||||
export function defineSchema(schemaString: string): ParsedSchema {
|
export function defineSchema(schemaString: string): ParsedSchema {
|
||||||
|
|
@ -15,4 +15,4 @@ export function defineSchema(schemaString: string): ParsedSchema {
|
||||||
}
|
}
|
||||||
|
|
||||||
export { parseSchema, parseValue, createValidator, ParseError };
|
export { parseSchema, parseValue, createValidator, ParseError };
|
||||||
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, ParsedSchema };
|
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema };
|
||||||
|
|
|
||||||
100
src/parser.ts
100
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 {
|
export class ParseError extends Error {
|
||||||
constructor(message: string, public position?: number) {
|
constructor(message: string, public position?: number) {
|
||||||
|
|
@ -59,6 +59,65 @@ class Parser {
|
||||||
parseSchema(): Schema {
|
parseSchema(): Schema {
|
||||||
this.skipWhitespace();
|
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[]
|
// Check for reference syntax: @tablename[]
|
||||||
if (this.consumeStr('@')) {
|
if (this.consumeStr('@')) {
|
||||||
return this.parseReferenceSchema();
|
return this.parseReferenceSchema();
|
||||||
|
|
@ -187,6 +246,45 @@ class Parser {
|
||||||
throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos);
|
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 {
|
private parseNamedSchema(): NamedSchema {
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
|
|
|
||||||
30
src/test.ts
30
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' },
|
{ 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 }) => {
|
testCases.forEach(({ schema, value, description }) => {
|
||||||
try {
|
try {
|
||||||
console.log(`Test: ${description}`);
|
console.log(`Test: ${description}`);
|
||||||
|
|
|
||||||
12
src/types.ts
12
src/types.ts
|
|
@ -27,7 +27,17 @@ export interface ReferenceSchema {
|
||||||
isArray: boolean;
|
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 {
|
export interface ParsedSchema {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { ParseError } from './parser';
|
||||||
|
|
||||||
class ValueParser {
|
class ValueParser {
|
||||||
|
|
@ -45,6 +45,10 @@ class ValueParser {
|
||||||
return this.parseFloatValue();
|
return this.parseFloatValue();
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return this.parseBooleanValue();
|
return this.parseBooleanValue();
|
||||||
|
case 'stringLiteral':
|
||||||
|
return this.parseStringLiteralValue(schema);
|
||||||
|
case 'union':
|
||||||
|
return this.parseUnionValue(schema);
|
||||||
case 'tuple':
|
case 'tuple':
|
||||||
return this.parseTupleValue(schema, allowOmitBrackets);
|
return this.parseTupleValue(schema, allowOmitBrackets);
|
||||||
case 'array':
|
case 'array':
|
||||||
|
|
@ -120,6 +124,66 @@ class ValueParser {
|
||||||
throw new ParseError('Expected true or false', this.pos);
|
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[] {
|
private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] {
|
||||||
let hasOpenBracket = false;
|
let hasOpenBracket = false;
|
||||||
|
|
||||||
|
|
@ -309,6 +373,10 @@ export function createValidator(schema: Schema): (value: unknown) => boolean {
|
||||||
return typeof value === 'number' && !isNaN(value);
|
return typeof value === 'number' && !isNaN(value);
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return typeof value === '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':
|
case 'tuple':
|
||||||
if (!Array.isArray(value)) return false;
|
if (!Array.isArray(value)) return false;
|
||||||
if (value.length !== schema.elements.length) return false;
|
if (value.length !== schema.elements.length) return false;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue