Skip to content

Commit 2e5b844

Browse files
committed
Inline properties option
1 parent 81280fb commit 2e5b844

6 files changed

Lines changed: 195 additions & 37 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ Emits parsed C# comments in generated TypeScript files. Default: `false`.
9191
`--convert-documentation-comments`, `--no-convert-documentation-comments` optional
9292
Converts C# XML documentation comments (`///`) into TypeScript JSDoc comments. Default: `false`.
9393

94+
`--inline-inherited-properties`, `--no-inline-inherited-properties` optional
95+
Adds properties from inherited classes and interfaces directly into the generated interface body. Default: `false`.
96+
9497
## Configuration File
9598

9699
Create `typesharp.json`:
@@ -110,7 +113,8 @@ Create `typesharp.json`:
110113
"semicolons": true,
111114
"normalizeAcronyms": false,
112115
"preserveComments": false,
113-
"convertDocumentationComments": false
116+
"convertDocumentationComments": false,
117+
"inlineInheritedProperties": false
114118
}
115119
```
116120

src/typesharp-ts/dist/main.mjs

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

src/typesharp-ts/spec/node_test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,59 @@ namespace Shedmate.Designer.Models.Engineering.FEM
268268
assert.match(generated.get("IOptionalWarning.ts")!, /warning: T;/);
269269
});
270270

271+
test("inlines inherited properties when configured", () => {
272+
const files = [
273+
parseSourceFile(
274+
"instances.cs",
275+
`
276+
namespace Demo {
277+
public class Vector3d { public double X { get; set; } }
278+
public class WasherInstance { public string Name { get; set; } }
279+
public class EntityInstance {
280+
public string Guid { get; set; }
281+
public string Name { get; set; }
282+
}
283+
public class BoltInstance : EntityInstance {
284+
public Vector3d Normal { get; set; }
285+
public double Length { get; set; }
286+
public List<WasherInstance> Washers { get; set; }
287+
}
288+
public class AnchorBoltInstance : BoltInstance {
289+
public string JustificationX { get; set; }
290+
}
291+
public interface IOptionalError<T> {
292+
public T Error { get; set; }
293+
}
294+
public class EngineeringResult : IOptionalError<List<string>> {
295+
public bool Done { get; set; }
296+
}
297+
}`,
298+
),
299+
];
300+
301+
const defaultAnchor = new Map(generate(files).map((file) => [file.name, file.text])).get("AnchorBoltInstance.ts")!;
302+
assert.match(defaultAnchor, /justificationX: string;/);
303+
assert.doesNotMatch(defaultAnchor, /normal: Vector3d;|guid: string;/);
304+
305+
const generated = new Map(
306+
generate(files, { inlineInheritedProperties: true }).map((file) => [file.name, file.text]),
307+
);
308+
const anchor = generated.get("AnchorBoltInstance.ts")!;
309+
assert.match(anchor, /import \{ BoltInstance \} from "\.\/BoltInstance";/);
310+
assert.match(anchor, /import \{ Vector3d \} from "\.\/Vector3d";/);
311+
assert.match(anchor, /import \{ WasherInstance \} from "\.\/WasherInstance";/);
312+
assert.match(anchor, /justificationX: string;/);
313+
assert.match(anchor, /normal: Vector3d;/);
314+
assert.match(anchor, /length: number;/);
315+
assert.match(anchor, /washers: WasherInstance\[\];/);
316+
assert.match(anchor, /guid: string;/);
317+
assert.match(anchor, /name: string;/);
318+
319+
const result = generated.get("EngineeringResult.ts")!;
320+
assert.match(result, /done: boolean;/);
321+
assert.match(result, /error: string\[\];/);
322+
});
323+
271324
test("generates sample outputs for current golden DTOs", async () => {
272325
const temp = await mkdtemp(join(tmpdir(), "typesharp-"));
273326
try {
@@ -317,6 +370,7 @@ test("supports current CLI arguments", () => {
317370
"--normalize-acronyms",
318371
"--preserve-comments",
319372
"--convert-documentation-comments",
373+
"--inline-inherited-properties",
320374
]),
321375
{
322376
configPath: "./config/tssharp.json",
@@ -334,6 +388,7 @@ test("supports current CLI arguments", () => {
334388
normalizeAcronyms: true,
335389
preserveComments: true,
336390
convertDocumentationComments: true,
391+
inlineInheritedProperties: true,
337392
},
338393
);
339394
});
@@ -354,6 +409,7 @@ test("loads typesharp.json and lets CLI override file values", async () => {
354409
normalizeAcronyms: true,
355410
preserveComments: true,
356411
convertDocumentationComments: true,
412+
inlineInheritedProperties: true,
357413
}),
358414
);
359415

@@ -373,6 +429,7 @@ test("loads typesharp.json and lets CLI override file values", async () => {
373429
normalizeAcronyms: true,
374430
preserveComments: true,
375431
convertDocumentationComments: true,
432+
inlineInheritedProperties: true,
376433
});
377434
} finally {
378435
await rm(temp, { recursive: true, force: true });
@@ -406,6 +463,7 @@ test("defaults fileFilter to C# source files", async () => {
406463
normalizeAcronyms: undefined,
407464
preserveComments: undefined,
408465
convertDocumentationComments: undefined,
466+
inlineInheritedProperties: undefined,
409467
});
410468
} finally {
411469
await rm(temp, { recursive: true, force: true });

src/typesharp-ts/src/generator.ts

Lines changed: 117 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { AttributeNode, ClassNode, CommentTrivia, SourceFileNode, TypeDeclarationNode, TypeReferenceNode } from "./ast";
1+
import {
2+
AttributeNode,
3+
ClassNode,
4+
CommentTrivia,
5+
PropertyNode,
6+
SourceFileNode,
7+
TypeDeclarationNode,
8+
TypeReferenceNode,
9+
} from "./ast";
210

311
export interface GenerateOptions {
412
exportModule?: boolean;
@@ -10,6 +18,7 @@ export interface GenerateOptions {
1018
normalizeAcronyms?: boolean;
1119
preserveComments?: boolean;
1220
convertDocumentationComments?: boolean;
21+
inlineInheritedProperties?: boolean;
1322
}
1423

1524
export interface GeneratedFile {
@@ -21,6 +30,11 @@ interface TypeMap {
2130
declarations: Map<string, TypeDeclarationNode>;
2231
}
2332

33+
interface EmittableProperty {
34+
owner: ClassNode;
35+
property: PropertyNode;
36+
}
37+
2438
const numberTypes = new Set([
2539
"byte",
2640
"sbyte",
@@ -77,7 +91,7 @@ function generateEnum(node: Extract<TypeDeclarationNode, { kind: "enum" }>, opti
7791
}
7892

7993
function generateClass(node: ClassNode, typeMap: TypeMap, options: Required<GenerateOptions>): string {
80-
const imports = collectImports(node, typeMap);
94+
const imports = collectImports(node, typeMap, options);
8195
const lines: string[] = [];
8296
for (const importName of imports) {
8397
lines.push(`import { ${importName} } from ${quote(`./${importName}`, options)}${statementEnd(options)}`);
@@ -92,35 +106,58 @@ function generateClass(node: ClassNode, typeMap: TypeMap, options: Required<Gene
92106
hasAttribute(node.attributes, "ReadOnly") || hasAttribute(node.attributes, "ReadonlyProperties");
93107
pushComments(lines, node.leadingComments, "", options);
94108
lines.push(`export interface ${node.name}${typeParams}${base} {`);
109+
const emittedNames = new Set<string>();
95110
for (const prop of node.properties) {
96-
pushComments(lines, prop.leadingComments, " ", options);
97-
const union = typeUnion(prop.attributes, options);
98-
const readonly = readonlyClass || hasAttribute(prop.attributes, "Readonly") ||
99-
hasAttribute(prop.attributes, "ReadOnly");
100-
const prefix = readonly ? "readonly " : "";
101-
if (union) {
102-
lines.push(
103-
` ${prefix}${propertyName(prop.name, options)}: ${union}${statementEnd(options)}${
104-
trailingComment(prop.trailingComments, options)
105-
}`,
106-
);
107-
continue;
111+
emitProperty(lines, prop, readonlyClass, node, typeMap, options);
112+
emittedNames.add(propertyName(prop.name, options));
113+
}
114+
if (options.inlineInheritedProperties) {
115+
for (const inherited of collectInheritedProperties(node, typeMap)) {
116+
const name = propertyName(inherited.property.name, options);
117+
if (emittedNames.has(name)) continue;
118+
const inheritedReadonlyClass = options.readonlyProperties || hasAttribute(inherited.owner.attributes, "Readonly") ||
119+
hasAttribute(inherited.owner.attributes, "ReadOnly") || hasAttribute(inherited.owner.attributes, "ReadonlyProperties");
120+
emitProperty(lines, inherited.property, inheritedReadonlyClass, node, typeMap, options);
121+
emittedNames.add(name);
108122
}
123+
}
124+
lines.push("}", "");
125+
return lines.join("\n");
126+
}
109127

110-
const optional = hasAttribute(prop.attributes, "Optional") || prop.type.nullable;
111-
const nullable = hasAttribute(prop.attributes, "Nullable") ? " | null" : "";
112-
let typeName = hasAttribute(prop.attributes, "UnknownObject")
113-
? "unknown"
114-
: toTypeScriptType(prop.type, node, typeMap, options);
115-
if (hasAttribute(prop.attributes, "Partial")) typeName = `Partial<${typeName}>`;
128+
function emitProperty(
129+
lines: string[],
130+
prop: PropertyNode,
131+
readonlyClass: boolean,
132+
typeOwner: ClassNode,
133+
typeMap: TypeMap,
134+
options: Required<GenerateOptions>,
135+
): void {
136+
pushComments(lines, prop.leadingComments, " ", options);
137+
const union = typeUnion(prop.attributes, options);
138+
const readonly = readonlyClass || hasAttribute(prop.attributes, "Readonly") ||
139+
hasAttribute(prop.attributes, "ReadOnly");
140+
const prefix = readonly ? "readonly " : "";
141+
if (union) {
116142
lines.push(
117-
` ${prefix}${propertyName(prop.name, options)}${optional ? "?" : ""}: ${typeName}${nullable}${
118-
statementEnd(options)
119-
}${trailingComment(prop.trailingComments, options)}`,
143+
` ${prefix}${propertyName(prop.name, options)}: ${union}${statementEnd(options)}${
144+
trailingComment(prop.trailingComments, options)
145+
}`,
120146
);
147+
return;
121148
}
122-
lines.push("}", "");
123-
return lines.join("\n");
149+
150+
const optional = hasAttribute(prop.attributes, "Optional") || prop.type.nullable;
151+
const nullable = hasAttribute(prop.attributes, "Nullable") ? " | null" : "";
152+
let typeName = hasAttribute(prop.attributes, "UnknownObject")
153+
? "unknown"
154+
: toTypeScriptType(prop.type, typeOwner, typeMap, options);
155+
if (hasAttribute(prop.attributes, "Partial")) typeName = `Partial<${typeName}>`;
156+
lines.push(
157+
` ${prefix}${propertyName(prop.name, options)}${optional ? "?" : ""}: ${typeName}${nullable}${
158+
statementEnd(options)
159+
}${trailingComment(prop.trailingComments, options)}`,
160+
);
124161
}
125162

126163
function pushComments(
@@ -196,7 +233,7 @@ function documentationText(text: string): string {
196233
.trim();
197234
}
198235

199-
function collectImports(node: ClassNode, typeMap: TypeMap): string[] {
236+
function collectImports(node: ClassNode, typeMap: TypeMap, options: Required<GenerateOptions>): string[] {
200237
const imports = new Set<string>();
201238
const visit = (type: TypeReferenceNode) => {
202239
const base = simpleName(type.name);
@@ -207,9 +244,62 @@ function collectImports(node: ClassNode, typeMap: TypeMap): string[] {
207244
};
208245
for (const baseType of node.baseTypes ?? (node.baseType ? [node.baseType] : [])) visit(baseType);
209246
for (const prop of node.properties) visit(prop.type);
247+
if (options.inlineInheritedProperties) {
248+
for (const inherited of collectInheritedProperties(node, typeMap)) visit(inherited.property.type);
249+
}
210250
return [...imports];
211251
}
212252

253+
function collectInheritedProperties(node: ClassNode, typeMap: TypeMap): EmittableProperty[] {
254+
const properties: EmittableProperty[] = [];
255+
const seenTypes = new Set<string>();
256+
const visitBase = (baseType: TypeReferenceNode) => {
257+
const baseName = simpleName(baseType.name);
258+
if (seenTypes.has(baseName)) return;
259+
seenTypes.add(baseName);
260+
261+
const declaration = typeMap.declarations.get(baseName);
262+
if (!declaration || declaration.kind !== "class") return;
263+
264+
const substitutions = new Map<string, TypeReferenceNode>();
265+
for (let i = 0; i < declaration.typeParameters.length; i++) {
266+
const replacement = baseType.args[i];
267+
if (replacement) substitutions.set(declaration.typeParameters[i], replacement);
268+
}
269+
270+
for (const property of declaration.properties) {
271+
properties.push({
272+
owner: declaration,
273+
property: {
274+
...property,
275+
type: substituteType(property.type, substitutions),
276+
},
277+
});
278+
}
279+
for (const inheritedBaseType of declaration.baseTypes ?? (declaration.baseType ? [declaration.baseType] : [])) {
280+
visitBase(substituteType(inheritedBaseType, substitutions));
281+
}
282+
};
283+
284+
for (const baseType of node.baseTypes ?? (node.baseType ? [node.baseType] : [])) visitBase(baseType);
285+
return properties;
286+
}
287+
288+
function substituteType(type: TypeReferenceNode, substitutions: Map<string, TypeReferenceNode>): TypeReferenceNode {
289+
const replacement = substitutions.get(simpleName(type.name));
290+
if (replacement && type.args.length === 0) {
291+
return {
292+
...replacement,
293+
nullable: replacement.nullable || type.nullable,
294+
arrayRank: replacement.arrayRank + type.arrayRank,
295+
};
296+
}
297+
return {
298+
...type,
299+
args: type.args.map((arg) => substituteType(arg, substitutions)),
300+
};
301+
}
302+
213303
function toTypeScriptType(
214304
type: TypeReferenceNode,
215305
owner: ClassNode,
@@ -306,6 +396,7 @@ function normalizeOptions(options: GenerateOptions): Required<GenerateOptions> {
306396
normalizeAcronyms: options.normalizeAcronyms ?? false,
307397
preserveComments: options.preserveComments ?? false,
308398
convertDocumentationComments: options.convertDocumentationComments ?? false,
399+
inlineInheritedProperties: options.inlineInheritedProperties ?? false,
309400
};
310401
}
311402

src/typesharp-ts/src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export async function convert(options: CliOptions): Promise<void> {
5353
normalizeAcronyms: options.normalizeAcronyms,
5454
preserveComments: options.preserveComments,
5555
convertDocumentationComments: options.convertDocumentationComments,
56+
inlineInheritedProperties: options.inlineInheritedProperties,
5657
});
5758
await mkdir(options.destination, { recursive: true });
5859
for (const file of files) {
@@ -93,6 +94,7 @@ export function parseArgs(args: string[]): ParsedCliOptions {
9394
"normalize-acronyms",
9495
"preserve-comments",
9596
"convert-documentation-comments",
97+
"inline-inherited-properties",
9698
]);
9799
const arrayFlags = new Set(["exclude-pattern", "exclude-patterns"]);
98100

@@ -171,6 +173,7 @@ function finalizeOptions(options: ConfigFileOptions): CliOptions {
171173
normalizeAcronyms: options.normalizeAcronyms,
172174
preserveComments: options.preserveComments,
173175
convertDocumentationComments: options.convertDocumentationComments,
176+
inlineInheritedProperties: options.inlineInheritedProperties,
174177
};
175178
}
176179

@@ -191,6 +194,7 @@ function mapRawOptions(values: Map<string, string | boolean>): ParsedCliOptions
191194
setBoolean(options, "normalizeAcronyms", values.get("normalize-acronyms"));
192195
setBoolean(options, "preserveComments", values.get("preserve-comments"));
193196
setBoolean(options, "convertDocumentationComments", values.get("convert-documentation-comments"));
197+
setBoolean(options, "inlineInheritedProperties", values.get("inline-inherited-properties"));
194198
return options;
195199
}
196200

src/typesharp-ts/typesharp.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212
"semicolons": true,
1313
"normalizeAcronyms": false,
1414
"preserveComments": false,
15-
"convertDocumentationComments": false
15+
"convertDocumentationComments": false,
16+
"inlineInheritedProperties": false
1617
}

0 commit comments

Comments
 (0)