Skip to content

Commit c54d27c

Browse files
authored
feat(jsii-pacmak): document union types for C# (#4928)
In some places union types are rendered to overloaded setters, and in other places the type is collapsed to `Object`. Whenever the type is collapsed to `Object`, any other type information is lost for the user. In this PR, emit the union type information to XML docs so users can find it again. Also update the JavaDoc handler to handle type intersections. --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
1 parent bac5377 commit c54d27c

File tree

7 files changed

+296
-65
lines changed

7 files changed

+296
-65
lines changed

packages/@jsii/spec/src/assembly.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,15 +844,25 @@ export interface Method extends Callable {
844844
*/
845845
static?: boolean;
846846
}
847+
847848
/**
848-
* Determines whether a Callable is a Method or not.
849+
* Determines whether a Callable is a Method or not (if not, it's an initializer)
849850
*
850851
* @param callable the callable to be checked.
851852
*/
852853
export function isMethod(callable: Callable): callable is Method {
853854
return !!(callable as Method).name;
854855
}
855856

857+
/**
858+
* Returns whether an API element looks like a property
859+
*/
860+
export function isProperty(
861+
member: Type | Callable | Property,
862+
): member is Property {
863+
return !!(member as Property).name && !!(member as Property).type;
864+
}
865+
856866
/**
857867
* Represents a type definition (not a type reference).
858868
*/

packages/jsii-pacmak/lib/targets/dotnet/dotnetdocgenerator.ts

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,21 @@ import {
1010
import * as xmlbuilder from 'xmlbuilder';
1111

1212
import { renderSummary } from '../_utils';
13+
import { DotNetTypeResolver } from './dotnettyperesolver';
1314
import { DotNetNameUtils } from './nameutils';
1415
import { assertSpecIsRosettaCompatible } from '../../rosetta-assembly';
16+
import { containsUnionType } from '../../type-utils';
17+
import { visitTypeReference } from '../../type-visitor';
18+
19+
// Define some tokens that will be turned into literal < and > in XML comments.
20+
// This will be used by a function later on that needs to output literal tokens,
21+
// in a string where they would usually be escaped into &lt; and &gt;
22+
//
23+
// We use a random string in here so the actual token values cannot be predicted
24+
// in advance, so that an attacker can not use this knowledge to inject the tokens
25+
// literally into doc comments, and perform an XSS attack that way.
26+
const L_ANGLE = `@l${Math.random()}@`;
27+
const R_ANGLE = `@r${Math.random()}@`;
1528

1629
/**
1730
* Generates the Jsii attributes and calls for the .NET runtime
@@ -26,6 +39,7 @@ export class DotNetDocGenerator {
2639
code: CodeMaker,
2740
private readonly rosetta: RosettaTabletReader,
2841
private readonly assembly: spec.Assembly,
42+
private readonly resolver: DotNetTypeResolver,
2943
) {
3044
this.code = code;
3145
}
@@ -54,32 +68,57 @@ export class DotNetDocGenerator {
5468
const paramName = this.nameutils
5569
.convertParameterName(param.name)
5670
.replace(/^@/, '');
57-
this.emitXmlDoc('param', param.docs?.summary ?? '', {
58-
attributes: { name: paramName },
59-
});
71+
72+
const unionHint = containsUnionType(param.type)
73+
? `Type union: ${this.renderTypeForDocs(param.type)}`
74+
: '';
75+
76+
this.emitXmlDoc(
77+
'param',
78+
combineSentences(param.docs?.summary, unionHint),
79+
{
80+
attributes: { name: paramName },
81+
},
82+
);
6083
});
6184
}
6285

63-
// At this pdocfx namespacedocd a valid instance of docs
64-
if (!docs) {
65-
return;
66-
}
86+
const returnUnionHint =
87+
objMethod.returns && containsUnionType(objMethod.returns.type)
88+
? `Type union: ${this.renderTypeForDocs(objMethod.returns.type)}`
89+
: '';
6790

68-
if (docs.returns) {
69-
this.emitXmlDoc('returns', docs.returns);
91+
if (docs?.returns || returnUnionHint) {
92+
this.emitXmlDoc(
93+
'returns',
94+
combineSentences(docs?.returns, returnUnionHint),
95+
);
7096
}
7197

98+
const propUnionHint =
99+
spec.isProperty(obj) && containsUnionType(obj.type)
100+
? `Type union: ${this.renderTypeForDocs(obj.type)}`
101+
: '';
102+
72103
// Remarks does not use emitXmlDoc() because the remarks can contain code blocks
73104
// which are fenced with <code> tags, which would be escaped to
74105
// &lt;code&gt; if we used the xml builder.
75-
const remarks = this.renderRemarks(docs, apiLocation);
76-
if (remarks.length > 0) {
106+
const remarks = this.renderRemarks(docs ?? {}, apiLocation);
107+
if (remarks.length > 0 || propUnionHint) {
77108
this.code.line('/// <remarks>');
78109
remarks.forEach((r) => this.code.line(`/// ${r}`.trimRight()));
110+
111+
if (propUnionHint) {
112+
// Very likely to contain < and > from `Dictionary<...>`, but we also want the literal angle brackets
113+
// from `<see cref="...">`.
114+
this.code.line(
115+
`/// <para>${unescapeAngleMarkers(escapeAngleBrackets(propUnionHint))}</para>`,
116+
);
117+
}
79118
this.code.line('/// </remarks>');
80119
}
81120

82-
if (docs.example) {
121+
if (docs?.example) {
83122
this.code.line('/// <example>');
84123
this.emitXmlDoc('code', this.convertExample(docs.example, apiLocation));
85124
this.code.line('/// </example>');
@@ -193,14 +232,39 @@ export class DotNetDocGenerator {
193232
for (const [name, value] of Object.entries(attributes)) {
194233
xml.att(name, value);
195234
}
196-
const xmlstring = xml.end({ allowEmpty: true, pretty: false });
235+
236+
// Unescape angle brackets that may have been injected by `renderTypeForDocs`
237+
const xmlstring = unescapeAngleMarkers(
238+
xml.end({ allowEmpty: true, pretty: false }),
239+
);
240+
197241
const trimLeft = tag !== 'code';
198242
for (const line of xmlstring
199243
.split('\n')
200244
.map((x) => (trimLeft ? x.trim() : x.trimRight()))) {
201245
this.code.line(`/// ${line}`);
202246
}
203247
}
248+
249+
private renderTypeForDocs(x: spec.TypeReference): string {
250+
return visitTypeReference<string>(x, {
251+
named: (ref) =>
252+
`${L_ANGLE}see cref="${this.resolver.toNativeFqn(ref.fqn)}" /${R_ANGLE}`,
253+
primitive: (ref) => this.resolver.toDotNetType(ref),
254+
collection: (ref) => {
255+
switch (ref.collection.kind) {
256+
case spec.CollectionKind.Array:
257+
return `(${this.renderTypeForDocs(ref.collection.elementtype)})[]`;
258+
case spec.CollectionKind.Map:
259+
return `Dictionary<string, ${this.renderTypeForDocs(ref.collection.elementtype)}>`;
260+
}
261+
},
262+
union: (ref) =>
263+
`either ${ref.union.types.map((x) => this.renderTypeForDocs(x)).join(' or ')}`,
264+
intersection: (ref) =>
265+
`${ref.intersection.types.map((x) => this.renderTypeForDocs(x)).join(' + ')}`,
266+
});
267+
}
204268
}
205269

206270
/**
@@ -214,3 +278,20 @@ function shouldMentionStability(s: spec.Stability) {
214278
// Don't render "stable" or "external", those are both stable by implication
215279
return s === spec.Stability.Deprecated || s === spec.Stability.Experimental;
216280
}
281+
282+
function combineSentences(...xs: Array<string | undefined>): string {
283+
return xs.filter((x) => x).join('. ');
284+
}
285+
286+
function escapeAngleBrackets(x: string) {
287+
return x.replace(/</g, '&lt;').replace(/>/g, '&gt;');
288+
}
289+
290+
/**
291+
* Replace the special angle markers produced by renderTypeForDocs with literal angle brackets
292+
*/
293+
function unescapeAngleMarkers(x: string) {
294+
return x
295+
.replace(new RegExp(L_ANGLE, 'g'), '<')
296+
.replace(new RegExp(R_ANGLE, 'g'), '>');
297+
}

packages/jsii-pacmak/lib/targets/dotnet/dotnetgenerator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class DotNetGenerator extends Generator {
7878
this.code,
7979
this.rosetta,
8080
this.assembly,
81+
this.typeresolver,
8182
);
8283

8384
this.emitAssemblyDocs();

packages/jsii-pacmak/lib/targets/java.ts

Lines changed: 18 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { VERSION, VERSION_DESC } from '../version';
3131
import { stabilityPrefixFor, renderSummary } from './_utils';
3232
import { toMavenVersionRange, toReleaseVersion } from './version-utils';
3333
import { assertSpecIsRosettaCompatible } from '../rosetta-assembly';
34+
import { containsUnionType } from '../type-utils';
35+
import { visitTypeReference } from '../type-visitor';
3436

3537
import { TargetName } from './index';
3638

@@ -3061,24 +3063,22 @@ class JavaGenerator extends Generator {
30613063
* Render a type reference to something human readable for use in JavaDocs
30623064
*/
30633065
private renderTypeReference(x: spec.TypeReference): string {
3064-
if (spec.isPrimitiveTypeReference(x)) {
3065-
return `{@link ${this.toJavaPrimitive(x.primitive)}}`;
3066-
}
3067-
if (spec.isNamedTypeReference(x)) {
3068-
return `{@link ${this.toNativeFqn(x.fqn)}}`;
3069-
}
3070-
if (spec.isCollectionTypeReference(x)) {
3071-
switch (x.collection.kind) {
3072-
case spec.CollectionKind.Array:
3073-
return `List<${this.renderTypeReference(x.collection.elementtype)}>`;
3074-
case spec.CollectionKind.Map:
3075-
return `Map<String, ${this.renderTypeReference(x.collection.elementtype)}>`;
3076-
}
3077-
}
3078-
if (spec.isUnionTypeReference(x)) {
3079-
return `either ${x.union.types.map((x) => this.renderTypeReference(x)).join(' or ')}`;
3080-
}
3081-
throw new Error(`Unknown type reference: ${JSON.stringify(x)}`);
3066+
return visitTypeReference<string>(x, {
3067+
primitive: (x) => `{@link ${this.toJavaPrimitive(x.primitive)}}`,
3068+
named: (x) => `{@link ${this.toNativeFqn(x.fqn)}}`,
3069+
collection: (x) => {
3070+
switch (x.collection.kind) {
3071+
case spec.CollectionKind.Array:
3072+
return `List<${this.renderTypeReference(x.collection.elementtype)}>`;
3073+
case spec.CollectionKind.Map:
3074+
return `Map<String, ${this.renderTypeReference(x.collection.elementtype)}>`;
3075+
}
3076+
},
3077+
union: (x) =>
3078+
`either ${x.union.types.map((x) => this.renderTypeReference(x)).join(' or ')}`,
3079+
intersection: (x) =>
3080+
`${x.intersection.types.map((x) => this.renderTypeReference(x)).join(' + ')}`,
3081+
});
30823082
}
30833083

30843084
private renderMethodCallArguments(method: spec.Callable) {
@@ -3732,16 +3732,6 @@ function escape(s: string) {
37323732
return s.replace(/["\\<>&]/g, (c) => `&#${c.charCodeAt(0)};`);
37333733
}
37343734

3735-
function containsUnionType(
3736-
typeRef: spec.TypeReference,
3737-
): typeRef is spec.UnionTypeReference | spec.CollectionTypeReference {
3738-
return (
3739-
spec.isUnionTypeReference(typeRef) ||
3740-
(spec.isCollectionTypeReference(typeRef) &&
3741-
containsUnionType(typeRef.collection.elementtype))
3742-
);
3743-
}
3744-
37453735
function myMarkDownToJavaDoc(source: string) {
37463736
if (source.includes('{@link') || source.includes('{@code')) {
37473737
// Slightly dirty hack: if we are seeing this, it means the docstring was provided literally
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as spec from '@jsii/spec';
2+
3+
export function containsUnionType(
4+
typeRef: spec.TypeReference,
5+
): typeRef is spec.UnionTypeReference | spec.CollectionTypeReference {
6+
return (
7+
spec.isUnionTypeReference(typeRef) ||
8+
(spec.isCollectionTypeReference(typeRef) &&
9+
containsUnionType(typeRef.collection.elementtype))
10+
);
11+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as spec from '@jsii/spec';
2+
3+
export interface TypeReferenceVisitor<A = void> {
4+
named(ref: spec.NamedTypeReference): A;
5+
primitive(ref: spec.PrimitiveTypeReference): A;
6+
collection(ref: spec.CollectionTypeReference): A;
7+
union(ref: spec.UnionTypeReference): A;
8+
intersection(ref: spec.IntersectionTypeReference): A;
9+
}
10+
11+
export function visitTypeReference<A>(
12+
typeRef: spec.TypeReference,
13+
visitor: TypeReferenceVisitor<A>,
14+
) {
15+
if (spec.isNamedTypeReference(typeRef)) {
16+
return visitor.named(typeRef);
17+
} else if (spec.isPrimitiveTypeReference(typeRef)) {
18+
return visitor.primitive(typeRef);
19+
} else if (spec.isCollectionTypeReference(typeRef)) {
20+
return visitor.collection(typeRef);
21+
} else if (spec.isUnionTypeReference(typeRef)) {
22+
return visitor.union(typeRef);
23+
} else if (spec.isIntersectionTypeReference(typeRef)) {
24+
return visitor.intersection(typeRef);
25+
}
26+
throw new Error(`Unknown type reference: ${JSON.stringify(typeRef)}`);
27+
}
28+
29+
export interface TypeVisitor<A = void> {
30+
classType(t: spec.ClassType): A;
31+
interfaceType(t: spec.InterfaceType): A;
32+
dataType(t: spec.InterfaceType): A;
33+
enumType(t: spec.EnumType): A;
34+
}
35+
36+
export function visitType<A>(t: spec.Type, visitor: TypeVisitor<A>) {
37+
if (spec.isClassType(t)) {
38+
return visitor.classType(t);
39+
} else if (spec.isInterfaceType(t) && t.datatype) {
40+
return visitor.dataType(t);
41+
} else if (spec.isInterfaceType(t)) {
42+
return visitor.interfaceType(t);
43+
} else if (spec.isEnumType(t)) {
44+
return visitor.enumType(t);
45+
}
46+
throw new Error(`Unknown type: ${JSON.stringify(t)}`);
47+
}
48+
49+
export function isDataType(t: spec.Type): t is spec.InterfaceType {
50+
return spec.isInterfaceType(t) && !!t.datatype;
51+
}
52+
53+
export function isBehavioralInterfaceType(
54+
t: spec.Type,
55+
): t is spec.InterfaceType {
56+
return spec.isInterfaceType(t) && !t.datatype;
57+
}

0 commit comments

Comments
 (0)