refactor: modularize validator and type declaration logic

This commit is contained in:
hypercross 2026-04-21 13:55:47 +08:00
parent 53ccac39e6
commit 55a33b98e5
5 changed files with 802 additions and 776 deletions

View File

@ -36,242 +36,12 @@ import { generateTypeDefinition } from "./type-gen.js";
import { csvToModule } from "./module-gen.js"; import { csvToModule } from "./module-gen.js";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import {
/** parseTypeDeclaration,
* Parse a type declaration from a comment line. parseReverseReferenceDeclaration,
* Format: # TypeName := schema expandSchemaString,
* Examples: resolveTypeReferences,
* # Trigger := 'onPlay' | 'onDraw' | 'onDiscard' } from "./type-declarations.js";
* # Effect := [Trigger, @effect, int]
* Returns null if the line is not a type declaration.
*/
function parseTypeDeclaration(
line: string,
commentChar: string = "#",
): { typeName: string; schemaString: string } | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: TypeName := schema
const match = content.match(/^([A-Z][a-zA-Z0-9]*)\s*:=\s*(.+)$/);
if (!match) return null;
const [, typeName, schemaString] = match;
return { typeName, schemaString };
}
/**
* Expand a type name to its schema by replacing the type name with its schema inline.
* Returns the schema string with type names expanded, or null if not a type name.
*/
function expandTypeName(
schemaString: string,
declaredTypes: Map<string, string>,
): string | null {
const trimmed = schemaString.trim();
if (declaredTypes.has(trimmed)) {
return declaredTypes.get(trimmed)!;
}
return null;
}
/**
* Recursively expand all type name references in a schema string.
* Handles unions, tuples, arrays, and nested structures.
*/
function expandSchemaString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
let result = schemaString;
// Keep expanding until no more changes (handles recursive dependencies)
let prev = "";
while (prev !== result) {
prev = result;
result = expandSchemaInString(result, declaredTypes);
}
return result;
}
/**
* Single pass of type name expansion in a schema string.
*/
function expandSchemaInString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
// Check if the entire string is a type name
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
return expanded;
}
// Handle union types (recursively expand each member)
if (schemaString.includes("|")) {
// Split by | but respect quotes
const parts = splitByToken(schemaString, "|");
if (parts.length > 1) {
const expandedParts = parts.map((part) =>
expandSchemaInString(part.trim(), declaredTypes),
);
return expandedParts.join(" | ");
}
}
// Handle tuple/array syntax [el1; el2; ...] or [elements]
// Check if it's a bracketed structure
if (schemaString.startsWith("[") && schemaString.endsWith("]")) {
const inner = schemaString.slice(1, -1);
// Check if it's semicolon-separated (tuple syntax)
if (inner.includes(";")) {
const elements = splitByToken(inner, ";");
const expandedElements = elements.map((el) =>
expandSchemaInString(el.trim(), declaredTypes),
);
return `[${expandedElements.join("; ")}]`;
}
// Otherwise it's a simple array, expand recursively
return `[${expandSchemaInString(inner, declaredTypes)}]`;
}
// Check if it's a type name reference (only uppercase start to avoid conflicts with primitives)
const typeNameMatch = schemaString.trim().match(/^[A-Z][a-zA-Z0-9]*$/);
if (typeNameMatch) {
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
return expanded;
}
}
return schemaString;
}
/**
* Split a string by a token, respecting quoted strings.
*/
function splitByToken(str: string, token: string): string[] {
const result: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (inQuote) {
if (char === inQuote && str[i - 1] !== "\\") {
inQuote = null;
}
current += char;
} else if (char === '"' || char === "'") {
inQuote = char;
current += char;
} else if (char === token && inQuote === null) {
result.push(current);
current = "";
} else {
current += char;
}
}
if (current.length > 0 || str.endsWith(token)) {
result.push(current);
}
return result;
}
/**
* Resolve type name references within a schema using declared types.
* For example, if "Trigger" is a declared type, references to "Trigger" in
* other schemas will be replaced with the actual Trigger schema definition.
*/
function resolveTypeReferences(
schema: Schema,
declaredTypes: Map<string, Schema>,
): Schema {
switch (schema.type) {
case "union":
return {
type: "union",
members: schema.members.map((m) =>
resolveTypeReferences(m, declaredTypes),
),
};
case "tuple":
return {
type: "tuple",
elements: schema.elements.map((el) => ({
name: el.name,
schema: resolveTypeReferences(el.schema, declaredTypes),
})),
};
case "array":
return {
type: "array",
element: resolveTypeReferences(schema.element, declaredTypes),
};
case "reference":
// Don't resolve references to other tables
return schema;
default:
return schema;
}
}
/**
* Resolve type name references in a type declaration's schema string.
* Called after all type names are known.
*/
function resolveTypeDeclarationSchema(
schemaString: string,
declaredTypes: Map<string, Schema>,
): Schema {
const schema = parseSchema(schemaString.trim());
return resolveTypeReferences(schema, declaredTypes);
}
/**
* Parse a reverse reference declaration from a comment line.
* Format: # fieldName := ~tableName(foreignKey)
* Returns null if the line is not a reverse reference declaration.
*/
function parseReverseReferenceDeclaration(
line: string,
commentChar: string = "#",
): ReverseReferenceDeclaration | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: fieldName := ~tableName(foreignKey)
const match = content.match(/^(\w+)\s*:=\s*~(\w+)\((\w+)\)(\?)?$/);
if (!match) return null;
const [, fieldName, tableName, foreignKey, optionalMark] = match;
const isOptional = optionalMark === "?";
const schema: ReverseReferenceSchema = {
type: "reverseReference",
tableName,
foreignKey,
isOptional,
};
return {
fieldName,
tableName,
foreignKey,
isOptional,
schema,
};
}
/** /**
* Parse CSV content string into structured data with schema validation. * Parse CSV content string into structured data with schema validation.

View File

@ -0,0 +1,251 @@
import type {
Schema,
ReverseReferenceSchema,
} from "../types.js";
import { parseSchema } from "../parser.js";
export interface ReverseReferenceDeclaration {
fieldName: string;
tableName: string;
foreignKey: string;
isOptional: boolean;
schema: ReverseReferenceSchema;
}
export interface TypeDeclaration {
name: string;
schema: Schema;
}
/**
* Parse a type declaration from a comment line.
* Format: # TypeName := schema
* Returns null if the line is not a type declaration.
*/
export function parseTypeDeclaration(
line: string,
commentChar: string = "#",
): { typeName: string; schemaString: string } | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: TypeName := schema
const match = content.match(/^([A-Z][a-zA-Z0-9]*)\s*:=\s*(.+)$/);
if (!match) return null;
const [, typeName, schemaString] = match;
return { typeName, schemaString };
}
/**
* Expand a type name to its schema by replacing the type name with its schema inline.
* Returns the schema string with type names expanded, or null if not a type name.
*/
function expandTypeName(
schemaString: string,
declaredTypes: Map<string, string>,
): string | null {
const trimmed = schemaString.trim();
if (declaredTypes.has(trimmed)) {
return declaredTypes.get(trimmed)!;
}
return null;
}
/**
* Recursively expand all type name references in a schema string.
* Handles unions, tuples, arrays, and nested structures.
*/
export function expandSchemaString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
let result = schemaString;
// Keep expanding until no more changes (handles recursive dependencies)
let prev = "";
while (prev !== result) {
prev = result;
result = expandSchemaInString(result, declaredTypes);
}
return result;
}
/**
* Single pass of type name expansion in a schema string.
*/
function expandSchemaInString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
// Check if the entire string is a type name
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
return expanded;
}
// Handle union types (recursively expand each member)
if (schemaString.includes("|")) {
// Split by | but respect quotes
const parts = splitByToken(schemaString, "|");
if (parts.length > 1) {
const expandedParts = parts.map((part) =>
expandSchemaInString(part.trim(), declaredTypes),
);
return expandedParts.join(" | ");
}
}
// Handle tuple/array syntax [el1; el2; ...] or [elements]
// Check if it's a bracketed structure
if (schemaString.startsWith("[") && schemaString.endsWith("]")) {
const inner = schemaString.slice(1, -1);
// Check if it's semicolon-separated (tuple syntax)
if (inner.includes(";")) {
const elements = splitByToken(inner, ";");
const expandedElements = elements.map((el) =>
expandSchemaInString(el.trim(), declaredTypes),
);
return `[${expandedElements.join("; ")}]`;
}
// Otherwise it's a simple array, expand recursively
return `[${expandSchemaInString(inner, declaredTypes)}]`;
}
// Check if it's a type name reference (only uppercase start to avoid conflicts with primitives)
const typeNameMatch = schemaString.trim().match(/^[A-Z][a-zA-Z0-9]*$/);
if (typeNameMatch) {
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
return expanded;
}
}
return schemaString;
}
/**
* Split a string by a token, respecting quoted strings.
*/
function splitByToken(str: string, token: string): string[] {
const result: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (inQuote) {
if (char === inQuote && str[i - 1] !== "\\") {
inQuote = null;
}
current += char;
} else if (char === '"' || char === "'") {
inQuote = char;
current += char;
} else if (char === token && inQuote === null) {
result.push(current);
current = "";
} else {
current += char;
}
}
if (current.length > 0 || str.endsWith(token)) {
result.push(current);
}
return result;
}
/**
* Resolve type name references within a schema using declared types.
* For example, if "Trigger" is a declared type, references to "Trigger" in
* other schemas will be replaced with the actual Trigger schema definition.
*/
export function resolveTypeReferences(
schema: Schema,
declaredTypes: Map<string, Schema>,
): Schema {
switch (schema.type) {
case "union":
return {
type: "union",
members: schema.members.map((m) =>
resolveTypeReferences(m, declaredTypes),
),
};
case "tuple":
return {
type: "tuple",
elements: schema.elements.map((el) => ({
name: el.name,
schema: resolveTypeReferences(el.schema, declaredTypes),
})),
};
case "array":
return {
type: "array",
element: resolveTypeReferences(schema.element, declaredTypes),
};
case "reference":
// Don't resolve references to other tables
return schema;
default:
return schema;
}
}
/**
* Resolve type name references in a type declaration's schema string.
* Called after all type names are known.
*/
export function resolveTypeDeclarationSchema(
schemaString: string,
declaredTypes: Map<string, Schema>,
): Schema {
const schema = parseSchema(schemaString.trim());
return resolveTypeReferences(schema, declaredTypes);
}
/**
* Parse a reverse reference declaration from a comment line.
* Format: # fieldName := ~tableName(foreignKey)
* Returns null if the line is not a reverse reference declaration.
*/
export function parseReverseReferenceDeclaration(
line: string,
commentChar: string = "#",
): ReverseReferenceDeclaration | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: fieldName := ~tableName(foreignKey)
const match = content.match(/^(\w+)\s*:=\s*~(\w+)\((\w+)\)(\?)?$/);
if (!match) return null;
const [, fieldName, tableName, foreignKey, optionalMark] = match;
const isOptional = optionalMark === "?";
const schema: ReverseReferenceSchema = {
type: "reverseReference",
tableName,
foreignKey,
isOptional,
};
return {
fieldName,
tableName,
foreignKey,
isOptional,
schema,
};
}

111
src/type-utils.ts Normal file
View File

@ -0,0 +1,111 @@
import type {
Schema,
ReferenceSchema,
ReverseReferenceSchema,
UnionSchema,
} from "./types";
export function schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) {
case "string":
return "string";
case "number":
case "int":
case "float":
return "number";
case "boolean":
return "boolean";
case "stringLiteral":
return `"${schema.value}"`;
case "union":
return schema.members
.map((m) => schemaToTypeString(m, resourceNames))
.join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "reverseReference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = `${typeName}[]`;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "array":
if (schema.element.type === "tuple") {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}][]`;
}
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === "union") {
return `(${elementType})[]`;
}
return `${elementType}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
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 "int":
return (
typeof value === "number" && !isNaN(value) && Number.isInteger(value)
);
case "float":
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;
return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema.schema)(value[index]),
);
case "array":
if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item));
case "reference":
if (schema.isOptional && value === null) return true;
if (schema.isArray) {
return (
Array.isArray(value) && value.every((id) => typeof id === "string")
);
}
return (
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"))
);
case "reverseReference":
if (schema.isOptional && value === null) return true;
return Array.isArray(value);
default:
return false;
}
};
}

View File

@ -1,540 +1,3 @@
import type { // Re-export everything from the split modules
Schema, export { parseValue } from "./value-parser";
PrimitiveSchema, export { schemaToTypeString, createValidator } from "./type-utils";
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} 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 "int":
return this.parseIntValue();
case "float":
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":
return this.parseArrayValue(schema, allowOmitBrackets);
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema);
case "reverseReference":
// Reverse references are derived fields, not stored in CSV cells
// They resolve to null at parse time; actual resolution happens in the loader
return null;
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 parseIntValue(): 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);
}
if (!Number.isInteger(num)) {
throw new ParseError("Expected integer value", this.pos - numStr.length);
}
return num;
}
private parseFloatValue(): number {
return this.parseNumberValue();
}
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 parseStringLiteralValue(schema: StringLiteralSchema): string {
const quote = this.peek();
// 支持带引号或不带引号的字符串值
if (quote === '"' || quote === "'") {
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);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ")") {
break;
}
value += this.consume();
}
value = value.trim();
if (value !== schema.value) {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length,
);
}
return value;
}
}
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;
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();
const elementSchema = schema.elements[i];
// Try to consume optional name prefix (e.g., "current:")
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
// Name not found, reset position and continue without name
this.pos = savedPos;
}
}
result.push(this.parseValue(elementSchema.schema, 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.pos >= this.input.length || !this.input.trim()) {
return [];
}
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;
}
private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) {
this.skipWhitespace();
if (this.pos >= this.input.length) {
return null;
}
}
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
// Parse each ID as a string
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
ids.push(id.trim());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return ids;
} else {
// Parse single ID as string
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
}
return id.trim();
}
}
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 schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) {
case "string":
return "string";
case "number":
case "int":
case "float":
return "number";
case "boolean":
return "boolean";
case "stringLiteral":
return `"${schema.value}"`;
case "union":
return schema.members
.map((m) => schemaToTypeString(m, resourceNames))
.join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "reverseReference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
// Reverse references always resolve to an array (one-to-many)
const baseType = `${typeName}[]`;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "array":
if (schema.element.type === "tuple") {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}][]`;
}
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === "union") {
return `(${elementType})[]`;
}
return `${elementType}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
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 "int":
return (
typeof value === "number" && !isNaN(value) && Number.isInteger(value)
);
case "float":
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;
return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema.schema)(value[index]),
);
case "array":
if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item));
case "reference":
if (schema.isOptional && value === null) return true;
if (schema.isArray) {
return (
Array.isArray(value) && value.every((id) => typeof id === "string")
);
}
return (
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"))
);
case "reverseReference":
if (schema.isOptional && value === null) return true;
return Array.isArray(value);
default:
return false;
}
};
}

431
src/value-parser.ts Normal file
View File

@ -0,0 +1,431 @@
import type {
Schema,
TupleSchema,
ArraySchema,
ReferenceSchema,
StringLiteralSchema,
UnionSchema,
} 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 "int":
return this.parseIntValue();
case "float":
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":
return this.parseArrayValue(schema, allowOmitBrackets);
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema);
case "reverseReference":
// Reverse references are derived fields, not stored in CSV cells
// They resolve to null at parse time; actual resolution happens in the loader
return null;
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 parseIntValue(): 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);
}
if (!Number.isInteger(num)) {
throw new ParseError("Expected integer value", this.pos - numStr.length);
}
return num;
}
private parseFloatValue(): number {
return this.parseNumberValue();
}
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 parseStringLiteralValue(schema: StringLiteralSchema): string {
const quote = this.peek();
// 支持带引号或不带引号的字符串值
if (quote === '"' || quote === "'") {
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);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ")") {
break;
}
value += this.consume();
}
value = value.trim();
if (value !== schema.value) {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length,
);
}
return value;
}
}
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;
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();
const elementSchema = schema.elements[i];
// Try to consume optional name prefix (e.g., "current:")
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
// Name not found, reset position and continue without name
this.pos = savedPos;
}
}
result.push(this.parseValue(elementSchema.schema, 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.pos >= this.input.length || !this.input.trim()) {
return [];
}
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;
}
private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) {
this.skipWhitespace();
if (this.pos >= this.input.length) {
return null;
}
}
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
// Parse each ID as a string
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
ids.push(id.trim());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return ids;
} else {
// Parse single ID as string
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
}
return id.trim();
}
}
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;
}