450 lines
11 KiB
TypeScript
450 lines
11 KiB
TypeScript
import type {
|
|
Schema,
|
|
PrimitiveSchema,
|
|
TupleSchema,
|
|
ArraySchema,
|
|
NamedSchema,
|
|
ReferenceSchema,
|
|
ReverseReferenceSchema,
|
|
StringLiteralSchema,
|
|
UnionSchema,
|
|
} from "./types";
|
|
|
|
export class ParseError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public position?: number,
|
|
public schema?: string,
|
|
public value?: string,
|
|
) {
|
|
let fullMessage = message;
|
|
if (position !== undefined) {
|
|
fullMessage += ` at position ${position}`;
|
|
}
|
|
if (schema !== undefined) {
|
|
fullMessage += `. Schema: ${schema}`;
|
|
}
|
|
if (value !== undefined) {
|
|
fullMessage += `. Value: ${value}`;
|
|
}
|
|
super(fullMessage);
|
|
this.name = "ParseError";
|
|
}
|
|
}
|
|
|
|
export interface ReferenceInfo {
|
|
/** Referenced table name (e.g., 'parts' from '@parts[]') */
|
|
tableName: string;
|
|
/** Whether it's an array reference */
|
|
isArray: boolean;
|
|
}
|
|
|
|
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();
|
|
|
|
// Parse the first schema element
|
|
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];
|
|
|
|
while (true) {
|
|
this.skipWhitespace();
|
|
const member = this.parseSchemaInternal();
|
|
members.push(member);
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr("|")) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: "union",
|
|
members,
|
|
};
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
private parseSchemaInternal(): Schema {
|
|
this.skipWhitespace();
|
|
|
|
// Check for parentheses (grouping)
|
|
if (this.consumeStr("(")) {
|
|
this.skipWhitespace();
|
|
const schema = this.parseSchema(); // Recursive call for nested unions
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr(")")) {
|
|
throw new ParseError("Expected )", this.pos);
|
|
}
|
|
|
|
// Check if there's an array suffix: (union)[]
|
|
this.skipWhitespace();
|
|
if (this.consumeStr("[")) {
|
|
this.skipWhitespace();
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError("Expected ]", this.pos);
|
|
}
|
|
return { type: "array", element: schema };
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
// Check for string literal syntax: "value" or 'value'
|
|
if (this.peek() === '"' || this.peek() === "'") {
|
|
return this.parseStringLiteralSchema();
|
|
}
|
|
|
|
// Check for reverse reference syntax: ~tablename(foreignKey)
|
|
if (this.consumeStr("~")) {
|
|
return this.parseReverseReferenceSchema();
|
|
}
|
|
|
|
// Check for reference syntax: @tablename[]
|
|
if (this.consumeStr("@")) {
|
|
return this.parseReferenceSchema();
|
|
}
|
|
|
|
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("int")) {
|
|
if (this.consumeStr("[")) {
|
|
this.skipWhitespace();
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError("Expected ]", this.pos);
|
|
}
|
|
return { type: "array", element: { type: "int" } };
|
|
}
|
|
return { type: "int" };
|
|
}
|
|
|
|
if (this.consumeStr("float")) {
|
|
if (this.consumeStr("[")) {
|
|
this.skipWhitespace();
|
|
if (!this.consumeStr("]")) {
|
|
throw new ParseError("Expected ]", this.pos);
|
|
}
|
|
return { type: "array", element: { type: "float" } };
|
|
}
|
|
return { type: "float" };
|
|
}
|
|
|
|
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: NamedSchema[] = [];
|
|
this.skipWhitespace();
|
|
|
|
if (this.peek() === "]") {
|
|
this.consume();
|
|
throw new ParseError("Empty array/tuple not allowed", this.pos);
|
|
}
|
|
|
|
elements.push(this.parseNamedSchema());
|
|
this.skipWhitespace();
|
|
|
|
if (this.consumeStr(";")) {
|
|
const remainingElements: NamedSchema[] = [];
|
|
while (true) {
|
|
this.skipWhitespace();
|
|
remainingElements.push(this.parseNamedSchema());
|
|
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 && !elements[0].name) {
|
|
return { type: "array", element: elements[0].schema };
|
|
}
|
|
return { type: "array", element: { type: "tuple", elements } };
|
|
}
|
|
|
|
if (elements.length === 1 && !elements[0].name) {
|
|
return { type: "array", element: elements[0].schema };
|
|
}
|
|
return { type: "tuple", elements };
|
|
}
|
|
|
|
throw new ParseError(
|
|
`Unknown type: ${this.peek() || "end of input"}`,
|
|
this.pos,
|
|
);
|
|
}
|
|
|
|
private parseStringLiteralSchema(): Schema {
|
|
const value = this.parseStringLiteral();
|
|
return {
|
|
type: "stringLiteral",
|
|
value,
|
|
};
|
|
}
|
|
|
|
private parseStringLiteral(): string {
|
|
const quote = this.peek();
|
|
if (quote !== '"' && quote !== "'") {
|
|
throw new ParseError("Expected string literal with quotes", this.pos);
|
|
}
|
|
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 === "|" ||
|
|
nextChar === ";" ||
|
|
nextChar === "(" ||
|
|
nextChar === ")"
|
|
) {
|
|
value += nextChar;
|
|
} else {
|
|
value += "\\" + nextChar;
|
|
}
|
|
} else if (char === quote) {
|
|
this.consume(); // Consume closing quote
|
|
return value;
|
|
} else {
|
|
value += this.consume();
|
|
}
|
|
}
|
|
|
|
throw new ParseError("Unterminated string literal", this.pos);
|
|
}
|
|
|
|
private parseNamedSchema(): NamedSchema {
|
|
this.skipWhitespace();
|
|
|
|
const startpos = this.pos;
|
|
let identifier = "";
|
|
|
|
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
|
identifier += this.consume();
|
|
}
|
|
|
|
if (identifier.length === 0) {
|
|
const schema = this.parseSchema();
|
|
return { schema };
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (this.consumeStr(":")) {
|
|
this.skipWhitespace();
|
|
const name = identifier;
|
|
const schema = this.parseSchema();
|
|
return { name, schema };
|
|
} else {
|
|
this.pos = startpos;
|
|
const schema = this.parseSchema();
|
|
return { schema };
|
|
}
|
|
}
|
|
|
|
private parseReferenceSchema(): Schema {
|
|
// Parse table name
|
|
let tableName = "";
|
|
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
|
tableName += this.consume();
|
|
}
|
|
|
|
if (tableName.length === 0) {
|
|
throw new ParseError("Expected table name after @", this.pos);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
// Check for array syntax
|
|
if (this.consumeStr("[]")) {
|
|
this.skipWhitespace();
|
|
const isOptional = this.consumeStr("?");
|
|
return {
|
|
type: "reference",
|
|
tableName,
|
|
isArray: true,
|
|
isOptional,
|
|
};
|
|
}
|
|
|
|
// Check for optional suffix
|
|
const isOptional = this.consumeStr("?");
|
|
|
|
// Single reference (non-array)
|
|
return {
|
|
type: "reference",
|
|
tableName,
|
|
isArray: false,
|
|
isOptional,
|
|
};
|
|
}
|
|
|
|
private parseReverseReferenceSchema(): Schema {
|
|
// Parse table name
|
|
let tableName = "";
|
|
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
|
tableName += this.consume();
|
|
}
|
|
|
|
if (tableName.length === 0) {
|
|
throw new ParseError("Expected table name after ~", this.pos);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
// Parse (foreignKey)
|
|
if (!this.consumeStr("(")) {
|
|
throw new ParseError(
|
|
"Expected ( after reverse reference table name",
|
|
this.pos,
|
|
);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
let foreignKey = "";
|
|
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
|
foreignKey += this.consume();
|
|
}
|
|
|
|
if (foreignKey.length === 0) {
|
|
throw new ParseError("Expected foreign key name inside ()", this.pos);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
if (!this.consumeStr(")")) {
|
|
throw new ParseError("Expected ) after foreign key name", this.pos);
|
|
}
|
|
|
|
this.skipWhitespace();
|
|
|
|
// Check for optional suffix
|
|
const isOptional = this.consumeStr("?");
|
|
|
|
return {
|
|
type: "reverseReference",
|
|
tableName,
|
|
foreignKey,
|
|
isOptional,
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|