Skip to content

Commit d88f56e

Browse files
jongwooha98claude
andauthored
Support description comments in printer and builder (#11)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a75cf1 commit d88f56e

File tree

8 files changed

+197
-46
lines changed

8 files changed

+197
-46
lines changed

packages/builder/src/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface ParameterDefinition {
5858
name: string;
5959
type: ParamType;
6060
optional?: boolean;
61+
comment?: string;
6162
}
6263

6364
export interface PageDefinition {
@@ -71,15 +72,16 @@ export interface PageDefinition {
7172
* URLSpec builder class for programmatic generation of .urlspec files
7273
*/
7374
export class URLSpec {
74-
private paramTypes: Map<string, ParamType> = new Map();
75+
private paramTypes: Map<string, { type: ParamType; comment?: string }> =
76+
new Map();
7577
private globalParams: ParameterDefinition[] = [];
7678
private pages: PageDefinition[] = [];
7779

7880
/**
7981
* Add a parameter type definition
8082
*/
81-
addParamType(name: string, type: ParamType): void {
82-
this.paramTypes.set(name, type);
83+
addParamType(name: string, type: ParamType, comment?: string): void {
84+
this.paramTypes.set(name, { type, comment });
8385
}
8486

8587
/**
@@ -102,8 +104,10 @@ export class URLSpec {
102104
toAST(): URLSpecDocument {
103105
// Build param types
104106
const paramTypes: ParamTypeDeclaration[] = [];
105-
for (const [name, type] of this.paramTypes.entries()) {
106-
paramTypes.push(createParamTypeDeclaration(name, this.buildType(type)));
107+
for (const [name, { type, comment }] of this.paramTypes.entries()) {
108+
paramTypes.push(
109+
createParamTypeDeclaration(name, this.buildType(type), comment),
110+
);
107111
}
108112

109113
// Build global block
@@ -121,6 +125,7 @@ export class URLSpec {
121125
page.name,
122126
page.path,
123127
page.parameters?.map((p) => this.buildParameter(p)),
128+
page.comment,
124129
),
125130
);
126131

@@ -220,6 +225,7 @@ export class URLSpec {
220225
param.name,
221226
this.buildType(param.type),
222227
param.optional,
228+
param.comment,
223229
);
224230
}
225231
}

packages/builder/test/builder.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,66 @@ describe("URLSpec Builder", () => {
128128
expect(result).toContain("page rejectedList = /jobs/rejected");
129129
});
130130

131+
describe("Comment/Description Support", () => {
132+
it("should output page comments", () => {
133+
const spec = new URLSpec();
134+
spec.addPage({
135+
name: "list",
136+
path: "/jobs",
137+
comment: "Job listing page",
138+
});
139+
const result = spec.toString();
140+
expect(result).toContain("// Job listing page");
141+
expect(result).toContain("page list = /jobs {");
142+
});
143+
144+
it("should output parameter comments", () => {
145+
const spec = new URLSpec();
146+
spec.addPage({
147+
name: "list",
148+
path: "/jobs",
149+
parameters: [
150+
{ name: "sort", type: "string", comment: "Sort order" },
151+
],
152+
});
153+
const result = spec.toString();
154+
expect(result).toContain(" // Sort order");
155+
expect(result).toContain(" sort: string;");
156+
});
157+
158+
it("should output param type comments", () => {
159+
const spec = new URLSpec();
160+
spec.addParamType("sortOrder", ["recent", "popular"], "Available sort orders");
161+
spec.addPage({ name: "list", path: "/jobs" });
162+
const result = spec.toString();
163+
expect(result).toContain("// Available sort orders");
164+
});
165+
166+
it("should output global parameter comments", () => {
167+
const spec = new URLSpec();
168+
spec.addGlobalParam({
169+
name: "utm_source",
170+
type: "string",
171+
optional: true,
172+
comment: "UTM source tracking",
173+
});
174+
spec.addPage({ name: "list", path: "/jobs" });
175+
const result = spec.toString();
176+
expect(result).toContain(" // UTM source tracking");
177+
});
178+
179+
it("should output multi-line comments", () => {
180+
const spec = new URLSpec();
181+
spec.addPage({
182+
name: "list",
183+
path: "/jobs",
184+
comment: "Job listing page\nDisplays all available jobs",
185+
});
186+
const result = spec.toString();
187+
expect(result).toContain("// Job listing page\n// Displays all available jobs");
188+
});
189+
});
190+
131191
describe("Security - Path Traversal Prevention", () => {
132192
it("should reject paths with .. traversal sequences", async () => {
133193
const spec = new URLSpec();

packages/language/src/ast-builder.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ export function createTypeReference(refName: string): TypeReference {
6565
export function createParamTypeDeclaration(
6666
name: string,
6767
type: Type,
68+
description?: string,
6869
): ParamTypeDeclaration {
69-
return {
70+
const node = {
7071
$type: "ParamTypeDeclaration",
7172
name,
7273
type,
7374
} as ParamTypeDeclaration;
75+
if (description) {
76+
(node as any).$description = description;
77+
}
78+
return node;
7479
}
7580

7681
/**
@@ -80,13 +85,18 @@ export function createParameterDeclaration(
8085
name: string,
8186
type: Type,
8287
optional?: boolean,
88+
description?: string,
8389
): ParameterDeclaration {
84-
return {
90+
const node = {
8591
$type: "ParameterDeclaration",
8692
name,
8793
type,
8894
optional: optional ? "?" : undefined,
8995
} as ParameterDeclaration;
96+
if (description) {
97+
(node as any).$description = description;
98+
}
99+
return node;
90100
}
91101

92102
/**
@@ -152,13 +162,18 @@ export function createPageDeclaration(
152162
name: string,
153163
pathStr: string,
154164
parameters?: ParameterDeclaration[],
165+
description?: string,
155166
): PageDeclaration {
156-
return {
167+
const node = {
157168
$type: "PageDeclaration",
158169
name,
159170
path: parsePath(pathStr),
160171
parameters: parameters || [],
161172
} as PageDeclaration;
173+
if (description) {
174+
(node as any).$description = description;
175+
}
176+
return node;
162177
}
163178

164179
/**

packages/language/src/cst-utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* CST utilities for extracting metadata from Concrete Syntax Tree nodes
3+
*/
4+
5+
import type { AstNode } from "langium";
6+
7+
/**
8+
* Extract description from comments preceding an AST node
9+
*/
10+
export function extractDescription(node: AstNode): string | undefined {
11+
const cstNode = node.$cstNode;
12+
if (!cstNode?.container) return undefined;
13+
14+
const container = cstNode.container;
15+
const children = container.content;
16+
const currentIndex = children.indexOf(cstNode);
17+
const comments: string[] = [];
18+
19+
// Look at previous siblings only at the immediate level
20+
let foundNonWhitespace = false;
21+
for (let i = currentIndex - 1; i >= 0; i--) {
22+
const sibling = children[i];
23+
if (sibling.tokenType?.name === "SL_COMMENT") {
24+
const commentText = sibling.text.replace(/^\/\/\s*/, "").trim();
25+
if (commentText) {
26+
comments.unshift(commentText);
27+
}
28+
foundNonWhitespace = true;
29+
} else if (sibling.tokenType?.name === "WS") {
30+
const newlineCount = (sibling.text.match(/\n/g) || []).length;
31+
if (newlineCount > 1 && foundNonWhitespace) {
32+
break;
33+
}
34+
} else {
35+
break;
36+
}
37+
}
38+
39+
return comments.length > 0 ? comments.join("\n") : undefined;
40+
}

packages/language/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export type {
2424
ResolvedURLSpec,
2525
} from "./resolved-types";
2626

27+
// Export CST utilities
28+
export { extractDescription } from "./cst-utils";
29+
2730
// Export resolver
2831
export { resolve } from "./resolver";
2932

packages/language/src/printer.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { LangiumDocument } from "langium";
1+
import type { AstNode, LangiumDocument } from "langium";
22
import type {
33
PageDeclaration,
44
ParameterDeclaration,
@@ -10,6 +10,24 @@ import type {
1010
UnionType,
1111
URLSpecDocument,
1212
} from "./__generated__/ast";
13+
import { extractDescription } from "./cst-utils";
14+
15+
/**
16+
* Get description from a node: check $description (builder) or CST (parsed)
17+
*/
18+
function getDescription(node: AstNode): string | undefined {
19+
const builderDesc = (node as any).$description;
20+
if (builderDesc) return builderDesc;
21+
return extractDescription(node);
22+
}
23+
24+
/**
25+
* Convert a description string into formatted comment lines
26+
*/
27+
function descriptionLines(desc: string | undefined, indent: string): string[] {
28+
if (!desc) return [];
29+
return desc.split("\n").map((line) => `${indent}// ${line}`);
30+
}
1331

1432
/**
1533
* Print Langium AST back to .urlspec format
@@ -21,6 +39,7 @@ export function print(doc: LangiumDocument<URLSpecDocument>): string {
2139
// Param types
2240
if (model.paramTypes.length > 0) {
2341
for (const paramType of model.paramTypes) {
42+
lines.push(...descriptionLines(getDescription(paramType), ""));
2443
lines.push(`param ${paramType.name} = ${printType(paramType.type)};`);
2544
}
2645
lines.push("");
@@ -30,6 +49,7 @@ export function print(doc: LangiumDocument<URLSpecDocument>): string {
3049
if (model.global) {
3150
lines.push("global {");
3251
for (const param of model.global.parameters) {
52+
lines.push(...descriptionLines(getDescription(param), " "));
3353
lines.push(` ${printParameter(param)}`);
3454
}
3555
lines.push("}");
@@ -38,6 +58,7 @@ export function print(doc: LangiumDocument<URLSpecDocument>): string {
3858

3959
// Pages
4060
for (const page of model.pages) {
61+
lines.push(...descriptionLines(getDescription(page), ""));
4162
lines.push(printPage(page));
4263
lines.push("");
4364
}
@@ -54,6 +75,7 @@ function printPage(page: PageDeclaration): string {
5475
lines.push(`page ${page.name} = ${path} {`);
5576

5677
for (const param of page.parameters) {
78+
lines.push(...descriptionLines(getDescription(param), " "));
5779
lines.push(` ${printParameter(param)}`);
5880
}
5981

packages/language/src/resolver.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AstNode, LangiumDocument } from "langium";
1+
import type { LangiumDocument } from "langium";
22
import type {
33
PageDeclaration,
44
ParameterDeclaration,
@@ -9,6 +9,7 @@ import type {
99
UnionType,
1010
URLSpecDocument,
1111
} from "./__generated__/ast";
12+
import { extractDescription } from "./cst-utils";
1213
import type {
1314
ResolvedPage,
1415
ResolvedParameter,
@@ -18,41 +19,6 @@ import type {
1819
ResolvedURLSpec,
1920
} from "./resolved-types";
2021

21-
/**
22-
* Extract description from comments preceding an AST node
23-
*/
24-
function extractDescription(node: AstNode): string | undefined {
25-
const cstNode = node.$cstNode;
26-
if (!cstNode?.container) return undefined;
27-
28-
const container = cstNode.container;
29-
const children = container.content;
30-
const currentIndex = children.indexOf(cstNode);
31-
const comments: string[] = [];
32-
33-
// Look at previous siblings only at the immediate level
34-
let foundNonWhitespace = false;
35-
for (let i = currentIndex - 1; i >= 0; i--) {
36-
const sibling = children[i];
37-
if (sibling.tokenType?.name === "SL_COMMENT") {
38-
const commentText = sibling.text.replace(/^\/\/\s*/, "").trim();
39-
if (commentText) {
40-
comments.unshift(commentText);
41-
}
42-
foundNonWhitespace = true;
43-
} else if (sibling.tokenType?.name === "WS") {
44-
const newlineCount = (sibling.text.match(/\n/g) || []).length;
45-
if (newlineCount > 1 && foundNonWhitespace) {
46-
break;
47-
}
48-
} else {
49-
break;
50-
}
51-
}
52-
53-
return comments.length > 0 ? comments.join("\n") : undefined;
54-
}
55-
5622
/**
5723
* Resolve Langium AST to user-friendly ResolvedURLSpec
5824
* - Resolves type references to actual types

0 commit comments

Comments
 (0)