Implements Phase 1 and 2 of the syntax rework plan: - Brackets `[]` are now mandatory for all tuple and array values. - Removed the `[Type][]` array syntax; arrays now only use `Type[]`. - `[single]` is now strictly a 1-tuple rather than an array. - Updated documentation and test fixtures to reflect these breaking changes.
482 lines
11 KiB
TypeScript
482 lines
11 KiB
TypeScript
import type {
|
|
Schema,
|
|
TupleSchema,
|
|
ArraySchema,
|
|
ReferenceSchema,
|
|
StringLiteralSchema,
|
|
UnionSchema,
|
|
} from "./types";
|
|
import { ParseError } from "./parser";
|
|
import { schemaToTypeString } from "./type-utils";
|
|
|
|
class ValueParser {
|
|
private input: string;
|
|
private schemaString: string;
|
|
private pos: number = 0;
|
|
|
|
constructor(input: string, schemaString: string) {
|
|
this.input = input;
|
|
this.schemaString = schemaString;
|
|
}
|
|
|
|
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): 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);
|
|
case "array":
|
|
return this.parseArrayValue(schema);
|
|
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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
if (!Number.isInteger(num)) {
|
|
throw new ParseError(
|
|
"Expected integer value",
|
|
this.pos - numStr.length,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
return value;
|
|
} else {
|
|
value += this.consume();
|
|
}
|
|
}
|
|
|
|
throw new ParseError(
|
|
"Unterminated string literal",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
} 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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
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]);
|
|
} 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,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
private parseTupleValue(schema: TupleSchema): unknown[] {
|
|
if (!this.consumeStr("[")) {
|
|
throw new ParseError(
|
|
"Expected [",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (this.peek() === "]") {
|
|
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));
|
|
this.skipWhitespace();
|
|
|
|
if (i < schema.elements.length - 1) {
|
|
if (!this.consumeStr(";")) {
|
|
throw new ParseError(
|
|
"Expected ;",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError(
|
|
"Expected ]",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private parseArrayValue(schema: ArraySchema): unknown[] {
|
|
if (this.pos >= this.input.length || !this.input.trim()) {
|
|
return [];
|
|
}
|
|
|
|
if (!this.consumeStr("[")) {
|
|
throw new ParseError(
|
|
"Expected [",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (this.peek() === "]") {
|
|
this.consume();
|
|
return [];
|
|
}
|
|
|
|
const result: unknown[] = [];
|
|
while (true) {
|
|
this.skipWhitespace();
|
|
|
|
result.push(this.parseValue(schema.element));
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr(";")) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError(
|
|
"Expected ]",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
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]
|
|
if (!this.consumeStr("[")) {
|
|
throw new ParseError(
|
|
"Expected [",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (this.peek() === "]") {
|
|
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;
|
|
}
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError(
|
|
"Expected ]",
|
|
this.pos,
|
|
this.schemaString,
|
|
this.input,
|
|
);
|
|
}
|
|
|
|
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,
|
|
schemaString?: string,
|
|
): unknown {
|
|
const sStr = schemaString || schemaToTypeString(schema);
|
|
const parser = new ValueParser(valueString.trim(), sStr);
|
|
const value = parser.parseValue(schema);
|
|
|
|
if (parser.getPosition() < parser.getInputLength()) {
|
|
throw new ParseError(
|
|
"Unexpected input after value",
|
|
parser.getPosition(),
|
|
sStr,
|
|
valueString.trim(),
|
|
);
|
|
}
|
|
|
|
return value;
|
|
}
|