init: inline-schema thing

This commit is contained in:
2026-03-31 12:17:46 +08:00
commit 4296c2bdcd
9 changed files with 748 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import { parseSchema } from './parser';
import { parseValue, createValidator } from './validator';
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ParsedSchema } from './types';
import { ParseError } from './parser';
export function defineSchema(schemaString: string): ParsedSchema {
const schema = parseSchema(schemaString);
const validator = createValidator(schema);
return {
schema,
validator,
parse: (valueString: string) => parseValue(schema, valueString),
};
}
export { parseSchema, parseValue, createValidator, ParseError };
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ParsedSchema };
+166
View File
@@ -0,0 +1,166 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types';
export class ParseError extends Error {
constructor(message: string, public position?: number) {
super(position !== undefined ? `${message} at position ${position}` : message);
this.name = 'ParseError';
}
}
class Parser {
private input: string;
private pos: number = 0;
constructor(input: string) {
this.input = input;
}
private peek(): string {
return this.input[this.pos] || '';
}
private consume(): string {
return this.input[this.pos++] || '';
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private match(str: string): boolean {
return this.input.slice(this.pos, this.pos + str.length) === str;
}
private consumeStr(str: string): boolean {
if (this.match(str)) {
this.pos += str.length;
return true;
}
return false;
}
getPosition(): number {
return this.pos;
}
getInputLength(): number {
return this.input.length;
}
parseSchema(): Schema {
this.skipWhitespace();
if (this.consumeStr('string')) {
if (this.consumeStr('[')) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
return { type: 'array', element: { type: 'string' } };
}
return { type: 'string' };
}
if (this.consumeStr('number')) {
if (this.consumeStr('[')) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
return { type: 'array', element: { type: 'number' } };
}
return { type: 'number' };
}
if (this.consumeStr('boolean')) {
if (this.consumeStr('[')) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
return { type: 'array', element: { type: 'boolean' } };
}
return { type: 'boolean' };
}
if (this.consumeStr('[')) {
const elements: Schema[] = [];
this.skipWhitespace();
if (this.peek() === ']') {
this.consume();
throw new ParseError('Empty array/tuple not allowed', this.pos);
}
elements.push(this.parseSchema());
this.skipWhitespace();
if (this.consumeStr(';')) {
const remainingElements: Schema[] = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseSchema());
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] };
}
return { type: 'array', element: { type: 'tuple', elements } };
}
if (elements.length === 1) {
return { type: 'array', element: elements[0] };
}
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();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
return { type: 'array', element: { type: 'string' } };
}
return { type: 'string' };
}
throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos);
}
}
export function parseSchema(schemaString: string): Schema {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError('Unexpected input after schema', parser.getPosition());
}
return schema;
}
+68
View File
@@ -0,0 +1,68 @@
import { defineSchema, parseSchema, parseValue, createValidator } from './index';
console.log('=== Testing Schema Parser ===\n');
const testCases = [
{ schema: 'string', value: 'hello', description: 'Simple string' },
{ schema: 'number', value: '42', description: 'Simple number' },
{ schema: 'boolean', value: 'true', description: 'Simple boolean' },
{ schema: '[string; number]', value: '[hello; 42]', description: 'Tuple' },
{ schema: '[string; number]', value: 'hello; 42', description: 'Tuple without brackets' },
{ schema: 'string[]', value: '[hello; world; test]', description: 'Array of strings' },
{ schema: 'string[]', value: 'hello; world; test', description: 'Array without brackets' },
{ schema: 'number[]', value: '[1; 2; 3; 4]', description: 'Array of numbers' },
{ schema: '[string; number][]', value: '[[a; 1]; [b; 2]; [c; 3]]', description: 'Array of tuples' },
{ schema: '[string; number][]', value: '[a; 1]; [b; 2]; [c; 3]', description: 'Array of tuples without outer brackets' },
{ schema: 'word-smith', value: 'word-smith', description: 'String with hyphen' },
{ schema: 'string', value: 'hello\\;world', description: 'Escaped semicolon' },
{ 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' },
];
testCases.forEach(({ schema, value, description }) => {
try {
console.log(`Test: ${description}`);
console.log(` Schema: ${schema}`);
console.log(` Value: "${value}"`);
const parsed = defineSchema(schema);
const parsedValue = parsed.parse(value);
const isValid = parsed.validator(parsedValue);
console.log(` Parsed: ${JSON.stringify(parsedValue)}`);
console.log(` Valid: ${isValid}`);
console.log(' ✓ Passed\n');
} catch (error) {
console.log(` ✗ Failed: ${(error as Error).message}\n`);
}
});
console.log('=== Testing Validation ===\n');
const stringSchema = defineSchema('string');
console.log('String schema validation:');
console.log(` "hello" is valid: ${stringSchema.validator('hello')}`);
console.log(` 42 is valid: ${stringSchema.validator(42)}\n`);
const numberSchema = defineSchema('number');
console.log('Number schema validation:');
console.log(` 42 is valid: ${numberSchema.validator(42)}`);
console.log(` "42" is valid: ${numberSchema.validator('42')}\n`);
const tupleSchema = defineSchema('[string; number; boolean]');
console.log('Tuple [string; number; boolean] validation:');
console.log(` ["hello", 42, true] is valid: ${tupleSchema.validator(['hello', 42, true])}`);
console.log(` ["hello", "42", true] is valid: ${tupleSchema.validator(['hello', '42', true])}\n`);
const arraySchema = defineSchema('number[]');
console.log('Array number[] validation:');
console.log(` [1, 2, 3] is valid: ${arraySchema.validator([1, 2, 3])}`);
console.log(` [1, "2", 3] is valid: ${arraySchema.validator([1, '2', 3])}\n`);
const arrayOfTuplesSchema = defineSchema('[string; number][]');
console.log('Array of tuples [string; number][] validation:');
console.log(` [["a", 1], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', 1], ['b', 2]])}`);
console.log(` [["a", "1"], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', '1'], ['b', 2]])}\n`);
console.log('=== All tests completed ===');
+23
View File
@@ -0,0 +1,23 @@
export type SchemaType = 'string' | 'number' | 'boolean';
export interface PrimitiveSchema {
type: SchemaType;
}
export interface TupleSchema {
type: 'tuple';
elements: Schema[];
}
export interface ArraySchema {
type: 'array';
element: Schema;
}
export type Schema = PrimitiveSchema | TupleSchema | ArraySchema;
export interface ParsedSchema {
schema: Schema;
validator: (value: unknown) => boolean;
parse: (valueString: string) => unknown;
}
+228
View File
@@ -0,0 +1,228 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types';
import { ParseError } from './parser';
class ValueParser {
private input: string;
private pos: number = 0;
constructor(input: string) {
this.input = input;
}
private peek(): string {
return this.input[this.pos] || '';
}
private consume(): string {
return this.input[this.pos++] || '';
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private consumeStr(str: string): boolean {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown {
this.skipWhitespace();
switch (schema.type) {
case 'string':
return this.parseStringValue();
case 'number':
return this.parseNumberValue();
case 'boolean':
return this.parseBooleanValue();
case 'tuple':
return this.parseTupleValue(schema, allowOmitBrackets);
case 'array':
return this.parseArrayValue(schema, allowOmitBrackets);
default:
throw new ParseError(`Unknown schema type: ${(schema as { type: string }).type}`, this.pos);
}
}
private parseStringValue(): string {
let result = '';
while (this.pos < this.input.length) {
const char = this.peek();
if (char === '\\') {
this.consume();
const nextChar = this.consume();
if (nextChar === ';' || nextChar === '[' || nextChar === ']' || nextChar === '\\') {
result += nextChar;
} else {
result += '\\' + nextChar;
}
} else if (char === ';' || char === ']') {
break;
} else {
result += this.consume();
}
}
return result.trim();
}
private parseNumberValue(): number {
let numStr = '';
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError('Invalid number', this.pos - numStr.length);
}
return num;
}
private parseBooleanValue(): boolean {
if (this.consumeStr('true')) {
return true;
}
if (this.consumeStr('false')) {
return false;
}
throw new ParseError('Expected true or false', this.pos);
}
private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] {
let hasOpenBracket = false;
if (this.peek() === '[') {
this.consume();
hasOpenBracket = true;
} else if (!allowOmitBrackets) {
throw new ParseError('Expected [', this.pos);
}
this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) {
this.consume();
return [];
}
const result: unknown[] = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
result.push(this.parseValue(schema.elements[i], false));
this.skipWhitespace();
if (i < schema.elements.length - 1) {
if (!this.consumeStr(';')) {
throw new ParseError('Expected ;', this.pos);
}
}
}
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
}
return result;
}
private parseArrayValue(schema: ArraySchema, allowOmitBrackets: boolean): unknown[] {
let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === 'tuple' || schema.element.type === 'array';
if (this.peek() === '[') {
if (!elementIsTupleOrArray) {
this.consume();
hasOpenBracket = true;
} else if (this.input[this.pos + 1] === '[') {
this.consume();
hasOpenBracket = true;
}
}
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError('Expected [', this.pos);
}
this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) {
this.consume();
return [];
}
const result: unknown[] = [];
while (true) {
this.skipWhitespace();
result.push(this.parseValue(schema.element, false));
this.skipWhitespace();
if (!this.consumeStr(';')) {
break;
}
}
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
}
return result;
}
getPosition(): number {
return this.pos;
}
getInputLength(): number {
return this.input.length;
}
}
export function parseValue(schema: Schema, valueString: string): unknown {
const parser = new ValueParser(valueString.trim());
const allowOmitBrackets = schema.type === 'tuple' || schema.type === 'array';
const value = parser.parseValue(schema, allowOmitBrackets);
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError('Unexpected input after value', parser.getPosition());
}
return value;
}
export function createValidator(schema: Schema): (value: unknown) => boolean {
return function validate(value: unknown): boolean {
switch (schema.type) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'tuple':
if (!Array.isArray(value)) return false;
if (value.length !== schema.elements.length) return false;
return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema)(value[index])
);
case 'array':
if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item));
default:
return false;
}
};
}