Skip to content

Commit bc752fc

Browse files
BridgeJS: Generate Swift doc-comment based on JSDoc (swiftwasm#596)
* Doc comments from .d.ts JSDoc now flow into generated Swift DocC output. - Added JSDoc parsing (description/@param/@returns) and DocC emission for callable/typed declarations, including classes/interfaces/enums, in `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js`, with guards to only read param/return tags on callable nodes. - Added a documented fixture to cover the new behavior and updated the Vitest snapshot (`Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/Documentation.d.ts`, `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap`). Tests: - `npm test -- -u` (Plugins/BridgeJS/Sources/TS2Swift/JavaScript) Next steps: optionally run `swift test --package-path ./Plugins/BridgeJS` to keep the Swift-side suite green. * Added richer WebIDL-derived doc coverage using the bridgejs-development skill: new fixtures model lib.dom-style comments (MDN reference blocks, @param text) and updated Vitest snapshots to show how those JSDoc comments render into DocC. Key files: `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/WebIDLDOMDocs.d.ts`, `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/DOMLike.d.ts`, and the refreshed snapshot in `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap`. Tests run: `npm test -- -u` (TS2Swift JavaScript). * Removed the DOM-like fixture and refreshed snapshots to drop its output. Changes: deleted `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/DOMLike.d.ts` and updated `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap`. Tests: `npm test -- -u` (TS2Swift JavaScript).
1 parent 444393f commit bc752fc

File tree

4 files changed

+476
-35
lines changed

4 files changed

+476
-35
lines changed

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js

Lines changed: 195 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,9 @@ export class TypeProcessor {
157157
this.visitEnumType(type, node);
158158
continue;
159159
}
160-
const typeString = this.checker.typeToString(type);
161160
const members = type.getProperties();
162161
if (members) {
163-
this.visitStructuredType(typeString, members);
162+
this.visitStructuredType(type, node, members);
164163
}
165164
}
166165
});
@@ -284,6 +283,7 @@ export class TypeProcessor {
284283
if (fromArg) args.push(fromArg);
285284
const annotation = this.renderMacroAnnotation("JSGetter", args);
286285

286+
this.emitDocComment(decl, { indent: "" });
287287
this.swiftLines.push(`${annotation} var ${swiftVarName}: ${swiftType}`);
288288
this.swiftLines.push("");
289289
}
@@ -338,6 +338,7 @@ export class TypeProcessor {
338338
this.emittedEnumNames.add(enumName);
339339

340340
const members = decl.members ?? [];
341+
this.emitDocComment(decl, { indent: "" });
341342
if (members.length === 0) {
342343
this.diagnosticEngine.print("warning", `Empty enum is not supported: ${enumName}`, diagnosticNode);
343344
this.swiftLines.push(`typealias ${this.renderIdentifier(enumName)} = String`);
@@ -448,54 +449,194 @@ export class TypeProcessor {
448449
const signature = this.checker.getSignatureFromDeclaration(node);
449450
if (!signature) return;
450451

451-
const params = this.renderParameters(signature.getParameters(), node);
452+
const parameters = signature.getParameters();
453+
const parameterNameMap = this.buildParameterNameMap(parameters);
454+
const params = this.renderParameters(parameters, node);
452455
const returnType = this.visitType(signature.getReturnType(), node);
453456
const effects = this.renderEffects({ isAsync: false });
454457
const swiftFuncName = this.renderIdentifier(swiftName);
455458

459+
this.emitDocComment(node, { parameterNameMap });
456460
this.swiftLines.push(`${annotation} func ${swiftFuncName}(${params}) ${effects} -> ${returnType}`);
457461
this.swiftLines.push("");
458462
}
459463

460464
/**
461-
* Get the full JSDoc text from a node
462-
* @param {ts.Node} node - The node to get the JSDoc text from
463-
* @returns {string | undefined} The full JSDoc text
465+
* Convert a JSDoc comment node content to plain text.
466+
* @param {string | ts.NodeArray<ts.JSDocComment> | undefined} comment
467+
* @returns {string}
468+
* @private
464469
*/
465-
getFullJSDocText(node) {
466-
const docs = ts.getJSDocCommentsAndTags(node);
467-
const parts = [];
468-
for (const doc of docs) {
469-
if (ts.isJSDoc(doc)) {
470-
parts.push(doc.comment ?? "");
470+
renderJSDocText(comment) {
471+
if (!comment) return "";
472+
if (typeof comment === "string") return comment;
473+
let result = "";
474+
for (const part of comment) {
475+
if (typeof part === "string") {
476+
result += part;
477+
continue;
478+
}
479+
// JSDocText/JSDocLink both have a `text` field
480+
// https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts
481+
// @ts-ignore
482+
if (typeof part.text === "string") {
483+
// @ts-ignore
484+
result += part.text;
485+
continue;
486+
}
487+
if (typeof part.getText === "function") {
488+
result += part.getText();
471489
}
472490
}
473-
if (parts.length === 0) return undefined;
474-
return parts.join("\n");
491+
return result;
475492
}
476493

477-
/** @returns {string} */
478-
renderDefaultJSImportFromArgument() {
479-
if (this.defaultImportFromGlobal) return "from: .global";
480-
return "";
494+
/**
495+
* Split documentation text into lines suitable for DocC rendering.
496+
* @param {string} text
497+
* @returns {string[]}
498+
* @private
499+
*/
500+
splitDocumentationText(text) {
501+
if (!text) return [];
502+
return text.split(/\r?\n/).map(line => line.trimEnd());
481503
}
482504

483505
/**
484-
* Render constructor parameters
485-
* @param {ts.ConstructorDeclaration} node
486-
* @returns {string} Rendered parameters
506+
* @param {string[]} lines
507+
* @returns {boolean}
487508
* @private
488509
*/
489-
renderConstructorParameters(node) {
490-
const signature = this.checker.getSignatureFromDeclaration(node);
491-
if (!signature) return "";
510+
hasMeaningfulLine(lines) {
511+
return lines.some(line => line.trim().length > 0);
512+
}
513+
514+
/**
515+
* Render Swift doc comments from a node's JSDoc, including parameter/return tags.
516+
* @param {ts.Node} node
517+
* @param {{ indent?: string, parameterNameMap?: Map<string, string> }} options
518+
* @private
519+
*/
520+
emitDocComment(node, options = {}) {
521+
const indent = options.indent ?? "";
522+
const parameterNameMap = options.parameterNameMap ?? new Map();
523+
524+
/** @type {string[]} */
525+
const descriptionLines = [];
526+
for (const doc of ts.getJSDocCommentsAndTags(node)) {
527+
if (!ts.isJSDoc(doc)) continue;
528+
const text = this.renderJSDocText(doc.comment);
529+
if (text) {
530+
descriptionLines.push(...this.splitDocumentationText(text));
531+
}
532+
}
533+
534+
/** @type {Array<{ name: string, lines: string[] }>} */
535+
const parameterDocs = [];
536+
const supportsParameters = (
537+
ts.isFunctionLike(node) ||
538+
ts.isMethodSignature(node) ||
539+
ts.isCallSignatureDeclaration(node) ||
540+
ts.isConstructSignatureDeclaration(node)
541+
);
542+
/** @type {ts.JSDocReturnTag | undefined} */
543+
let returnTag = undefined;
544+
if (supportsParameters) {
545+
for (const tag of ts.getJSDocTags(node)) {
546+
if (ts.isJSDocParameterTag(tag)) {
547+
const tsName = tag.name.getText();
548+
const name = parameterNameMap.get(tsName) ?? this.renderIdentifier(tsName);
549+
const text = this.renderJSDocText(tag.comment);
550+
const lines = this.splitDocumentationText(text);
551+
parameterDocs.push({ name, lines });
552+
} else if (!returnTag && ts.isJSDocReturnTag(tag)) {
553+
returnTag = tag;
554+
}
555+
}
556+
}
557+
558+
const returnLines = returnTag ? this.splitDocumentationText(this.renderJSDocText(returnTag.comment)) : [];
559+
const hasDescription = this.hasMeaningfulLine(descriptionLines);
560+
const hasParameters = parameterDocs.length > 0;
561+
const hasReturns = returnTag !== undefined;
562+
563+
if (!hasDescription && !hasParameters && !hasReturns) {
564+
return;
565+
}
492566

493-
return this.renderParameters(signature.getParameters(), node);
567+
/** @type {string[]} */
568+
const docLines = [];
569+
if (hasDescription) {
570+
docLines.push(...descriptionLines);
571+
}
572+
573+
if (hasDescription && (hasParameters || hasReturns)) {
574+
docLines.push("");
575+
}
576+
577+
if (hasParameters) {
578+
docLines.push("- Parameters:");
579+
for (const param of parameterDocs) {
580+
const hasParamDescription = this.hasMeaningfulLine(param.lines);
581+
const [firstParamLine, ...restParamLines] = param.lines;
582+
if (hasParamDescription) {
583+
docLines.push(` - ${param.name}: ${firstParamLine}`);
584+
for (const line of restParamLines) {
585+
docLines.push(` ${line}`);
586+
}
587+
} else {
588+
docLines.push(` - ${param.name}:`);
589+
}
590+
}
591+
}
592+
593+
if (hasReturns) {
594+
const hasReturnDescription = this.hasMeaningfulLine(returnLines);
595+
const [firstReturnLine, ...restReturnLines] = returnLines;
596+
if (hasReturnDescription) {
597+
docLines.push(`- Returns: ${firstReturnLine}`);
598+
for (const line of restReturnLines) {
599+
docLines.push(` ${line}`);
600+
}
601+
} else {
602+
docLines.push("- Returns:");
603+
}
604+
}
605+
606+
const prefix = `${indent}///`;
607+
for (const line of docLines) {
608+
if (line.length === 0) {
609+
this.swiftLines.push(prefix);
610+
} else {
611+
this.swiftLines.push(`${prefix} ${line}`);
612+
}
613+
}
494614
}
495615

496616
/**
617+
* Build a map from TypeScript parameter names to rendered Swift identifiers.
618+
* @param {ts.Symbol[]} parameters
619+
* @returns {Map<string, string>}
620+
* @private
621+
*/
622+
buildParameterNameMap(parameters) {
623+
const map = new Map();
624+
for (const parameter of parameters) {
625+
map.set(parameter.name, this.renderIdentifier(parameter.name));
626+
}
627+
return map;
628+
}
629+
630+
/** @returns {string} */
631+
renderDefaultJSImportFromArgument() {
632+
if (this.defaultImportFromGlobal) return "from: .global";
633+
return "";
634+
}
635+
636+
/**
637+
* Visit a property declaration and extract metadata
497638
* @param {ts.PropertyDeclaration | ts.PropertySignature} node
498-
* @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean, documentation: string | undefined } | null}
639+
* @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean } | null}
499640
*/
500641
visitPropertyDecl(node) {
501642
if (!node.name) return null;
@@ -515,8 +656,7 @@ export class TypeProcessor {
515656
const type = this.checker.getTypeAtLocation(node)
516657
const swiftType = this.visitType(type, node);
517658
const isReadonly = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false;
518-
const documentation = this.getFullJSDocText(node);
519-
return { jsName, swiftName, type: swiftType, isReadonly, documentation };
659+
return { jsName, swiftName, type: swiftType, isReadonly };
520660
}
521661

522662
/**
@@ -551,6 +691,7 @@ export class TypeProcessor {
551691
if (fromArg) args.push(fromArg);
552692
const annotation = this.renderMacroAnnotation("JSClass", args);
553693
const className = this.renderIdentifier(swiftName);
694+
this.emitDocComment(node, { indent: "" });
554695
this.swiftLines.push(`${annotation} struct ${className} {`);
555696

556697
// Process members in declaration order
@@ -600,11 +741,15 @@ export class TypeProcessor {
600741

601742
/**
602743
* Visit a structured type (interface) and render Swift code
603-
* @param {string} name
744+
* @param {ts.Type} type
745+
* @param {ts.Node} diagnosticNode
604746
* @param {ts.Symbol[]} members
605747
* @private
606748
*/
607-
visitStructuredType(name, members) {
749+
visitStructuredType(type, diagnosticNode, members) {
750+
const symbol = type.getSymbol() ?? type.aliasSymbol;
751+
const name = symbol?.name ?? this.checker.typeToString(type);
752+
if (!name) return;
608753
if (this.emittedStructuredTypeNames.has(name)) return;
609754
this.emittedStructuredTypeNames.add(name);
610755

@@ -615,17 +760,22 @@ export class TypeProcessor {
615760
if (jsNameArg) args.push(jsNameArg);
616761
const annotation = this.renderMacroAnnotation("JSClass", args);
617762
const typeName = this.renderIdentifier(swiftName);
763+
const docNode = symbol?.getDeclarations()?.[0] ?? diagnosticNode;
764+
if (docNode) {
765+
this.emitDocComment(docNode, { indent: "" });
766+
}
618767
this.swiftLines.push(`${annotation} struct ${typeName} {`);
619768

620769
// Collect all declarations with their positions to preserve order
621770
/** @type {Array<{ decl: ts.Node, symbol: ts.Symbol, position: number }>} */
622771
const allDecls = [];
623772

624-
for (const symbol of members) {
625-
for (const decl of symbol.getDeclarations() ?? []) {
773+
const typeMembers = members ?? type.getProperties() ?? [];
774+
for (const memberSymbol of typeMembers) {
775+
for (const decl of memberSymbol.getDeclarations() ?? []) {
626776
const sourceFile = decl.getSourceFile();
627777
const pos = sourceFile ? decl.getStart() : 0;
628-
allDecls.push({ decl, symbol, position: pos });
778+
allDecls.push({ decl, symbol: memberSymbol, position: pos });
629779
}
630780
}
631781

@@ -854,6 +1004,7 @@ export class TypeProcessor {
8541004
const getterAnnotation = this.renderMacroAnnotation("JSGetter", getterArgs);
8551005

8561006
// Always render getter
1007+
this.emitDocComment(node, { indent: " " });
8571008
this.swiftLines.push(` ${getterAnnotation} var ${swiftName}: ${type}`);
8581009

8591010
// Render setter if not readonly
@@ -903,7 +1054,9 @@ export class TypeProcessor {
9031054
const signature = this.checker.getSignatureFromDeclaration(node);
9041055
if (!signature) return;
9051056

906-
const params = this.renderParameters(signature.getParameters(), node);
1057+
const parameters = signature.getParameters();
1058+
const parameterNameMap = this.buildParameterNameMap(parameters);
1059+
const params = this.renderParameters(parameters, node);
9071060
const returnType = this.visitType(signature.getReturnType(), node);
9081061
const effects = this.renderEffects({ isAsync: false });
9091062
const swiftMethodName = this.renderIdentifier(swiftName);
@@ -912,6 +1065,7 @@ export class TypeProcessor {
9121065
) ?? false;
9131066
const staticKeyword = isStatic ? "static " : "";
9141067

1068+
this.emitDocComment(node, { indent: " ", parameterNameMap });
9151069
this.swiftLines.push(` ${annotation} ${staticKeyword}func ${swiftMethodName}(${params}) ${effects} -> ${returnType}`);
9161070
}
9171071

@@ -930,8 +1084,14 @@ export class TypeProcessor {
9301084
* @private
9311085
*/
9321086
renderConstructor(node) {
933-
const params = this.renderConstructorParameters(node);
1087+
const signature = this.checker.getSignatureFromDeclaration(node);
1088+
if (!signature) return;
1089+
1090+
const parameters = signature.getParameters();
1091+
const parameterNameMap = this.buildParameterNameMap(parameters);
1092+
const params = this.renderParameters(parameters, node);
9341093
const effects = this.renderEffects({ isAsync: false });
1094+
this.emitDocComment(node, { indent: " ", parameterNameMap });
9351095
this.swiftLines.push(` @JSFunction init(${params}) ${effects}`);
9361096
}
9371097

0 commit comments

Comments
 (0)