Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/lang-core/src/parser/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,64 @@ describe("orphaned statements", () => {
});
});

// ── strict mode ────────────────────────────────────────────────────────────────

describe("strict mode", () => {
it("ignores non-statement lines by default (non-strict)", () => {
const result = parse(
'Here is the response:\nroot = Stack([Title("hi")])',
schema,
);
expect(result.meta.errors).toHaveLength(0);
expect(result.root).not.toBeNull();
});

it("reports non-statement lines as errors in strict mode", () => {
const result = parse(
'Here is the response:\nroot = Stack([Title("hi")])',
schema,
undefined,
true,
);
expect(result.meta.errors.length).toBeGreaterThan(0);
expect(result.meta.errors[0].code).toBe("parse-failed");
expect(result.meta.errors[0].message).toMatch(/unexpected text/i);
});

it("reports invalid identifier lines in strict mode", () => {
const result = parse(
'foo bar\nroot = Stack([Title("hi")])',
schema,
undefined,
true,
);
expect(result.meta.errors.length).toBeGreaterThan(0);
expect(result.meta.errors[0].code).toBe("parse-failed");
});

it("still parses valid statements correctly in strict mode", () => {
const result = parse(
'root = Stack([Title("hi")])',
schema,
undefined,
true,
);
expect(result.meta.errors).toHaveLength(0);
expect(result.root).not.toBeNull();
});

it("reports multiple invalid lines", () => {
const result = parse(
'some text\nmore noise\nroot = Stack([Title("hi")])',
schema,
undefined,
true,
);
expect(result.meta.errors).toHaveLength(2);
expect(result.meta.errors.every((e: { code: string }) => e.code === "parse-failed")).toBe(true);
});
});

describe("markdown fences and multiline comments in strings", () => {
it("preserves js markdown fences inside strings", () => {
const code = 'root = Title("```js\\nconsole.log(\\"Hello World\\");\\n```")';
Expand Down
2 changes: 1 addition & 1 deletion packages/lang-core/src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type {
ValidationErrorCode,
} from "./types";

export { createParser, createStreamingParser, parse } from "./parser";
export { createParser, createStrictParser, createStreamingParser, parse } from "./parser";
export type { Parser, StreamParser } from "./parser";

export { enrichErrors } from "./enrich-errors";
Expand Down
36 changes: 33 additions & 3 deletions packages/lang-core/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,16 @@ function preprocess(input: string): string {
*
* @param input - Full openui-lang source text (may be partial/streaming)
* @param cat - Param map for positional-arg → named-prop mapping
* @param strict - When true, report lines that are not valid openui-lang as errors
* @returns ParseResult with root ElementNode (or null) and metadata
*/
export function parse(input: string, cat: ParamMap, rootName?: string): ParseResult {
export function parse(input: string, cat: ParamMap, rootName?: string, strict?: boolean): ParseResult {
const trimmed = preprocess(input);
if (!trimmed) return emptyResult();

const { text, wasIncomplete } = autoClose(trimmed);
const stmts = split(tokenize(text));
const skipped: string[] = [];
const stmts = split(tokenize(text), strict ? skipped : undefined);
if (!stmts.length) return emptyResult(wasIncomplete);

const stmtMap = new Map<string, Statement>();
Expand All @@ -422,7 +424,21 @@ export function parse(input: string, cat: ParamMap, rootName?: string): ParseRes
// Derive from map to deduplicate — Map.set overwrites duplicates
const typedStmts = [...stmtMap.values()];

return buildResult(stmtMap, typedStmts, firstId, wasIncomplete, stmtMap.size, cat, rootName);
const result = buildResult(stmtMap, typedStmts, firstId, wasIncomplete, stmtMap.size, cat, rootName);

// In strict mode, add parse-level errors for skipped lines
if (strict && skipped.length > 0) {
for (const line of skipped) {
result.meta.errors.push({
code: "parse-failed",
component: "",
path: "",
message: `Unexpected text: "${line}" — expected a valid openui-lang statement (identifier = expression)`,
});
}
}

return result;
}

export interface StreamParser {
Expand Down Expand Up @@ -658,6 +674,20 @@ export function createParser(schema: LibraryJSONSchema, rootName?: string): Pars
};
}

/**
* Create a parser from a library JSON Schema document with strict mode.
* When strict is true, lines that don't parse as valid openui-lang statements
* are reported as errors instead of being silently skipped.
*/
export function createStrictParser(schema: LibraryJSONSchema, rootName?: string): Parser {
const paramMap = compileSchema(schema);
return {
parse(input: string): ParseResult {
return parse(input, paramMap, rootName, true);
},
};
}

/**
* Create a streaming parser from a library JSON Schema document.
* Pass `library.toJSONSchema()` to get the schema.
Expand Down
18 changes: 14 additions & 4 deletions packages/lang-core/src/parser/statements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Statement splitter for openui-lang
// ─────────────────────────────────────────────────────────────────────────────

import { T, type Token } from "./tokens";
import { T, type Token, tokenText } from "./tokens";

export interface RawStmt {
id: string;
Expand Down Expand Up @@ -69,7 +69,7 @@ export function autoClose(input: string): { text: string; wasIncomplete: boolean
*
* Invalid lines (no `=`, or no identifier) are silently skipped.
*/
export function split(tokens: Token[]): RawStmt[] {
export function split(tokens: Token[], skipped?: string[]): RawStmt[] {
const stmts: RawStmt[] = [];
let pos = 0;

Expand All @@ -81,7 +81,12 @@ export function split(tokens: Token[]): RawStmt[] {
// Expect: Ident|Type|StateVar = expression
const tok = tokens[pos];
if (tok.t !== T.Ident && tok.t !== T.Type && tok.t !== T.StateVar) {
while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++;
let text = "";
while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) {
text += tokenText(tokens[pos]);
pos++;
}
if (skipped && text.trim()) skipped.push(text.trim());
continue;
}
const id = tok.v as string;
Expand All @@ -90,7 +95,12 @@ export function split(tokens: Token[]): RawStmt[] {

// Must be followed by `=`
if (pos >= tokens.length || tokens[pos].t !== T.Equals) {
while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++;
let text = id;
while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) {
text += tokenText(tokens[pos]);
pos++;
}
if (skipped && text.trim()) skipped.push(text.trim());
continue;
}
pos++;
Expand Down
38 changes: 38 additions & 0 deletions packages/lang-core/src/parser/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,41 @@ export type Token = {
t: T;
v?: string | number;
};

/** Reconstruct the text representation of a token. */
export function tokenText(tok: Token): string {
if (tok.v !== undefined) return String(tok.v);
switch (tok.t) {
case T.Newline: return "\n";
case T.LParen: return "(";
case T.RParen: return ")";
case T.LBrack: return "[";
case T.RBrack: return "]";
case T.LBrace: return "{";
case T.RBrace: return "}";
case T.Comma: return ",";
case T.Colon: return ":";
case T.Equals: return "=";
case T.True: return "true";
case T.False: return "false";
case T.Null: return "null";
case T.Dot: return ".";
case T.Plus: return "+";
case T.Minus: return "-";
case T.Star: return "*";
case T.Slash: return "/";
case T.Percent: return "%";
case T.EqEq: return "==";
case T.NotEq: return "!=";
case T.Greater: return ">";
case T.Less: return "<";
case T.GreaterEq: return ">=";
case T.LessEq: return "<=";
case T.And: return "&&";
case T.Or: return "||";
case T.Not: return "!";
case T.Question: return "?";
case T.EOF: return "";
default: return "";
}
}
3 changes: 2 additions & 1 deletion packages/lang-core/src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export type ValidationErrorCode =
| "null-required"
| "unknown-component"
| "inline-reserved"
| "excess-args";
| "excess-args"
| "parse-failed";

/**
* A prop validation error. Components with missing required props are
Expand Down