From 4296c2bdcd8a27e36b5051ec33c15068c9c0cb92 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 31 Mar 2026 12:17:46 +0800 Subject: [PATCH] init: inline-schema thing --- .gitignore | 40 +++++++++ README.md | 151 +++++++++++++++++++++++++++++++ package.json | 34 +++++++ src/index.ts | 18 ++++ src/parser.ts | 166 ++++++++++++++++++++++++++++++++++ src/test.ts | 68 ++++++++++++++ src/types.ts | 23 +++++ src/validator.ts | 228 +++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 20 +++++ 9 files changed, 748 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/test.ts create mode 100644 src/types.ts create mode 100644 src/validator.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8774eaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Build output +dist/ + +# TypeScript cache +*.tsbuildinfo + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# Test coverage +coverage/ + +# Temporary files +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..42aba08 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# inline-schema + +A TypeScript library for parsing and validating inline schemas with a TypeScript-like syntax using `;` instead of `,`. + +## Installation + +```bash +npm install inline-schema +``` + +## Usage + +### Basic Example + +```typescript +import { defineSchema } from 'inline-schema'; + +// Define a schema +const stringSchema = defineSchema('string'); +const numberSchema = defineSchema('number'); +const booleanSchema = defineSchema('boolean'); + +// Parse values +const name = stringSchema.parse('hello'); // "hello" +const age = numberSchema.parse('42'); // 42 +const active = booleanSchema.parse('true'); // true + +// Validate parsed values +stringSchema.validator(name); // true +numberSchema.validator(name); // false +``` + +### Tuples + +```typescript +const tupleSchema = defineSchema('[string; number; boolean]'); + +// With brackets +const value1 = tupleSchema.parse('[hello; 42; true]'); +// ["hello", 42, true] + +// Without brackets (outermost brackets are optional) +const value2 = tupleSchema.parse('hello; 42; true'); +// ["hello", 42, true] + +tupleSchema.validator(value1); // true +tupleSchema.validator(['a', 'b', true]); // false (second element should be number) +``` + +### Arrays + +```typescript +// Array syntax: Type[] or [Type][] +const stringArray = defineSchema('string[]'); +const numberArray = defineSchema('[number][]'); + +// With brackets +const names1 = stringArray.parse('[alice; bob; charlie]'); +// ["alice", "bob", "charlie"] + +// Without brackets (outermost brackets are optional) +const names2 = stringArray.parse('alice; bob; charlie'); +// ["alice", "bob", "charlie"] + +const numbers = numberArray.parse('[1; 2; 3; 4; 5]'); +// [1, 2, 3, 4, 5] +``` + +### Array of Tuples + +```typescript +const schema = defineSchema('[string; number][]'); + +// With outer brackets +const data1 = schema.parse('[[a; 1]; [b; 2]; [c; 3]]'); +// [["a", 1], ["b", 2], ["c", 3]] + +// Without outer brackets +const data2 = schema.parse('[a; 1]; [b; 2]; [c; 3]'); +// [["a", 1], ["b", 2], ["c", 3]] +``` + +### Escaping Special Characters + +Use `\` to escape special characters `;`, `[`, `]`, and `\` in string values: + +```typescript +const schema = defineSchema('string'); + +const value1 = schema.parse('hello\\;world'); // "hello;world" +const value2 = schema.parse('hello\\[world'); // "hello[world" +const value3 = schema.parse('hello\\\\world'); // "hello\\world" + +// In tuples +const tupleSchema = defineSchema('[string; string]'); +const tuple = tupleSchema.parse('hello\\;world; test'); +// ["hello;world", "test"] +``` + +### String Identifiers + +Any identifier (including hyphens) is treated as a string schema: + +```typescript +const schema = defineSchema('word-smith'); +const value = schema.parse('word-smith'); +// "word-smith" +``` + +## API + +### `defineSchema(schemaString: string): ParsedSchema` + +Parses a schema string and returns an object with: +- `schema`: The parsed schema AST +- `validator`: A function to validate values against the schema +- `parse`: A function to parse value strings + +### `parseSchema(schemaString: string): Schema` + +Parses a schema string and returns the schema AST. + +### `parseValue(schema: Schema, valueString: string): unknown` + +Parses a value string according to the given schema. + +### `createValidator(schema: Schema): (value: unknown) => boolean` + +Creates a validation function for the given schema. + +## Schema Syntax + +| Type | Schema | Example Value | +|------|--------|---------------| +| String | `string` or `identifier` | `hello` | +| Number | `number` | `42` | +| Boolean | `boolean` | `true` or `false` | +| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` | +| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` | +| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` | + +## Notes + +- Semicolons `;` are used as separators instead of commas `,` +- Outermost brackets `[]` are optional for tuple and array values +- Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\` +- Empty arrays/tuples are not allowed + +## License + +ISC diff --git a/package.json b/package.json new file mode 100644 index 0000000..92745a4 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "inline-schema", + "version": "1.0.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "test": "tsx src/test.ts" + }, + "keywords": [ + "schema", + "parser", + "validator", + "typescript" + ], + "author": "", + "license": "ISC", + "description": "A TypeScript library for parsing and validating inline schemas", + "devDependencies": { + "@types/node": "^25.5.0", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0778430 --- /dev/null +++ b/src/index.ts @@ -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 }; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..11012c3 --- /dev/null +++ b/src/parser.ts @@ -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; +} diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..2e36d8d --- /dev/null +++ b/src/test.ts @@ -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 ==='); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6d43cbb --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..1e4bffd --- /dev/null +++ b/src/validator.ts @@ -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; + } + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3348294 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "types": ["node"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}