diff --git a/package.json b/package.json index 3a4e634..5a81146 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/csv-loader/loader.ts b/src/csv-loader/loader.ts index af61352..efd3e43 100644 --- a/src/csv-loader/loader.ts +++ b/src/csv-loader/loader.ts @@ -230,9 +230,13 @@ function schemaToTypeString(schema: Schema, resourceNames?: Map) 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) || + const typeName = resourceNames?.get(schema.tableName) || schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); return schema.isArray ? `readonly ${typeName}[]` : typeName; } @@ -244,7 +248,12 @@ function schemaToTypeString(schema: Schema, resourceNames?: Map) }); 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); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..53b1a45 --- /dev/null +++ b/src/index.test.ts @@ -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); + }); +}); diff --git a/src/parser.ts b/src/parser.ts index 082ef47..363eebd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -63,6 +63,16 @@ class Parser { 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]; diff --git a/src/type-gen.test.ts b/src/type-gen.test.ts new file mode 100644 index 0000000..e36c6e3 --- /dev/null +++ b/src/type-gen.test.ts @@ -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"'); + }); + }); +});