Skip to content

Commit 29ce342

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): output-pattern expander for template codegen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
1 parent 6cb094c commit 29ce342

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Expands the tiny, fixed output-pattern grammar shared cross-port (SP-1 §3.3).
2+
// Placeholders: {name}, {Name} (PascalCase of name), {package} (:: → /).
3+
// An empty {package} collapses its trailing slash so `{package}/{name}` with no
4+
// package yields just `{name}`. Unknown placeholders are a hard error.
5+
6+
const KNOWN = new Set(["name", "Name", "package"]);
7+
8+
function pascalCase(s: string): string {
9+
return s
10+
.split(/[^A-Za-z0-9]+/)
11+
.filter(Boolean)
12+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
13+
.join("");
14+
}
15+
16+
export function expandOutputPattern(
17+
pattern: string,
18+
vars: { name?: string; package?: string },
19+
): string {
20+
let pkgWasEmpty = false;
21+
const out = pattern.replace(/\{(\w+)\}/g, (_m, token: string) => {
22+
if (!KNOWN.has(token)) {
23+
throw new Error(`unknown placeholder {${token}} in output pattern '${pattern}'`);
24+
}
25+
if (token === "package") {
26+
const p = (vars.package ?? "").replaceAll("::", "/");
27+
if (p === "") pkgWasEmpty = true;
28+
return p;
29+
}
30+
if (vars.name === undefined) {
31+
throw new Error(`output pattern '${pattern}' uses {${token}} but no entity name is in scope`);
32+
}
33+
return token === "Name" ? pascalCase(vars.name) : vars.name;
34+
});
35+
return pkgWasEmpty ? out.replace(/^\/+/, "").replace(/\/{2,}/g, "/") : out;
36+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { expandOutputPattern } from "../../src/template-codegen/output-pattern.js";
3+
4+
describe("expandOutputPattern", () => {
5+
test("{name} and {package} (:: → /)", () => {
6+
expect(expandOutputPattern("{package}/{name}Service.ts", { name: "order", package: "acme::sales" }))
7+
.toBe("acme/sales/orderService.ts");
8+
});
9+
test("{Name} is PascalCase", () => {
10+
expect(expandOutputPattern("{Name}.cs", { name: "order_line" })).toBe("OrderLine.cs");
11+
});
12+
test("literal pattern (perModel) passes through", () => {
13+
expect(expandOutputPattern("registry.ts", {})).toBe("registry.ts");
14+
});
15+
test("empty package collapses to no leading slash", () => {
16+
expect(expandOutputPattern("{package}/{name}.ts", { name: "x", package: "" })).toBe("x.ts");
17+
});
18+
test("unknown placeholder throws", () => {
19+
expect(() => expandOutputPattern("{bogus}.ts", { name: "x" })).toThrow(/unknown placeholder/i);
20+
});
21+
test("{name} with no name var throws", () => {
22+
expect(() => expandOutputPattern("{name}.ts", {})).toThrow(/name/i);
23+
});
24+
});

0 commit comments

Comments
 (0)