diff --git a/src/analyzer.ts b/src/analyzer.ts index bf9df2d..d624ad9 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -17,6 +17,7 @@ import { getEffectiveBody, getEffectivelySingleBlock, getEffectivelySingleExpression, + isDataExpression, isRootPathTraversal, isRootSegments, isThisExpression, @@ -468,6 +469,12 @@ function processMustache( // ── Simple expression ──────────────────────────────────────────────────── const resolved = resolveExpressionWithDiagnostics(stmt.path, ctx, stmt) ?? {}; + // @data variables (@index, @first, @last, @key) are runtime-provided — + // optionality checks against the input schema do not apply. + if (isDataExpression(stmt.path)) { + return resolved; + } + // When the expression points to an optional property (not in `required`), // the output can be null at runtime. Wrap the resolved schema with null // so the output schema accurately reflects this. @@ -1215,6 +1222,34 @@ function inferBlockType( * * @returns The resolved sub-schema, or `undefined` if the path is invalid. */ + +// ─── @data variable types ──────────────────────────────────────────────────── +// Handlebars injects these variables inside block helpers at runtime. +// We map each known variable to its static type so the analyzer can +// validate templates that use them without false UNKNOWN_PROPERTY errors. + +const DATA_VARIABLE_SCHEMAS: Record = { + index: { type: "number" }, + first: { type: "boolean" }, + last: { type: "boolean" }, + key: { type: "string" }, +}; + +/** + * Returns the inferred schema for a Handlebars `@data` variable. + * Known variables (`@index`, `@first`, `@last`, `@key`) return their + * concrete type; unknown `@data` variables return the open schema `{}` + * (any type) to avoid blocking valid templates. + */ +function resolveDataExpression(expr: hbs.AST.PathExpression): JSONSchema7 { + const name = expr.parts[0]; + if (name && name in DATA_VARIABLE_SCHEMAS) { + return DATA_VARIABLE_SCHEMAS[name] as JSONSchema7; + } + // Unknown @data variable — return open schema (best-effort) + return {}; +} + function resolveExpressionWithDiagnostics( expr: hbs.AST.Expression, ctx: AnalysisContext, @@ -1226,6 +1261,14 @@ function resolveExpressionWithDiagnostics( return ctx.current; } + // ── Handlebars @data variables (@index, @first, @last, @key) ──────────── + // These are runtime-provided by Handlebars inside block helpers (e.g. + // `#each`). They are NOT part of the user's input schema, so we must + // short-circuit here to avoid false UNKNOWN_PROPERTY diagnostics. + if (isDataExpression(expr)) { + return resolveDataExpression(expr as hbs.AST.PathExpression); + } + // ── SubExpression (nested helper call, e.g. `(lt account.balance 500)`) ── if (expr.type === "SubExpression") { return resolveSubExpression(expr as hbs.AST.SubExpression, ctx, parentNode); diff --git a/src/parser.ts b/src/parser.ts index 416ecaa..80b60af 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -105,6 +105,18 @@ export function isThisExpression(expr: hbs.AST.Expression): boolean { return path.original === "this" || path.original === "."; } +/** + * Checks whether an AST expression is a Handlebars `@data` variable + * (e.g. `@index`, `@first`, `@last`, `@key`). + * + * These variables are injected by Handlebars at runtime inside block helpers + * and are not part of the user-provided input schema. + */ +export function isDataExpression(expr: hbs.AST.Expression): boolean { + if (expr.type !== "PathExpression") return false; + return (expr as hbs.AST.PathExpression).data === true; +} + /** * Checks whether an AST expression is a `PathExpression` whose first * segment is the `$root` token. diff --git a/tests/analyzer.spec.ts b/tests/analyzer.spec.ts index 6847478..577bca3 100644 --- a/tests/analyzer.spec.ts +++ b/tests/analyzer.spec.ts @@ -364,6 +364,64 @@ describe("analyzer", () => { }); }); + describe("validation — @data variables in #each", () => { + test("@index does not produce validation errors", () => { + const result = analyze( + "{{#each tags}}{{@index}}: {{this}} {{/each}}", + userSchema, + ); + expect(result.valid).toBe(true); + expect(result.diagnostics).toHaveLength(0); + }); + + test("@first does not produce validation errors", () => { + const result = analyze( + "{{#each tags}}{{#if @first}}[FIRST] {{/if}}{{this}}{{/each}}", + userSchema, + ); + expect(result.valid).toBe(true); + expect(result.diagnostics).toHaveLength(0); + }); + + test("@last does not produce validation errors", () => { + const result = analyze( + "{{#each tags}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}", + userSchema, + ); + expect(result.valid).toBe(true); + expect(result.diagnostics).toHaveLength(0); + }); + + test("@key does not produce validation errors", () => { + const result = analyze( + "{{#each tags}}{{@key}}={{this}} {{/each}}", + userSchema, + ); + expect(result.valid).toBe(true); + expect(result.diagnostics).toHaveLength(0); + }); + + test("@index infers number type in output schema", () => { + const result = analyze("{{#each tags}}{{@index}}{{/each}}", userSchema); + expect(result.valid).toBe(true); + expect(result.outputSchema).toEqual({ type: "string" }); + }); + + test("@data variables work alongside regular properties", () => { + const result = analyze( + "{{#each orders}}{{@index}}: {{product}} (x{{quantity}}){{/each}}", + userSchema, + ); + expect(result.valid).toBe(true); + expect(result.diagnostics).toHaveLength(0); + }); + + test("unknown @data variable does not cause errors", () => { + const result = analyze("{{#each tags}}{{@level}}{{/each}}", userSchema); + expect(result.valid).toBe(true); + }); + }); + describe("validation — #with block", () => { test("#with on a valid object — correct inner access", () => { const result = analyze("{{#with address}}{{city}}{{/with}}", userSchema);