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
61 changes: 61 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,66 @@ 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);
});
});

// ── markdown fences ──────────────────────────────────────────────────────────────

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 Expand Up @@ -285,5 +345,6 @@ root = Title("hello")
expect(result.meta.errors).toHaveLength(0);
expect(result.root).not.toBeNull();
expect(result.root?.props.text).toBe("hello");

});
});
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
2 changes: 2 additions & 0 deletions packages/react-lang/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"jsdom": "^29.1.1",
"vitest": "^4.0.18"
}
}
79 changes: 79 additions & 0 deletions packages/react-lang/src/__tests__/Renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import { createLibrary, defineComponent } from "../library";
import { Renderer } from "../Renderer";

// Dummy renderer — never actually renders DOM, used for parser/callback tests
const DummyComponent = (() => null) as any;

const TextContent = defineComponent({
name: "TextContent",
props: z.object({ text: z.string() }),
description: "Displays text content",
component: DummyComponent,
});

const library = createLibrary({
components: [TextContent],
root: "TextContent",
});

// openui-lang uses assignment syntax: `identifier = Component(args)`
const VALID_RESPONSE = 'root = TextContent("Hello world")';

// ─── Renderer ───────────────────────────────────────────────────────────────

describe("Renderer", () => {
it("renders without errors when response is null", () => {
const { container } = render(<Renderer response={null} library={library} />);

expect(container).toBeDefined();
});

it("renders without errors when response is empty string", () => {
const { container } = render(<Renderer response="" library={library} />);

expect(container).toBeDefined();
});

it("calls onParseResult with null when response is null", () => {
const onParseResult = vi.fn();

render(<Renderer response={null} library={library} onParseResult={onParseResult} />);

expect(onParseResult).toHaveBeenCalledWith(null);
});

it("calls onParseResult with a ParseResult when given valid openui-lang", async () => {
const onParseResult = vi.fn();

render(<Renderer response={VALID_RESPONSE} library={library} onParseResult={onParseResult} />);

expect(onParseResult).toHaveBeenCalled();
const result = onParseResult.mock.calls[onParseResult.mock.calls.length - 1]![0];
expect(result).not.toBeNull();
expect(result.root).toBeDefined();
expect(result.root).not.toBeNull();
});

it("parse result contains the correct component typeName", async () => {
const onParseResult = vi.fn();

render(<Renderer response={VALID_RESPONSE} library={library} onParseResult={onParseResult} />);

const result = onParseResult.mock.calls[onParseResult.mock.calls.length - 1]![0];
expect(result?.root?.typeName).toBe("TextContent");
});

it("defaults isStreaming to false", () => {
// Should not throw when isStreaming is omitted
const { container } = render(<Renderer response={null} library={library} />);
expect(container).toBeDefined();
});

it("accepts isStreaming prop without errors", () => {
const { container } = render(<Renderer response={null} library={library} isStreaming={true} />);
expect(container).toBeDefined();
});
});
8 changes: 8 additions & 0 deletions packages/react-lang/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
environment: "jsdom",
},
});