diff --git a/packages/lang-core/src/parser/__tests__/parser.test.ts b/packages/lang-core/src/parser/__tests__/parser.test.ts index b95ed32b2..e56181ead 100644 --- a/packages/lang-core/src/parser/__tests__/parser.test.ts +++ b/packages/lang-core/src/parser/__tests__/parser.test.ts @@ -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```")'; @@ -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"); + }); }); diff --git a/packages/lang-core/src/parser/index.ts b/packages/lang-core/src/parser/index.ts index 13c2138bb..4aa97073c 100644 --- a/packages/lang-core/src/parser/index.ts +++ b/packages/lang-core/src/parser/index.ts @@ -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"; diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index 9be78733c..7b8c6f809 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -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(); @@ -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 { @@ -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. diff --git a/packages/lang-core/src/parser/statements.ts b/packages/lang-core/src/parser/statements.ts index 9cc3d6b32..41de2f571 100644 --- a/packages/lang-core/src/parser/statements.ts +++ b/packages/lang-core/src/parser/statements.ts @@ -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; @@ -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; @@ -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; @@ -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++; diff --git a/packages/lang-core/src/parser/tokens.ts b/packages/lang-core/src/parser/tokens.ts index 4202a955b..fee9604b2 100644 --- a/packages/lang-core/src/parser/tokens.ts +++ b/packages/lang-core/src/parser/tokens.ts @@ -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 ""; + } +} diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index dbe32229c..b6272c7dd 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -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 diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index 72b6693a3..dd20f8177 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -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" } } diff --git a/packages/react-lang/src/__tests__/Renderer.test.tsx b/packages/react-lang/src/__tests__/Renderer.test.tsx new file mode 100644 index 000000000..f86fad684 --- /dev/null +++ b/packages/react-lang/src/__tests__/Renderer.test.tsx @@ -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(); + + expect(container).toBeDefined(); + }); + + it("renders without errors when response is empty string", () => { + const { container } = render(); + + expect(container).toBeDefined(); + }); + + it("calls onParseResult with null when response is null", () => { + const onParseResult = vi.fn(); + + render(); + + expect(onParseResult).toHaveBeenCalledWith(null); + }); + + it("calls onParseResult with a ParseResult when given valid openui-lang", async () => { + const onParseResult = vi.fn(); + + render(); + + 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(); + + 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(); + expect(container).toBeDefined(); + }); + + it("accepts isStreaming prop without errors", () => { + const { container } = render(); + expect(container).toBeDefined(); + }); +}); diff --git a/packages/react-lang/vitest.config.ts b/packages/react-lang/vitest.config.ts new file mode 100644 index 000000000..64153fc54 --- /dev/null +++ b/packages/react-lang/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + environment: "jsdom", + }, +});