Skip to content
Merged
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
43 changes: 43 additions & 0 deletions src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getEffectiveBody,
getEffectivelySingleBlock,
getEffectivelySingleExpression,
isDataExpression,
isRootPathTraversal,
isRootSegments,
isThisExpression,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, JSONSchema7> = {
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,
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions tests/analyzer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading