Compare commits
2 Commits
d78ef75272
...
51a11a26bf
| Author | SHA1 | Date |
|---|---|---|
|
|
51a11a26bf | |
|
|
14948fb5f6 |
|
|
@ -34,7 +34,8 @@
|
|||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "tsx src/test.ts"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"schema",
|
||||
|
|
@ -54,9 +55,10 @@
|
|||
"devDependencies": {
|
||||
"@rspack/core": "^1.1.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"tsx": "^4.21.0",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rspack/core": "^1.x",
|
||||
|
|
|
|||
|
|
@ -230,6 +230,10 @@ function schemaToTypeString(schema: Schema, resourceNames?: Map<string, string>)
|
|||
return 'number';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'stringLiteral':
|
||||
return `"${schema.value}"`;
|
||||
case 'union':
|
||||
return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | ');
|
||||
case 'reference': {
|
||||
// Use the resource name mapping if provided, otherwise capitalize table name
|
||||
const typeName = resourceNames?.get(schema.tableName) ||
|
||||
|
|
@ -244,7 +248,12 @@ function schemaToTypeString(schema: Schema, resourceNames?: Map<string, string>)
|
|||
});
|
||||
return `readonly [${tupleElements.join(', ')}]`;
|
||||
}
|
||||
return `readonly ${schemaToTypeString(schema.element, resourceNames)}[]`;
|
||||
// Wrap union types in parentheses to maintain correct precedence
|
||||
const elementType = schemaToTypeString(schema.element, resourceNames);
|
||||
if (schema.element.type === 'union') {
|
||||
return `readonly (${elementType})[]`;
|
||||
}
|
||||
return `readonly ${elementType}[]`;
|
||||
case 'tuple':
|
||||
const tupleElements = schema.elements.map((el) => {
|
||||
const typeStr = schemaToTypeString(el.schema, resourceNames);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { defineSchema, parseSchema, parseValue, createValidator, ParseError } from './index';
|
||||
import type { Schema, StringLiteralSchema, UnionSchema } from './types';
|
||||
|
||||
describe('defineSchema', () => {
|
||||
it('should return a ParsedSchema with schema, validator, and parse', () => {
|
||||
const parsed = defineSchema('string');
|
||||
expect(parsed).toHaveProperty('schema');
|
||||
expect(parsed).toHaveProperty('validator');
|
||||
expect(parsed).toHaveProperty('parse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primitive types', () => {
|
||||
describe('string', () => {
|
||||
it('should parse and validate string', () => {
|
||||
const schema = defineSchema('string');
|
||||
expect(schema.parse('hello')).toBe('hello');
|
||||
expect(schema.validator('hello')).toBe(true);
|
||||
expect(schema.validator(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle identifiers with hyphens', () => {
|
||||
const schema = defineSchema('word-smith');
|
||||
expect(schema.parse('word-smith')).toBe('word-smith');
|
||||
expect(schema.validator('word-smith')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number', () => {
|
||||
it('should parse and validate number', () => {
|
||||
const schema = defineSchema('number');
|
||||
expect(schema.parse('42')).toBe(42);
|
||||
expect(schema.parse('3.14')).toBe(3.14);
|
||||
expect(schema.validator(42)).toBe(true);
|
||||
expect(schema.validator('42')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('int', () => {
|
||||
it('should parse and validate int', () => {
|
||||
const schema = defineSchema('int');
|
||||
expect(schema.parse('42')).toBe(42);
|
||||
expect(schema.validator(42)).toBe(true);
|
||||
expect(schema.validator(3.14)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject floats', () => {
|
||||
const schema = defineSchema('int');
|
||||
expect(() => schema.parse('3.14')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('float', () => {
|
||||
it('should parse and validate float', () => {
|
||||
const schema = defineSchema('float');
|
||||
expect(schema.parse('3.14')).toBe(3.14);
|
||||
expect(schema.parse('42')).toBe(42);
|
||||
expect(schema.validator(3.14)).toBe(true);
|
||||
expect(schema.validator(42)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean', () => {
|
||||
it('should parse and validate boolean', () => {
|
||||
const schema = defineSchema('boolean');
|
||||
expect(schema.parse('true')).toBe(true);
|
||||
expect(schema.parse('false')).toBe(false);
|
||||
expect(schema.validator(true)).toBe(true);
|
||||
expect(schema.validator('true')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-boolean values', () => {
|
||||
const schema = defineSchema('boolean');
|
||||
expect(() => schema.parse('yes')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String literals', () => {
|
||||
it('should parse double-quoted string literal', () => {
|
||||
const schema = defineSchema('"hello"');
|
||||
expect(schema.parse('"hello"')).toBe('hello');
|
||||
expect(schema.validator('hello')).toBe(true);
|
||||
expect(schema.validator('world')).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse single-quoted string literal', () => {
|
||||
const schema = defineSchema("'world'");
|
||||
expect(schema.parse("'world'")).toBe('world');
|
||||
expect(schema.validator('world')).toBe(true);
|
||||
expect(schema.validator('hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject mismatched string literal values', () => {
|
||||
const schema = defineSchema('"on"');
|
||||
expect(() => schema.parse('"off"')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should handle string literals with semicolons', () => {
|
||||
const schema = defineSchema('"hello;world"');
|
||||
expect(schema.parse('"hello;world"')).toBe('hello;world');
|
||||
expect(schema.validator('hello;world')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle escaped quotes in string literals', () => {
|
||||
const schema = defineSchema('"hello\\"world"');
|
||||
expect(schema.parse('"hello\\"world"')).toBe('hello"world');
|
||||
expect(schema.validator('hello"world')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Union types', () => {
|
||||
it('should parse union of string literals', () => {
|
||||
const schema = defineSchema('"on" | "off"');
|
||||
expect(schema.parse('"on"')).toBe('on');
|
||||
expect(schema.parse('"off"')).toBe('off');
|
||||
expect(schema.validator('on')).toBe(true);
|
||||
expect(schema.validator('off')).toBe(true);
|
||||
expect(schema.validator('maybe')).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse union with three members', () => {
|
||||
const schema = defineSchema('"pending" | "approved" | "rejected"');
|
||||
expect(schema.parse('"approved"')).toBe('approved');
|
||||
expect(schema.validator('pending')).toBe(true);
|
||||
expect(schema.validator('approved')).toBe(true);
|
||||
expect(schema.validator('rejected')).toBe(true);
|
||||
expect(schema.validator('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support parentheses for grouping', () => {
|
||||
const schema = defineSchema('( "active" | "inactive" )');
|
||||
expect(schema.parse('"active"')).toBe('active');
|
||||
expect(schema.validator('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid union values', () => {
|
||||
const schema = defineSchema('"a" | "b"');
|
||||
expect(() => schema.parse('"c"')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should support mixed type unions', () => {
|
||||
const schema = defineSchema('string | number');
|
||||
// string is tried first, so '42' is parsed as string "42"
|
||||
expect(schema.parse('hello')).toBe('hello');
|
||||
expect(schema.parse('42')).toBe('42');
|
||||
expect(schema.validator('hello')).toBe(true);
|
||||
expect(schema.validator(42)).toBe(true);
|
||||
expect(schema.validator(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support string type and string literal union', () => {
|
||||
const schema = defineSchema('string | "special"');
|
||||
// string matches first, so '"special"' is parsed as string '"special"'
|
||||
expect(schema.parse('normal')).toBe('normal');
|
||||
expect(schema.parse('special')).toBe('special');
|
||||
expect(schema.validator('normal')).toBe(true);
|
||||
expect(schema.validator('special')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tuples', () => {
|
||||
it('should parse and validate tuple', () => {
|
||||
const schema = defineSchema('[string; number]');
|
||||
const value = schema.parse('[hello; 42]');
|
||||
expect(value).toEqual(['hello', 42]);
|
||||
expect(schema.validator(['hello', 42])).toBe(true);
|
||||
expect(schema.validator(['hello', '42'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse tuple without brackets', () => {
|
||||
const schema = defineSchema('[string; number]');
|
||||
const value = schema.parse('hello; 42');
|
||||
expect(value).toEqual(['hello', 42]);
|
||||
});
|
||||
|
||||
it('should parse named tuple', () => {
|
||||
const schema = defineSchema('[x: number; y: number]');
|
||||
const value = schema.parse('[x: 10; y: 20]');
|
||||
expect(value).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('should parse tuple with union field', () => {
|
||||
const schema = defineSchema('[name: string; status: "active" | "inactive"]');
|
||||
const value = schema.parse('[myName; "active"]');
|
||||
expect(value).toEqual(['myName', 'active']);
|
||||
expect(schema.validator(['myName', 'active'])).toBe(true);
|
||||
expect(schema.validator(['myName', 'unknown'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse nested tuple', () => {
|
||||
const schema = defineSchema('[point: [x: number; y: number]]');
|
||||
const value = schema.parse('[point: [x: 5; y: 10]]');
|
||||
expect(value).toEqual([[5, 10]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrays', () => {
|
||||
it('should parse and validate array', () => {
|
||||
const schema = defineSchema('string[]');
|
||||
const value = schema.parse('[hello; world; test]');
|
||||
expect(value).toEqual(['hello', 'world', 'test']);
|
||||
expect(schema.validator(['hello', 'world'])).toBe(true);
|
||||
expect(schema.validator(['hello', 42])).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse array without brackets', () => {
|
||||
const schema = defineSchema('string[]');
|
||||
const value = schema.parse('hello; world; test');
|
||||
expect(value).toEqual(['hello', 'world', 'test']);
|
||||
});
|
||||
|
||||
it('should parse array of numbers', () => {
|
||||
const schema = defineSchema('number[]');
|
||||
const value = schema.parse('[1; 2; 3; 4]');
|
||||
expect(value).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should parse array of ints', () => {
|
||||
const schema = defineSchema('int[]');
|
||||
expect(schema.parse('[1; 2; 3; 4]')).toEqual([1, 2, 3, 4]);
|
||||
expect(() => schema.parse('[1; 2.5; 3]')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should parse array of floats', () => {
|
||||
const schema = defineSchema('float[]');
|
||||
expect(schema.parse('[1.5; 2.5; 3.5]')).toEqual([1.5, 2.5, 3.5]);
|
||||
expect(schema.parse('[1; 2; 3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should parse array of tuples', () => {
|
||||
const schema = defineSchema('[string; number][]');
|
||||
const value = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
|
||||
expect(value).toEqual([['a', 1], ['b', 2], ['c', 3]]);
|
||||
});
|
||||
|
||||
it('should parse array of tuples without outer brackets', () => {
|
||||
const schema = defineSchema('[string; number][]');
|
||||
const value = schema.parse('[a; 1]; [b; 2]; [c; 3]');
|
||||
expect(value).toEqual([['a', 1], ['b', 2], ['c', 3]]);
|
||||
});
|
||||
|
||||
it('should parse array of unions', () => {
|
||||
const schema = defineSchema('("pending" | "approved" | "rejected")[]');
|
||||
const value = schema.parse('["pending"; "approved"; "rejected"]');
|
||||
expect(value).toEqual(['pending', 'approved', 'rejected']);
|
||||
expect(schema.validator(['pending', 'approved'])).toBe(true);
|
||||
expect(schema.validator(['pending', 'unknown'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaping', () => {
|
||||
it('should handle escaped semicolon', () => {
|
||||
const schema = defineSchema('string');
|
||||
expect(schema.parse('hello\\;world')).toBe('hello;world');
|
||||
});
|
||||
|
||||
it('should handle escaped bracket', () => {
|
||||
const schema = defineSchema('string');
|
||||
expect(schema.parse('hello\\[world')).toBe('hello[world');
|
||||
});
|
||||
|
||||
it('should handle escaped backslash', () => {
|
||||
const schema = defineSchema('string');
|
||||
expect(schema.parse('hello\\\\world')).toBe('hello\\world');
|
||||
});
|
||||
|
||||
it('should handle escaped semicolon in tuple', () => {
|
||||
const schema = defineSchema('[string; string]');
|
||||
const value = schema.parse('hello\\;world; test');
|
||||
expect(value).toEqual(['hello;world', 'test']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSchema', () => {
|
||||
it('should parse string schema', () => {
|
||||
const schema = parseSchema('string');
|
||||
expect(schema).toEqual({ type: 'string' });
|
||||
});
|
||||
|
||||
it('should parse number schema', () => {
|
||||
const schema = parseSchema('number');
|
||||
expect(schema).toEqual({ type: 'number' });
|
||||
});
|
||||
|
||||
it('should parse string literal schema', () => {
|
||||
const schema = parseSchema('"on"');
|
||||
expect(schema).toEqual({ type: 'stringLiteral', value: 'on' });
|
||||
});
|
||||
|
||||
it('should parse union schema', () => {
|
||||
const schema = parseSchema('"on" | "off"');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(2);
|
||||
expect(schema.members[0]).toEqual({ type: 'stringLiteral', value: 'on' });
|
||||
expect(schema.members[1]).toEqual({ type: 'stringLiteral', value: 'off' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw ParseError for invalid schema', () => {
|
||||
expect(() => parseSchema('')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should throw ParseError for unexpected input', () => {
|
||||
expect(() => parseSchema('string extra')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should throw ParseError for invalid value', () => {
|
||||
const schema = defineSchema('number');
|
||||
expect(() => schema.parse('not-a-number')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should throw ParseError for mismatched enum value', () => {
|
||||
const schema = defineSchema('"on" | "off"');
|
||||
expect(() => schema.parse('"invalid"')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
110
src/parser.ts
110
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,75 @@ class Parser {
|
|||
parseSchema(): Schema {
|
||||
this.skipWhitespace();
|
||||
|
||||
// Parse the first schema element
|
||||
let schema = this.parseSchemaInternal();
|
||||
this.skipWhitespace();
|
||||
|
||||
// Check for array suffix: type[]
|
||||
if (this.consumeStr('[')) {
|
||||
this.skipWhitespace();
|
||||
if (!this.consumeStr(']')) {
|
||||
throw new ParseError('Expected ]', this.pos);
|
||||
}
|
||||
schema = { type: 'array', element: schema };
|
||||
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();
|
||||
|
|
@ -187,6 +256,45 @@ class Parser {
|
|||
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();
|
||||
|
||||
|
|
|
|||
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' },
|
||||
];
|
||||
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { defineSchema, parseSchema } from './index';
|
||||
import type { Schema, StringLiteralSchema, UnionSchema } from './types';
|
||||
|
||||
describe('Type generation for string literals and unions', () => {
|
||||
describe('String literal schema', () => {
|
||||
it('should parse string literal schema', () => {
|
||||
const schema = parseSchema('"on"');
|
||||
expect(schema.type).toBe('stringLiteral');
|
||||
if (schema.type === 'stringLiteral') {
|
||||
expect(schema.value).toBe('on');
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate correct type for string literal', () => {
|
||||
const schema = defineSchema('"on"');
|
||||
expect(schema.schema.type).toBe('stringLiteral');
|
||||
// The parsed value should be the literal string
|
||||
expect(schema.parse('"on"')).toBe('on');
|
||||
expect(schema.validator('on')).toBe(true);
|
||||
expect(schema.validator('off')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string literal with special characters', () => {
|
||||
const schema = defineSchema('"hello-world"');
|
||||
expect(schema.schema.type).toBe('stringLiteral');
|
||||
expect(schema.parse('"hello-world"')).toBe('hello-world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Union schema', () => {
|
||||
it('should parse union of string literals', () => {
|
||||
const schema = parseSchema('"on" | "off"');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(2);
|
||||
expect(schema.members[0].type).toBe('stringLiteral');
|
||||
expect(schema.members[1].type).toBe('stringLiteral');
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate correct type for union of string literals', () => {
|
||||
const schema = defineSchema('"on" | "off"');
|
||||
expect(schema.schema.type).toBe('union');
|
||||
|
||||
// Both values should be valid
|
||||
expect(schema.parse('"on"')).toBe('on');
|
||||
expect(schema.parse('"off"')).toBe('off');
|
||||
expect(schema.validator('on')).toBe(true);
|
||||
expect(schema.validator('off')).toBe(true);
|
||||
expect(schema.validator('maybe')).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse union with three members', () => {
|
||||
const schema = parseSchema('"pending" | "approved" | "rejected"');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle union with parentheses', () => {
|
||||
const schema = defineSchema('("a" | "b")');
|
||||
expect(schema.schema.type).toBe('union');
|
||||
expect(schema.parse('"a"')).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex schema combinations', () => {
|
||||
it('should handle tuple with union field', () => {
|
||||
const schema = defineSchema('[name: string; status: "active" | "inactive"]');
|
||||
expect(schema.schema.type).toBe('tuple');
|
||||
|
||||
const value = schema.parse('[john; "active"]');
|
||||
expect(value).toEqual(['john', 'active']);
|
||||
expect(schema.validator(['john', 'active'])).toBe(true);
|
||||
expect(schema.validator(['john', 'unknown'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array of unions', () => {
|
||||
const schema = defineSchema('("pending" | "approved" | "rejected")[]');
|
||||
expect(schema.schema.type).toBe('array');
|
||||
|
||||
const value = schema.parse('["pending"; "approved"]');
|
||||
expect(value).toEqual(['pending', 'approved']);
|
||||
expect(schema.validator(['pending', 'approved'])).toBe(true);
|
||||
expect(schema.validator(['pending', 'unknown'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array of string literals', () => {
|
||||
const schema = defineSchema('"item"[]');
|
||||
expect(schema.schema.type).toBe('array');
|
||||
|
||||
const value = schema.parse('["item"; "item"; "item"]');
|
||||
expect(value).toEqual(['item', 'item', 'item']);
|
||||
expect(schema.validator(['item', 'item'])).toBe(true);
|
||||
expect(schema.validator(['item', 'other'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested unions with mixed types', () => {
|
||||
const schema = parseSchema('string | number | "special"');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(3);
|
||||
expect(schema.members[0].type).toBe('string');
|
||||
expect(schema.members[1].type).toBe('number');
|
||||
expect(schema.members[2].type).toBe('stringLiteral');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type string generation', () => {
|
||||
// Helper function to test type generation
|
||||
function generateType(schema: Schema): string {
|
||||
function toType(s: Schema): string {
|
||||
switch (s.type) {
|
||||
case 'string': return 'string';
|
||||
case 'number':
|
||||
case 'int':
|
||||
case 'float': return 'number';
|
||||
case 'boolean': return 'boolean';
|
||||
case 'stringLiteral': return `"${s.value}"`;
|
||||
case 'union': return s.members.map(m => toType(m)).join(' | ');
|
||||
case 'array':
|
||||
const elemType = toType(s.element);
|
||||
if (s.element.type === 'union') {
|
||||
return `readonly (${elemType})[]`;
|
||||
}
|
||||
return `readonly ${elemType}[]`;
|
||||
case 'tuple':
|
||||
const elements = s.elements.map(el => {
|
||||
const typeStr = toType(el.schema);
|
||||
return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr;
|
||||
});
|
||||
return `readonly [${elements.join(', ')}]`;
|
||||
default: return 'unknown';
|
||||
}
|
||||
}
|
||||
return toType(schema);
|
||||
}
|
||||
|
||||
it('should generate "on" for string literal', () => {
|
||||
const schema = parseSchema('"on"');
|
||||
expect(generateType(schema)).toBe('"on"');
|
||||
});
|
||||
|
||||
it('should generate "on" | "off" for union of string literals', () => {
|
||||
const schema = parseSchema('"on" | "off"');
|
||||
expect(generateType(schema)).toBe('"on" | "off"');
|
||||
});
|
||||
|
||||
it('should generate correct type for array of string literals', () => {
|
||||
const schema = parseSchema('"item"[]');
|
||||
expect(generateType(schema)).toBe('readonly "item"[]');
|
||||
});
|
||||
|
||||
it('should generate correct type for array of unions', () => {
|
||||
const schema = parseSchema('("pending" | "approved" | "rejected")[]');
|
||||
// Union types in arrays need parentheses for correct precedence
|
||||
expect(generateType(schema)).toBe('readonly ("pending" | "approved" | "rejected")[]');
|
||||
});
|
||||
|
||||
it('should generate correct type for tuple with union field', () => {
|
||||
const schema = parseSchema('[name: string; status: "active" | "inactive"]');
|
||||
// Note: tuple elements have readonly modifier
|
||||
expect(generateType(schema)).toBe('readonly [readonly name: string, readonly status: "active" | "inactive"]');
|
||||
});
|
||||
|
||||
it('should generate correct type for complex union', () => {
|
||||
const schema = parseSchema('string | number | "special"');
|
||||
expect(generateType(schema)).toBe('string | number | "special"');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/types.ts
12
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue