Skip to content

Commit 81280fb

Browse files
committed
Support for comments
1 parent f061f63 commit 81280fb

6 files changed

Lines changed: 203 additions & 15 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ Controls semicolons on generated TypeScript statements. Options: `true`, `false`
8585
`--normalize-acronyms`, `--no-normalize-acronyms` optional
8686
Normalizes leading acronyms in generated property names, such as `ULS` to `uls`. Default: `false`.
8787

88+
`--preserve-comments`, `--no-preserve-comments` optional
89+
Emits parsed C# comments in generated TypeScript files. Default: `false`.
90+
91+
`--convert-documentation-comments`, `--no-convert-documentation-comments` optional
92+
Converts C# XML documentation comments (`///`) into TypeScript JSDoc comments. Default: `false`.
93+
8894
## Configuration File
8995

9096
Create `typesharp.json`:
@@ -102,7 +108,9 @@ Create `typesharp.json`:
102108
"readonlyProperties": false,
103109
"quoteStyle": "double",
104110
"semicolons": true,
105-
"normalizeAcronyms": false
111+
"normalizeAcronyms": false,
112+
"preserveComments": false,
113+
"convertDocumentationComments": false
106114
}
107115
```
108116

src/typesharp-ts/dist/main.mjs

Lines changed: 18 additions & 6 deletions
Large diffs are not rendered by default.

src/typesharp-ts/spec/node_test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,68 @@ public class Report {
149149
assert.match(normalizedReport, /urlValue: string;/);
150150
});
151151

152+
test("preserves comments only when enabled", () => {
153+
const files = [
154+
parseSourceFile(
155+
"commented.cs",
156+
`
157+
/// <summary>Report docs</summary>
158+
public class Report {
159+
// Property docs
160+
public string Name { get; set; } // trailing docs
161+
/// <summary>Count docs</summary>
162+
/* block docs */
163+
public int Count { get; set; }
164+
}
165+
166+
/// <summary>Status docs</summary>
167+
public enum Status {
168+
// Invalid docs
169+
Invalid = -1,
170+
/// <summary>Valid docs</summary>
171+
Valid = 1,
172+
}
173+
`,
174+
),
175+
];
176+
177+
const defaultGenerated = new Map(generate(files).map((file) => [file.name, file.text]));
178+
assert.doesNotMatch(defaultGenerated.get("Report.ts")!, /Report docs|Property docs|trailing docs|block docs|Count docs/);
179+
assert.doesNotMatch(defaultGenerated.get("Status.ts")!, /Status docs|Invalid docs|Valid docs/);
180+
181+
const preserved = new Map(generate(files, { preserveComments: true }).map((file) => [file.name, file.text]));
182+
assert.match(preserved.get("Report.ts")!, /\/\/\/ <summary>Report docs<\/summary>\nexport interface Report/);
183+
assert.match(preserved.get("Report.ts")!, / \/\/ Property docs\n name: string; \/\/ trailing docs/);
184+
assert.match(preserved.get("Report.ts")!, / \/\/\/ <summary>Count docs<\/summary>\n \/\* block docs \*\/\n count: number;/);
185+
assert.match(preserved.get("Status.ts")!, /\/\/\/ <summary>Status docs<\/summary>\nexport enum Status/);
186+
assert.match(preserved.get("Status.ts")!, / \/\/ Invalid docs\n Invalid = -1,/);
187+
assert.match(preserved.get("Status.ts")!, / \/\/\/ <summary>Valid docs<\/summary>\n Valid = 1,/);
188+
});
189+
190+
test("converts documentation comments without preserving ordinary comments", () => {
191+
const files = [
192+
parseSourceFile(
193+
"commented.cs",
194+
`
195+
/// <summary>Report docs</summary>
196+
/// <remarks>More details</remarks>
197+
public class Report {
198+
// Ordinary comment
199+
/// <summary>Name docs</summary>
200+
public string Name { get; set; } // trailing docs
201+
}
202+
`,
203+
),
204+
];
205+
206+
const generated = new Map(
207+
generate(files, { convertDocumentationComments: true }).map((file) => [file.name, file.text]),
208+
).get("Report.ts")!;
209+
assert.match(generated, /\/\*\*\n \* Report docs\n \*\n \* More details\n \*\/\nexport interface Report/);
210+
assert.match(generated, / \/\*\*\n \* Name docs\n \*\/\n name: string;/);
211+
assert.doesNotMatch(generated, /Ordinary comment|trailing docs|\/\/\//);
212+
});
213+
152214
test("supports property readonly attributes without global readonly", () => {
153215
const person = generate([
154216
parseSourceFile(
@@ -253,6 +315,8 @@ test("supports current CLI arguments", () => {
253315
"--quote-style=single",
254316
"--no-semicolons",
255317
"--normalize-acronyms",
318+
"--preserve-comments",
319+
"--convert-documentation-comments",
256320
]),
257321
{
258322
configPath: "./config/tssharp.json",
@@ -268,6 +332,8 @@ test("supports current CLI arguments", () => {
268332
quoteStyle: "single",
269333
semicolons: false,
270334
normalizeAcronyms: true,
335+
preserveComments: true,
336+
convertDocumentationComments: true,
271337
},
272338
);
273339
});
@@ -286,6 +352,8 @@ test("loads typesharp.json and lets CLI override file values", async () => {
286352
dictionaryStyle: "index-signature",
287353
readonlyProperties: true,
288354
normalizeAcronyms: true,
355+
preserveComments: true,
356+
convertDocumentationComments: true,
289357
}),
290358
);
291359

@@ -303,6 +371,8 @@ test("loads typesharp.json and lets CLI override file values", async () => {
303371
quoteStyle: undefined,
304372
semicolons: undefined,
305373
normalizeAcronyms: true,
374+
preserveComments: true,
375+
convertDocumentationComments: true,
306376
});
307377
} finally {
308378
await rm(temp, { recursive: true, force: true });
@@ -334,6 +404,8 @@ test("defaults fileFilter to C# source files", async () => {
334404
quoteStyle: undefined,
335405
semicolons: undefined,
336406
normalizeAcronyms: undefined,
407+
preserveComments: undefined,
408+
convertDocumentationComments: undefined,
337409
});
338410
} finally {
339411
await rm(temp, { recursive: true, force: true });

src/typesharp-ts/src/generator.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AttributeNode, ClassNode, SourceFileNode, TypeDeclarationNode, TypeReferenceNode } from "./ast";
1+
import { AttributeNode, ClassNode, CommentTrivia, SourceFileNode, TypeDeclarationNode, TypeReferenceNode } from "./ast";
22

33
export interface GenerateOptions {
44
exportModule?: boolean;
@@ -8,6 +8,8 @@ export interface GenerateOptions {
88
quoteStyle?: "double" | "single";
99
semicolons?: boolean;
1010
normalizeAcronyms?: boolean;
11+
preserveComments?: boolean;
12+
convertDocumentationComments?: boolean;
1113
}
1214

1315
export interface GeneratedFile {
@@ -41,7 +43,7 @@ export function generate(files: SourceFileNode[], options: GenerateOptions = {})
4143
const typeMap: TypeMap = { declarations: new Map(declarations.map((d) => [d.name, d])) };
4244
const output = declarations.map((declaration) => ({
4345
name: `${declaration.name}.ts`,
44-
text: declaration.kind === "enum" ? generateEnum(declaration) : generateClass(declaration, typeMap, settings),
46+
text: declaration.kind === "enum" ? generateEnum(declaration, settings) : generateClass(declaration, typeMap, settings),
4547
}));
4648

4749
if (settings.exportModule) {
@@ -62,10 +64,13 @@ function shouldEmit(declaration: TypeDeclarationNode): boolean {
6264
return !declaration.name.endsWith("Attribute") && declaration.properties.length > 0;
6365
}
6466

65-
function generateEnum(node: Extract<TypeDeclarationNode, { kind: "enum" }>): string {
66-
const lines = ["", `export enum ${node.name} {`];
67+
function generateEnum(node: Extract<TypeDeclarationNode, { kind: "enum" }>, options: Required<GenerateOptions>): string {
68+
const lines = [""];
69+
pushComments(lines, node.leadingComments, "", options);
70+
lines.push(`export enum ${node.name} {`);
6771
for (const member of node.members) {
68-
lines.push(` ${member.name} = ${member.value ?? 0},`);
72+
pushComments(lines, member.leadingComments, " ", options);
73+
lines.push(` ${member.name} = ${member.value ?? 0},${trailingComment(member.trailingComments, options)}`);
6974
}
7075
lines.push("}", "");
7176
return lines.join("\n");
@@ -85,14 +90,20 @@ function generateClass(node: ClassNode, typeMap: TypeMap, options: Required<Gene
8590
: "";
8691
const readonlyClass = options.readonlyProperties || hasAttribute(node.attributes, "Readonly") ||
8792
hasAttribute(node.attributes, "ReadOnly") || hasAttribute(node.attributes, "ReadonlyProperties");
93+
pushComments(lines, node.leadingComments, "", options);
8894
lines.push(`export interface ${node.name}${typeParams}${base} {`);
8995
for (const prop of node.properties) {
96+
pushComments(lines, prop.leadingComments, " ", options);
9097
const union = typeUnion(prop.attributes, options);
9198
const readonly = readonlyClass || hasAttribute(prop.attributes, "Readonly") ||
9299
hasAttribute(prop.attributes, "ReadOnly");
93100
const prefix = readonly ? "readonly " : "";
94101
if (union) {
95-
lines.push(` ${prefix}${propertyName(prop.name, options)}: ${union}${statementEnd(options)}`);
102+
lines.push(
103+
` ${prefix}${propertyName(prop.name, options)}: ${union}${statementEnd(options)}${
104+
trailingComment(prop.trailingComments, options)
105+
}`,
106+
);
96107
continue;
97108
}
98109

@@ -105,13 +116,86 @@ function generateClass(node: ClassNode, typeMap: TypeMap, options: Required<Gene
105116
lines.push(
106117
` ${prefix}${propertyName(prop.name, options)}${optional ? "?" : ""}: ${typeName}${nullable}${
107118
statementEnd(options)
108-
}`,
119+
}${trailingComment(prop.trailingComments, options)}`,
109120
);
110121
}
111122
lines.push("}", "");
112123
return lines.join("\n");
113124
}
114125

126+
function pushComments(
127+
lines: string[],
128+
comments: CommentTrivia[],
129+
indent: string,
130+
options: Required<GenerateOptions>,
131+
): void {
132+
let docComments: CommentTrivia[] = [];
133+
const flushDocComments = () => {
134+
if (docComments.length === 0) return;
135+
if (options.convertDocumentationComments) {
136+
pushDocumentationComment(lines, docComments, indent);
137+
} else if (options.preserveComments) {
138+
for (const comment of docComments) lines.push(`${indent}${comment.text.trim()}`);
139+
}
140+
docComments = [];
141+
};
142+
143+
for (const comment of comments) {
144+
if (comment.kind === "doc") {
145+
docComments.push(comment);
146+
continue;
147+
}
148+
149+
flushDocComments();
150+
if (comment.kind !== "doc" && !options.preserveComments) continue;
151+
const text = comment.text.trim();
152+
if (comment.kind === "block") {
153+
for (const line of text.split(/\r?\n/)) lines.push(`${indent}${line}`);
154+
} else {
155+
lines.push(`${indent}${text}`);
156+
}
157+
}
158+
flushDocComments();
159+
}
160+
161+
function trailingComment(comments: CommentTrivia[], options: Required<GenerateOptions>): string {
162+
if (!options.preserveComments || comments.length === 0) return "";
163+
return ` ${comments.map((comment) => comment.text.trim()).join(" ")}`;
164+
}
165+
166+
function pushDocumentationComment(lines: string[], comments: CommentTrivia[], indent: string): void {
167+
const text = comments
168+
.map((comment) => documentationText(comment.text))
169+
.join("\n\n")
170+
.trim();
171+
if (!text) return;
172+
173+
lines.push(`${indent}/**`);
174+
for (const line of text.split(/\r?\n/)) {
175+
lines.push(line ? `${indent} * ${line}` : `${indent} *`);
176+
}
177+
lines.push(`${indent} */`);
178+
}
179+
180+
function documentationText(text: string): string {
181+
const xml = text
182+
.split(/\r?\n/)
183+
.map((line) => line.trim().replace(/^\/\/\/\s?/, ""))
184+
.join("\n")
185+
.trim();
186+
return xml
187+
.replace(/<summary>\s*([\s\S]*?)\s*<\/summary>/gi, "$1")
188+
.replace(/<remarks>\s*([\s\S]*?)\s*<\/remarks>/gi, "\n\n$1")
189+
.replace(/<returns>\s*([\s\S]*?)\s*<\/returns>/gi, "\n\n@returns $1")
190+
.replace(/<param\s+name="([^"]+)">\s*([\s\S]*?)\s*<\/param>/gi, "\n\n@param $1 $2")
191+
.replace(/<[^>]+>/g, "")
192+
.split(/\r?\n/)
193+
.map((line) => line.trim())
194+
.join("\n")
195+
.replace(/\n{3,}/g, "\n\n")
196+
.trim();
197+
}
198+
115199
function collectImports(node: ClassNode, typeMap: TypeMap): string[] {
116200
const imports = new Set<string>();
117201
const visit = (type: TypeReferenceNode) => {
@@ -220,6 +304,8 @@ function normalizeOptions(options: GenerateOptions): Required<GenerateOptions> {
220304
quoteStyle: options.quoteStyle ?? "double",
221305
semicolons: options.semicolons ?? true,
222306
normalizeAcronyms: options.normalizeAcronyms ?? false,
307+
preserveComments: options.preserveComments ?? false,
308+
convertDocumentationComments: options.convertDocumentationComments ?? false,
223309
};
224310
}
225311

src/typesharp-ts/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export async function convert(options: CliOptions): Promise<void> {
5151
quoteStyle: options.quoteStyle,
5252
semicolons: options.semicolons,
5353
normalizeAcronyms: options.normalizeAcronyms,
54+
preserveComments: options.preserveComments,
55+
convertDocumentationComments: options.convertDocumentationComments,
5456
});
5557
await mkdir(options.destination, { recursive: true });
5658
for (const file of files) {
@@ -89,6 +91,8 @@ export function parseArgs(args: string[]): ParsedCliOptions {
8991
"readonly-properties",
9092
"semicolons",
9193
"normalize-acronyms",
94+
"preserve-comments",
95+
"convert-documentation-comments",
9296
]);
9397
const arrayFlags = new Set(["exclude-pattern", "exclude-patterns"]);
9498

@@ -165,6 +169,8 @@ function finalizeOptions(options: ConfigFileOptions): CliOptions {
165169
quoteStyle: options.quoteStyle,
166170
semicolons: options.semicolons,
167171
normalizeAcronyms: options.normalizeAcronyms,
172+
preserveComments: options.preserveComments,
173+
convertDocumentationComments: options.convertDocumentationComments,
168174
};
169175
}
170176

@@ -183,6 +189,8 @@ function mapRawOptions(values: Map<string, string | boolean>): ParsedCliOptions
183189
setString(options, "quoteStyle", values.get("quote-style"));
184190
setBoolean(options, "semicolons", values.get("semicolons"));
185191
setBoolean(options, "normalizeAcronyms", values.get("normalize-acronyms"));
192+
setBoolean(options, "preserveComments", values.get("preserve-comments"));
193+
setBoolean(options, "convertDocumentationComments", values.get("convert-documentation-comments"));
186194
return options;
187195
}
188196

src/typesharp-ts/typesharp.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@
1010
"readonlyProperties": false,
1111
"quoteStyle": "double",
1212
"semicolons": true,
13-
"normalizeAcronyms": false
13+
"normalizeAcronyms": false,
14+
"preserveComments": false,
15+
"convertDocumentationComments": false
1416
}

0 commit comments

Comments
 (0)