diff --git a/ts/packages/actionGrammar/src/grammarCompiler.ts b/ts/packages/actionGrammar/src/grammarCompiler.ts index 0bcc4769d..13c355251 100644 --- a/ts/packages/actionGrammar/src/grammarCompiler.ts +++ b/ts/packages/actionGrammar/src/grammarCompiler.ts @@ -11,40 +11,93 @@ import { Rule, RuleDefinition } from "./grammarRuleParser.js"; type DefinitionMap = Map< string, - { rules: Rule[]; grammarRules?: GrammarRule[] } + { rules: Rule[]; pos: number | undefined; grammarRules?: GrammarRule[] } >; -export function compileGrammar(definitions: RuleDefinition[]): Grammar { +type GrammarCompileResult = { + grammar: Grammar; + errors: GrammarCompileError[]; + warnings: GrammarCompileError[]; +}; + +export type GrammarCompileError = { + message: string; + definition?: string | undefined; + pos?: number | undefined; +}; + +type CompileContext = { + ruleDefMap: DefinitionMap; + currentDefinition?: string | undefined; + errors: GrammarCompileError[]; + warnings: GrammarCompileError[]; +}; + +export function compileGrammar( + definitions: RuleDefinition[], + start: string, +): GrammarCompileResult { const ruleDefMap: DefinitionMap = new Map(); + const context: CompileContext = { + ruleDefMap, + errors: [], + warnings: [], + }; + for (const def of definitions) { const existing = ruleDefMap.get(def.name); if (existing === undefined) { - ruleDefMap.set(def.name, { rules: [...def.rules] }); + ruleDefMap.set(def.name, { rules: [...def.rules], pos: def.pos }); } else { existing.rules.push(...def.rules); } } - return { rules: createGrammarRules(ruleDefMap, "Start") }; + const grammar = { rules: createGrammarRules(context, start) }; + + for (const [name, record] of ruleDefMap.entries()) { + if (record.grammarRules === undefined) { + context.warnings.push({ + message: `Rule '<${name}>' is defined but never used.`, + pos: record.pos, + }); + } + } + return { + grammar, + errors: context.errors, + warnings: context.warnings, + }; } +const emptyRecord = { rules: [], pos: undefined, grammarRules: [] }; function createGrammarRules( - ruleDefMap: DefinitionMap, + context: CompileContext, name: string, + pos?: number, ): GrammarRule[] { - const record = ruleDefMap.get(name); + const record = context.ruleDefMap.get(name); if (record === undefined) { - throw new Error(`Missing rule definition for '<${name}>'`); + context.errors.push({ + message: `Missing rule definition for '<${name}>'`, + definition: context.currentDefinition, + pos, + }); + context.ruleDefMap.set(name, emptyRecord); + return emptyRecord.grammarRules; } if (record.grammarRules === undefined) { record.grammarRules = []; + const prev = context.currentDefinition; + context.currentDefinition = name; for (const r of record.rules) { - record.grammarRules.push(createGrammarRule(ruleDefMap, r)); + record.grammarRules.push(createGrammarRule(context, r)); } + context.currentDefinition = prev; } return record.grammarRules; } -function createGrammarRule(ruleDefMap: DefinitionMap, rule: Rule): GrammarRule { +function createGrammarRule(context: CompileContext, rule: Rule): GrammarRule { const { expressions, value } = rule; const parts: GrammarPart[] = []; for (const expr of expressions) { @@ -59,15 +112,15 @@ function createGrammarRule(ruleDefMap: DefinitionMap, rule: Rule): GrammarRule { break; } case "variable": { - const { name, typeName, ruleReference } = expr; + const { name, typeName, ruleReference, ruleRefPos } = expr; if (ruleReference) { - const rules = ruleDefMap.get(typeName); - if (rules === undefined) { - throw new Error(`No rule named ${typeName}`); - } parts.push({ type: "rules", - rules: createGrammarRules(ruleDefMap, typeName), + rules: createGrammarRules( + context, + typeName, + ruleRefPos, + ), variable: name, name: typeName, optional: expr.optional, @@ -91,7 +144,7 @@ function createGrammarRule(ruleDefMap: DefinitionMap, rule: Rule): GrammarRule { case "ruleReference": parts.push({ type: "rules", - rules: createGrammarRules(ruleDefMap, expr.name), + rules: createGrammarRules(context, expr.name, expr.pos), name: expr.name, }); break; @@ -99,7 +152,7 @@ function createGrammarRule(ruleDefMap: DefinitionMap, rule: Rule): GrammarRule { const { rules, optional } = expr; parts.push({ type: "rules", - rules: rules.map((r) => createGrammarRule(ruleDefMap, r)), + rules: rules.map((r) => createGrammarRule(context, r)), optional, }); @@ -107,7 +160,7 @@ function createGrammarRule(ruleDefMap: DefinitionMap, rule: Rule): GrammarRule { } default: throw new Error( - `Unknown expression type ${(expr as any).type}`, + `Internal Error: Unknown expression type ${(expr as any).type}`, ); } } diff --git a/ts/packages/actionGrammar/src/grammarLoader.ts b/ts/packages/actionGrammar/src/grammarLoader.ts index ff8010c2c..1cee18c73 100644 --- a/ts/packages/actionGrammar/src/grammarLoader.ts +++ b/ts/packages/actionGrammar/src/grammarLoader.ts @@ -1,12 +1,72 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { compileGrammar } from "./grammarCompiler.js"; +import { compileGrammar, GrammarCompileError } from "./grammarCompiler.js"; import { parseGrammarRules } from "./grammarRuleParser.js"; import { Grammar } from "./grammarTypes.js"; +import { getLineCol } from "./utils.js"; -export function loadGrammarRules(fileName: string, content: string): Grammar { +// REVIEW: start symbol should be configurable +const start = "Start"; + +function convertCompileError( + fileName: string, + content: string, + type: "error" | "warning", + errors: GrammarCompileError[], +) { + return errors.map((e) => { + const lineCol = getLineCol(content, e.pos ?? 0); + return `${fileName}(${lineCol.line},${lineCol.col}): ${type}: ${e.message}${e.definition ? ` in definition '<${e.definition}>'` : ""}`; + }); +} + +// Throw exception when error. +export function loadGrammarRules(fileName: string, content: string): Grammar; +// Return undefined when error if errors array provided. +export function loadGrammarRules( + fileName: string, + content: string, + errors: string[], + warnings?: string[], +): Grammar | undefined; +export function loadGrammarRules( + fileName: string, + content: string, + errors?: string[], + warnings?: string[], +): Grammar | undefined { const definitions = parseGrammarRules(fileName, content); - const grammar = compileGrammar(definitions); - return grammar; + const result = compileGrammar(definitions, start); + + if (result.warnings.length > 0 && warnings !== undefined) { + warnings.push( + ...convertCompileError( + fileName, + content, + "warning", + result.warnings, + ), + ); + } + + if (result.errors.length === 0) { + return result.grammar; + } + const errorMessages = convertCompileError( + fileName, + content, + "error", + result.errors, + ); + if (errors) { + errors.push(...errorMessages); + return undefined; + } + + const errorStr = result.errors.length === 1 ? "error" : "errors"; + errorMessages.unshift( + `Error detected in grammar compilation '${fileName}': ${result.errors.length} ${errorStr}.`, + ); + throw new Error(errorMessages.join("\n")); } diff --git a/ts/packages/actionGrammar/src/grammarRuleParser.ts b/ts/packages/actionGrammar/src/grammarRuleParser.ts index e1162d3f3..97fc4b23e 100644 --- a/ts/packages/actionGrammar/src/grammarRuleParser.ts +++ b/ts/packages/actionGrammar/src/grammarRuleParser.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import registerDebug from "debug"; +import { getLineCol } from "./utils.js"; const debugParse = registerDebug("typeagent:grammar:parse"); /** @@ -50,8 +51,9 @@ const debugParse = registerDebug("typeagent:grammar:parse"); export function parseGrammarRules( fileName: string, content: string, + position: boolean = true, ): RuleDefinition[] { - const parser = new GrammarRuleParser(fileName, content); + const parser = new GrammarRuleParser(fileName, content, position); const definitions = parser.parse(); debugParse(JSON.stringify(definitions, undefined, 2)); return definitions; @@ -67,6 +69,7 @@ type StrExpr = { type RuleRefExpr = { type: "ruleReference"; name: string; + pos?: number | undefined; }; type RulesExpr = { @@ -80,6 +83,7 @@ type VarDefExpr = { name: string; typeName: string; ruleReference: boolean; + ruleRefPos?: number | undefined; optional?: boolean; }; @@ -113,6 +117,7 @@ export type Rule = { export type RuleDefinition = { name: string; rules: Rule[]; + pos?: number | undefined; }; export function isWhitespace(char: string) { @@ -152,8 +157,13 @@ class GrammarRuleParser { constructor( private readonly fileName: string, private readonly content: string, + private readonly position: boolean = true, ) {} + private get pos(): number | undefined { + return this.position ? this.curr : undefined; + } + private isAtWhiteSpace() { return !this.isAtEnd() && isWhitespace(this.content[this.curr]); } @@ -319,14 +329,16 @@ class GrammarRuleParser { const id = this.parseId("Variable name"); let typeName: string = "string"; let ruleReference: boolean = false; + let ruleRefPos: number | undefined = undefined; if (this.isAt(":")) { // Consume colon this.skipWhitespace(1); if (this.isAt("<")) { - typeName = this.parseRuleName(); + ruleRefPos = this.pos; ruleReference = true; + typeName = this.parseRuleName(); } else { typeName = this.parseId("Type name"); } @@ -336,6 +348,7 @@ class GrammarRuleParser { name: id, typeName, ruleReference, + ruleRefPos, }; } @@ -343,11 +356,9 @@ class GrammarRuleParser { const expNodes: Expr[] = []; do { if (this.isAt("<")) { - const n = this.parseRuleName(); - expNodes.push({ - type: "ruleReference", - name: n, - }); + const pos = this.pos; + const name = this.parseRuleName(); + expNodes.push({ type: "ruleReference", name, pos }); continue; } if (this.isAt("$(")) { @@ -575,13 +586,11 @@ class GrammarRuleParser { private parseRuleDefinition(): RuleDefinition { this.consume("@", "start of rule"); - const n = this.parseRuleName(); + const pos = this.pos; + const name = this.parseRuleName(); this.consume("=", "after rule identifier"); - const r = this.parseRules(); - return { - name: n, - rules: r, - }; + const rules = this.parseRules(); + return { name, rules, pos }; } private consume(expected: string, reason?: string) { @@ -594,18 +603,7 @@ class GrammarRuleParser { } private getLineCol(pos: number) { - let line = 1; - let col = 1; - const content = this.content; - for (let i = 0; i < pos && i < content.length; i++) { - if (content[i] === "\n") { - line++; - col = 1; - } else { - col++; - } - } - return { line, col }; + return getLineCol(this.content, pos); } private throwError(message: string, pos: number = this.curr): never { diff --git a/ts/packages/actionGrammar/src/utils.ts b/ts/packages/actionGrammar/src/utils.ts new file mode 100644 index 000000000..78fdecd42 --- /dev/null +++ b/ts/packages/actionGrammar/src/utils.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export function getLineCol(content: string, pos: number) { + let line = 1; + let col = 1; + for (let i = 0; i < pos && i < content.length; i++) { + if (content[i] === "\n") { + line++; + col = 1; + } else { + col++; + } + } + return { line, col }; +} diff --git a/ts/packages/actionGrammar/test/grammarCompileError.spec.ts b/ts/packages/actionGrammar/test/grammarCompileError.spec.ts new file mode 100644 index 000000000..ad9a3ef6c --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompileError.spec.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; + +describe("Grammar Compiler", () => { + describe("Error", () => { + it("Undefined rule reference", () => { + const grammarText = ` + @ = + @ = + `; + const errors: string[] = []; + loadGrammarRules("test", grammarText, errors); + expect(errors.length).toBe(1); + expect(errors[0]).toContain( + "error: Missing rule definition for ''", + ); + }); + + it("Undefined rule reference in variable", () => { + const grammarText = ` + @ = + @ = $(x:) + `; + const errors: string[] = []; + loadGrammarRules("test", grammarText, errors); + expect(errors.length).toBe(1); + expect(errors[0]).toContain( + "error: Missing rule definition for ''", + ); + }); + }); + describe("Warning", () => { + it("Unused", () => { + const grammarText = ` + @ = + @ = pause + @ = unused + `; + const errors: string[] = []; + const warnings: string[] = []; + loadGrammarRules("test", grammarText, errors, warnings); + expect(errors.length).toBe(0); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain( + "warning: Rule '' is defined but never used.", + ); + }); + }); +}); diff --git a/ts/packages/actionGrammar/test/grammarRuleParser.spec.ts b/ts/packages/actionGrammar/test/grammarRuleParser.spec.ts index bb6882b82..c1283f04a 100644 --- a/ts/packages/actionGrammar/test/grammarRuleParser.spec.ts +++ b/ts/packages/actionGrammar/test/grammarRuleParser.spec.ts @@ -4,11 +4,14 @@ import { parseGrammarRules } from "../src/grammarRuleParser.js"; import { escapedSpaces, spaces } from "./testUtils.js"; +const testParamGrammarRules = (fileName: string, content: string) => + parseGrammarRules(fileName, content, false); + describe("Grammar Rule Parser", () => { describe("Basic Rule Definitions", () => { it("a simple rule with string expression", () => { const grammar = "@ = hello world"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toEqual([ { @@ -29,7 +32,7 @@ describe("Grammar Rule Parser", () => { it("a rule with multiple alternatives", () => { const grammar = "@ = hello | hi | hey"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toHaveLength(1); expect(result[0].name).toBe("greeting"); @@ -50,7 +53,7 @@ describe("Grammar Rule Parser", () => { it("a rule with value mapping", () => { const grammar = '@ = hello -> "greeting"'; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toHaveLength(1); expect(result[0].rules[0].value).toEqual({ @@ -64,7 +67,7 @@ describe("Grammar Rule Parser", () => { @ = hello @ = goodbye `; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toHaveLength(2); expect(result[0].name).toBe("greeting"); @@ -73,7 +76,7 @@ describe("Grammar Rule Parser", () => { it("rule with rule reference", () => { const grammar = "@ = world"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions).toHaveLength(2); expect(result[0].rules[0].expressions[0]).toEqual({ @@ -90,7 +93,7 @@ describe("Grammar Rule Parser", () => { describe("Expression Parsing", () => { it("variable expressions with default type", () => { const grammar = "@ = $(name)"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "variable", @@ -102,7 +105,7 @@ describe("Grammar Rule Parser", () => { it("variable expressions with specified type", () => { const grammar = "@ = $(count:number)"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "variable", @@ -114,7 +117,7 @@ describe("Grammar Rule Parser", () => { it("variable expressions with rule reference", () => { const grammar = "@ = $(item:)"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "variable", @@ -126,7 +129,7 @@ describe("Grammar Rule Parser", () => { it("variable expressions - optional", () => { const grammar = "@ = $(item:)?"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "variable", @@ -139,7 +142,7 @@ describe("Grammar Rule Parser", () => { it("group expressions", () => { const grammar = "@ = (hello | hi) world"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions).toHaveLength(2); expect(result[0].rules[0].expressions[0]).toEqual({ @@ -159,7 +162,7 @@ describe("Grammar Rule Parser", () => { it("optional group expressions", () => { const grammar = "@ = (please)? help"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "rules", @@ -175,7 +178,7 @@ describe("Grammar Rule Parser", () => { it("complex expressions with multiple components", () => { const grammar = "@ = $(action) the $(adverb:string)"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions).toHaveLength(4); expect(result[0].rules[0].expressions[0].type).toBe("variable"); @@ -191,7 +194,7 @@ describe("Grammar Rule Parser", () => { it("should handle escaped characters in string expressions", () => { const grammar = "@ = hello\\0world"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "string", @@ -205,8 +208,8 @@ describe("Grammar Rule Parser", () => { const grammar1 = "@ = test -> true"; const grammar2 = "@ = test -> false"; - const result1 = parseGrammarRules("test.grammar", grammar1); - const result2 = parseGrammarRules("test.grammar", grammar2); + const result1 = testParamGrammarRules("test.grammar", grammar1); + const result2 = testParamGrammarRules("test.grammar", grammar2); expect(result1[0].rules[0].value).toEqual({ type: "literal", @@ -220,7 +223,7 @@ describe("Grammar Rule Parser", () => { it("float literal values", () => { const grammar = "@ = test -> 42.5"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -230,7 +233,7 @@ describe("Grammar Rule Parser", () => { it("integer literal values", () => { const grammar = "@ = test -> 12"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -240,7 +243,7 @@ describe("Grammar Rule Parser", () => { it("integer hex literal values", () => { const grammar = "@ = test -> 0xC"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -250,7 +253,7 @@ describe("Grammar Rule Parser", () => { it("integer oct literal values", () => { const grammar = "@ = test -> 0o14"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -260,7 +263,7 @@ describe("Grammar Rule Parser", () => { it("integer binary literal values", () => { const grammar = "@ = test -> 0b1100"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -272,8 +275,8 @@ describe("Grammar Rule Parser", () => { const grammar1 = '@ = test -> "hello world"'; const grammar2 = "@ = test -> 'hello world'"; - const result1 = parseGrammarRules("test.grammar", grammar1); - const result2 = parseGrammarRules("test.grammar", grammar2); + const result1 = testParamGrammarRules("test.grammar", grammar1); + const result2 = testParamGrammarRules("test.grammar", grammar2); expect(result1[0].rules[0].value).toEqual({ type: "literal", @@ -287,7 +290,7 @@ describe("Grammar Rule Parser", () => { it("string values with escape sequences", () => { const grammar = '@ = test -> "hello\\tworld\\n"'; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "literal", @@ -297,7 +300,7 @@ describe("Grammar Rule Parser", () => { it("array values", () => { const grammar = '@ = test -> [1, "hello", true]'; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "array", @@ -311,7 +314,7 @@ describe("Grammar Rule Parser", () => { it("empty array values", () => { const grammar = "@ = test -> []"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "array", @@ -321,7 +324,7 @@ describe("Grammar Rule Parser", () => { it("object values", () => { const grammar = '@ = test -> {type: "greeting", count: 1}'; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -334,7 +337,7 @@ describe("Grammar Rule Parser", () => { it("empty object values", () => { const grammar = "@ = test -> {}"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -345,7 +348,7 @@ describe("Grammar Rule Parser", () => { it("object values with single quote properties", () => { const grammar = "@ = test -> {'type': \"greeting\", 'count': 1}"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -359,7 +362,7 @@ describe("Grammar Rule Parser", () => { it("object values with double quote properties", () => { const grammar = '@ = test -> {"type": "greeting", "count": 1}'; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -372,7 +375,7 @@ describe("Grammar Rule Parser", () => { it("variable reference values", () => { const grammar = "@ = $(name) -> $(name)"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "variable", @@ -383,7 +386,7 @@ describe("Grammar Rule Parser", () => { it("nested object and array values", () => { const grammar = "@ = test -> {items: [1, 2], meta: {count: 2}}"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -420,7 +423,7 @@ describe("Grammar Rule Parser", () => { } `; - const result = parseGrammarRules("nested.grammar", grammar); + const result = testParamGrammarRules("nested.grammar", grammar); expect(result).toEqual([ { @@ -559,7 +562,7 @@ describe("Grammar Rule Parser", () => { @ = \\@ \\| \\( \\) -> "escaped" `; - const result = parseGrammarRules("unicode.grammar", grammar); + const result = testParamGrammarRules("unicode.grammar", grammar); expect(result).toHaveLength(3); expect(result[0].rules[0].expressions[0]).toEqual({ @@ -582,7 +585,7 @@ describe("Grammar Rule Parser", () => { const spaces = " \t\v\f\u00a0\ufeff\n\r\u2028\u2029\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000"; const grammar = `${spaces}@${spaces}${spaces}=${spaces}hello${spaces}world${spaces}->${spaces}true${spaces}`; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toEqual([ { @@ -607,7 +610,7 @@ describe("Grammar Rule Parser", () => { it("should keep escaped whitespace in expression", () => { const grammar = `@=${escapedSpaces}hello${escapedSpaces}world${escapedSpaces}->true`; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toEqual([ { @@ -638,7 +641,7 @@ describe("Grammar Rule Parser", () => { @ = hello // End of line comment // Another comment `; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result).toHaveLength(1); expect(result[0].name).toBe("greeting"); @@ -652,7 +655,7 @@ describe("Grammar Rule Parser", () => { */ @ = hello /* inline comment */ world `; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "string", @@ -662,7 +665,7 @@ describe("Grammar Rule Parser", () => { it("should handle mixed whitespace types", () => { const grammar = "@\t=\r\nhello\n\t world"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "string", @@ -672,7 +675,7 @@ describe("Grammar Rule Parser", () => { it("should collapse multiple whitespace in strings to single space", () => { const grammar = "@ = hello world\t\t\ttest"; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].expressions[0]).toEqual({ type: "string", @@ -688,7 +691,7 @@ describe("Grammar Rule Parser", () => { count: 1 } `; - const result = parseGrammarRules("test.grammar", grammar); + const result = testParamGrammarRules("test.grammar", grammar); expect(result[0].rules[0].value).toEqual({ type: "object", @@ -703,114 +706,114 @@ describe("Grammar Rule Parser", () => { describe("Error Handling", () => { it("should throw error for missing @ at start of rule", () => { const grammar = " = hello"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "'@' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("'@' expected"); }); it("should throw error for malformed rule name", () => { const grammar = "@greeting = hello"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "'<' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("'<' expected"); }); it("should throw error for missing equals sign", () => { const grammar = "@ hello"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "'=' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("'=' expected"); }); it("should throw error for unterminated string literal", () => { const grammar = '@ = test -> "unterminated'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Unterminated string literal", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Unterminated string literal"); }); it("should throw error for unterminated variable", () => { const grammar = "@ = $(name"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "')' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("')' expected"); }); it("should throw error for unterminated group", () => { const grammar = "@ = (hello"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "')' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("')' expected"); }); it("should throw error for invalid escape sequence", () => { const grammar = '@ = test -> "invalid\\'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Missing escaped character.", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Missing escaped character."); }); it("should throw error for invalid hex escape", () => { const grammar = '@ = test -> "\\xZZ"'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Invalid hex escape sequence", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Invalid hex escape sequence"); }); it("should throw error for invalid unicode escape", () => { const grammar = '@ = test -> "\\uZZZZ"'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Invalid Unicode escape sequence", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Invalid Unicode escape sequence"); }); it("should throw error for unterminated array", () => { const grammar = "@ = test -> [1, 2"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Unexpected end of file in array value", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Unexpected end of file in array value"); }); it("should throw error for unterminated object", () => { const grammar = '@ = test -> {type: "test"'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Unexpected end of file in object value", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Unexpected end of file in object value"); }); it("should throw error for missing colon in object", () => { const grammar = '@ = test -> {type "test"}'; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "':' expected", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("':' expected"); }); it("should throw error for invalid number", () => { const grammar = "@ = test -> abc123"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Invalid literal", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Invalid literal"); }); it("should throw error for infinity values", () => { const grammar = "@ = test -> Infinity"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Infinity values are not allowed", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Infinity values are not allowed"); }); it("should throw error for unescaped special characters", () => { const grammar = "@ = hello-world"; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Special character needs to be escaped", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Special character needs to be escaped"); }); it("should throw error for empty expression", () => { const grammar = "@ = "; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - "Empty expression", - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow("Empty expression"); }); it("should include line and column information in errors", () => { @@ -818,9 +821,9 @@ describe("Grammar Rule Parser", () => { @ = hello @invalid = world `; - expect(() => parseGrammarRules("test.grammar", grammar)).toThrow( - /test\.grammar:\d+:\d+:/, - ); + expect(() => + testParamGrammarRules("test.grammar", grammar), + ).toThrow(/test\.grammar:\d+:\d+:/); }); }); @@ -842,7 +845,10 @@ describe("Grammar Rule Parser", () => { } `; - const result = parseGrammarRules("deeply-nested.grammar", grammar); + const result = testParamGrammarRules( + "deeply-nested.grammar", + grammar, + ); const value = result[0].rules[0].value as any; expect(value.type).toBe("object"); @@ -881,7 +887,10 @@ describe("Grammar Rule Parser", () => { } `; - const result = parseGrammarRules("conversation.grammar", grammar); + const result = testParamGrammarRules( + "conversation.grammar", + grammar, + ); expect(result).toHaveLength(3); diff --git a/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts b/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts index 1a228ccb5..a5e2b85a6 100644 --- a/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts +++ b/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts @@ -9,9 +9,9 @@ import { writeGrammarRules } from "../src/grammarRuleWriter.js"; import { escapedSpaces, spaces } from "./testUtils.js"; function validateRoundTrip(grammar: string) { - const rules = parseGrammarRules("orig", grammar); + const rules = parseGrammarRules("orig", grammar, false); const str = writeGrammarRules(rules); - const parsed = parseGrammarRules("test", str); + const parsed = parseGrammarRules("test", str, false); expect(parsed).toStrictEqual(rules); } diff --git a/ts/packages/actionGrammarCompiler/src/index.ts b/ts/packages/actionGrammarCompiler/src/index.ts index 325ae1b45..c26694851 100644 --- a/ts/packages/actionGrammarCompiler/src/index.ts +++ b/ts/packages/actionGrammarCompiler/src/index.ts @@ -28,11 +28,27 @@ export default class Compile extends Command { const name = path.basename(flags.input); + const errors: string[] = []; + const warnings: string[] = []; const grammar = loadGrammarRules( name, fs.readFileSync(flags.input, "utf-8"), + errors, + warnings, ); + if (grammar === undefined) { + console.error( + `Failed to compile action grammar due to the following errors:\n${errors.join( + "\n", + )}`, + ); + process.exit(1); + } + + if (warnings.length > 0) { + console.warn(warnings.join("\n")); + } const outputDir = path.dirname(flags.output); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true });