diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index ef446af0..f326de22 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -157,10 +157,9 @@ export class TypeProcessor { this.visitEnumType(type, node); continue; } - const typeString = this.checker.typeToString(type); const members = type.getProperties(); if (members) { - this.visitStructuredType(typeString, members); + this.visitStructuredType(type, node, members); } } }); @@ -284,6 +283,7 @@ export class TypeProcessor { if (fromArg) args.push(fromArg); const annotation = this.renderMacroAnnotation("JSGetter", args); + this.emitDocComment(decl, { indent: "" }); this.swiftLines.push(`${annotation} var ${swiftVarName}: ${swiftType}`); this.swiftLines.push(""); } @@ -338,6 +338,7 @@ export class TypeProcessor { this.emittedEnumNames.add(enumName); const members = decl.members ?? []; + this.emitDocComment(decl, { indent: "" }); if (members.length === 0) { this.diagnosticEngine.print("warning", `Empty enum is not supported: ${enumName}`, diagnosticNode); this.swiftLines.push(`typealias ${this.renderIdentifier(enumName)} = String`); @@ -448,54 +449,194 @@ export class TypeProcessor { const signature = this.checker.getSignatureFromDeclaration(node); if (!signature) return; - const params = this.renderParameters(signature.getParameters(), node); + const parameters = signature.getParameters(); + const parameterNameMap = this.buildParameterNameMap(parameters); + const params = this.renderParameters(parameters, node); const returnType = this.visitType(signature.getReturnType(), node); const effects = this.renderEffects({ isAsync: false }); const swiftFuncName = this.renderIdentifier(swiftName); + this.emitDocComment(node, { parameterNameMap }); this.swiftLines.push(`${annotation} func ${swiftFuncName}(${params}) ${effects} -> ${returnType}`); this.swiftLines.push(""); } /** - * Get the full JSDoc text from a node - * @param {ts.Node} node - The node to get the JSDoc text from - * @returns {string | undefined} The full JSDoc text + * Convert a JSDoc comment node content to plain text. + * @param {string | ts.NodeArray | undefined} comment + * @returns {string} + * @private */ - getFullJSDocText(node) { - const docs = ts.getJSDocCommentsAndTags(node); - const parts = []; - for (const doc of docs) { - if (ts.isJSDoc(doc)) { - parts.push(doc.comment ?? ""); + renderJSDocText(comment) { + if (!comment) return ""; + if (typeof comment === "string") return comment; + let result = ""; + for (const part of comment) { + if (typeof part === "string") { + result += part; + continue; + } + // JSDocText/JSDocLink both have a `text` field + // https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts + // @ts-ignore + if (typeof part.text === "string") { + // @ts-ignore + result += part.text; + continue; + } + if (typeof part.getText === "function") { + result += part.getText(); } } - if (parts.length === 0) return undefined; - return parts.join("\n"); + return result; } - /** @returns {string} */ - renderDefaultJSImportFromArgument() { - if (this.defaultImportFromGlobal) return "from: .global"; - return ""; + /** + * Split documentation text into lines suitable for DocC rendering. + * @param {string} text + * @returns {string[]} + * @private + */ + splitDocumentationText(text) { + if (!text) return []; + return text.split(/\r?\n/).map(line => line.trimEnd()); } /** - * Render constructor parameters - * @param {ts.ConstructorDeclaration} node - * @returns {string} Rendered parameters + * @param {string[]} lines + * @returns {boolean} * @private */ - renderConstructorParameters(node) { - const signature = this.checker.getSignatureFromDeclaration(node); - if (!signature) return ""; + hasMeaningfulLine(lines) { + return lines.some(line => line.trim().length > 0); + } + + /** + * Render Swift doc comments from a node's JSDoc, including parameter/return tags. + * @param {ts.Node} node + * @param {{ indent?: string, parameterNameMap?: Map }} options + * @private + */ + emitDocComment(node, options = {}) { + const indent = options.indent ?? ""; + const parameterNameMap = options.parameterNameMap ?? new Map(); + + /** @type {string[]} */ + const descriptionLines = []; + for (const doc of ts.getJSDocCommentsAndTags(node)) { + if (!ts.isJSDoc(doc)) continue; + const text = this.renderJSDocText(doc.comment); + if (text) { + descriptionLines.push(...this.splitDocumentationText(text)); + } + } + + /** @type {Array<{ name: string, lines: string[] }>} */ + const parameterDocs = []; + const supportsParameters = ( + ts.isFunctionLike(node) || + ts.isMethodSignature(node) || + ts.isCallSignatureDeclaration(node) || + ts.isConstructSignatureDeclaration(node) + ); + /** @type {ts.JSDocReturnTag | undefined} */ + let returnTag = undefined; + if (supportsParameters) { + for (const tag of ts.getJSDocTags(node)) { + if (ts.isJSDocParameterTag(tag)) { + const tsName = tag.name.getText(); + const name = parameterNameMap.get(tsName) ?? this.renderIdentifier(tsName); + const text = this.renderJSDocText(tag.comment); + const lines = this.splitDocumentationText(text); + parameterDocs.push({ name, lines }); + } else if (!returnTag && ts.isJSDocReturnTag(tag)) { + returnTag = tag; + } + } + } + + const returnLines = returnTag ? this.splitDocumentationText(this.renderJSDocText(returnTag.comment)) : []; + const hasDescription = this.hasMeaningfulLine(descriptionLines); + const hasParameters = parameterDocs.length > 0; + const hasReturns = returnTag !== undefined; + + if (!hasDescription && !hasParameters && !hasReturns) { + return; + } - return this.renderParameters(signature.getParameters(), node); + /** @type {string[]} */ + const docLines = []; + if (hasDescription) { + docLines.push(...descriptionLines); + } + + if (hasDescription && (hasParameters || hasReturns)) { + docLines.push(""); + } + + if (hasParameters) { + docLines.push("- Parameters:"); + for (const param of parameterDocs) { + const hasParamDescription = this.hasMeaningfulLine(param.lines); + const [firstParamLine, ...restParamLines] = param.lines; + if (hasParamDescription) { + docLines.push(` - ${param.name}: ${firstParamLine}`); + for (const line of restParamLines) { + docLines.push(` ${line}`); + } + } else { + docLines.push(` - ${param.name}:`); + } + } + } + + if (hasReturns) { + const hasReturnDescription = this.hasMeaningfulLine(returnLines); + const [firstReturnLine, ...restReturnLines] = returnLines; + if (hasReturnDescription) { + docLines.push(`- Returns: ${firstReturnLine}`); + for (const line of restReturnLines) { + docLines.push(` ${line}`); + } + } else { + docLines.push("- Returns:"); + } + } + + const prefix = `${indent}///`; + for (const line of docLines) { + if (line.length === 0) { + this.swiftLines.push(prefix); + } else { + this.swiftLines.push(`${prefix} ${line}`); + } + } } /** + * Build a map from TypeScript parameter names to rendered Swift identifiers. + * @param {ts.Symbol[]} parameters + * @returns {Map} + * @private + */ + buildParameterNameMap(parameters) { + const map = new Map(); + for (const parameter of parameters) { + map.set(parameter.name, this.renderIdentifier(parameter.name)); + } + return map; + } + + /** @returns {string} */ + renderDefaultJSImportFromArgument() { + if (this.defaultImportFromGlobal) return "from: .global"; + return ""; + } + + /** + * Visit a property declaration and extract metadata * @param {ts.PropertyDeclaration | ts.PropertySignature} node - * @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean, documentation: string | undefined } | null} + * @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean } | null} */ visitPropertyDecl(node) { if (!node.name) return null; @@ -515,8 +656,7 @@ export class TypeProcessor { const type = this.checker.getTypeAtLocation(node) const swiftType = this.visitType(type, node); const isReadonly = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false; - const documentation = this.getFullJSDocText(node); - return { jsName, swiftName, type: swiftType, isReadonly, documentation }; + return { jsName, swiftName, type: swiftType, isReadonly }; } /** @@ -551,6 +691,7 @@ export class TypeProcessor { if (fromArg) args.push(fromArg); const annotation = this.renderMacroAnnotation("JSClass", args); const className = this.renderIdentifier(swiftName); + this.emitDocComment(node, { indent: "" }); this.swiftLines.push(`${annotation} struct ${className} {`); // Process members in declaration order @@ -600,11 +741,15 @@ export class TypeProcessor { /** * Visit a structured type (interface) and render Swift code - * @param {string} name + * @param {ts.Type} type + * @param {ts.Node} diagnosticNode * @param {ts.Symbol[]} members * @private */ - visitStructuredType(name, members) { + visitStructuredType(type, diagnosticNode, members) { + const symbol = type.getSymbol() ?? type.aliasSymbol; + const name = symbol?.name ?? this.checker.typeToString(type); + if (!name) return; if (this.emittedStructuredTypeNames.has(name)) return; this.emittedStructuredTypeNames.add(name); @@ -615,17 +760,22 @@ export class TypeProcessor { if (jsNameArg) args.push(jsNameArg); const annotation = this.renderMacroAnnotation("JSClass", args); const typeName = this.renderIdentifier(swiftName); + const docNode = symbol?.getDeclarations()?.[0] ?? diagnosticNode; + if (docNode) { + this.emitDocComment(docNode, { indent: "" }); + } this.swiftLines.push(`${annotation} struct ${typeName} {`); // Collect all declarations with their positions to preserve order /** @type {Array<{ decl: ts.Node, symbol: ts.Symbol, position: number }>} */ const allDecls = []; - for (const symbol of members) { - for (const decl of symbol.getDeclarations() ?? []) { + const typeMembers = members ?? type.getProperties() ?? []; + for (const memberSymbol of typeMembers) { + for (const decl of memberSymbol.getDeclarations() ?? []) { const sourceFile = decl.getSourceFile(); const pos = sourceFile ? decl.getStart() : 0; - allDecls.push({ decl, symbol, position: pos }); + allDecls.push({ decl, symbol: memberSymbol, position: pos }); } } @@ -800,6 +950,7 @@ export class TypeProcessor { const getterAnnotation = this.renderMacroAnnotation("JSGetter", getterArgs); // Always render getter + this.emitDocComment(node, { indent: " " }); this.swiftLines.push(` ${getterAnnotation} var ${swiftName}: ${type}`); // Render setter if not readonly @@ -849,7 +1000,9 @@ export class TypeProcessor { const signature = this.checker.getSignatureFromDeclaration(node); if (!signature) return; - const params = this.renderParameters(signature.getParameters(), node); + const parameters = signature.getParameters(); + const parameterNameMap = this.buildParameterNameMap(parameters); + const params = this.renderParameters(parameters, node); const returnType = this.visitType(signature.getReturnType(), node); const effects = this.renderEffects({ isAsync: false }); const swiftMethodName = this.renderIdentifier(swiftName); @@ -858,6 +1011,7 @@ export class TypeProcessor { ) ?? false; const staticKeyword = isStatic ? "static " : ""; + this.emitDocComment(node, { indent: " ", parameterNameMap }); this.swiftLines.push(` ${annotation} ${staticKeyword}func ${swiftMethodName}(${params}) ${effects} -> ${returnType}`); } @@ -876,8 +1030,14 @@ export class TypeProcessor { * @private */ renderConstructor(node) { - const params = this.renderConstructorParameters(node); + const signature = this.checker.getSignatureFromDeclaration(node); + if (!signature) return; + + const parameters = signature.getParameters(); + const parameterNameMap = this.buildParameterNameMap(parameters); + const params = this.renderParameters(parameters, node); const effects = this.renderEffects({ isAsync: false }); + this.emitDocComment(node, { indent: " ", parameterNameMap }); this.swiftLines.push(` @JSFunction init(${params}) ${effects}`); } diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap index 6fdb79f7..0ef4daf9 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap @@ -48,6 +48,63 @@ exports[`ts2swift > snapshots Swift output for Async.d.ts > Async 1`] = ` " `; +exports[`ts2swift > snapshots Swift output for Documentation.d.ts > Documentation 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(Experimental) @_spi(BridgeJS) import JavaScriptKit + +/// Return a greeting for a user. +/// +/// - Parameters: +/// - name: The user's name. +/// - Returns: The greeting message. +@JSFunction func greet(_ name: String) throws(JSException) -> String + +@JSFunction func origin() throws(JSException) -> Point + +/// Represents a 2D point. +@JSClass struct Point { + /// The horizontal position. + @JSGetter var x: Double + @JSSetter func setX(_ value: Double) throws(JSException) + /// Translate the point. + /// + /// - Parameters: + /// - dx: Delta to apply on x. + /// - dy: Delta to apply on y. + /// - Returns: The moved point. + @JSFunction func translate(_ dx: Double, _ dy: Double) throws(JSException) -> Point +} + +/// Move a point by the given delta. +/// +/// - Parameters: +/// - point: The point to move. +/// - dx: Delta to apply on x. +/// - dy: Delta to apply on y. +@JSFunction func translatePoint(_ point: Point, _ dx: Double, _ dy: Double) throws(JSException) -> Point + +/// A greeter that keeps the target name. +@JSClass struct Greeter { + /// Create a greeter. + /// + /// - Parameters: + /// - name: Name to greet. + @JSFunction init(_ name: String) throws(JSException) + /// The configured name. + @JSGetter var targetName: String + /// Say hello. + /// + /// - Returns: Greeting message. + @JSFunction func greet() throws(JSException) -> String +} +" +`; + exports[`ts2swift > snapshots Swift output for Interface.d.ts > Interface 1`] = ` "// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. @@ -372,3 +429,90 @@ exports[`ts2swift > snapshots Swift output for VoidParameterVoidReturn.d.ts > Vo @JSFunction func check() throws(JSException) -> Void " `; + +exports[`ts2swift > snapshots Swift output for WebIDLDOMDocs.d.ts > WebIDLDOMDocs 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(Experimental) @_spi(BridgeJS) import JavaScriptKit + +/// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document) +@JSGetter var document: Document + +/// A simple Document subset with WebIDL-derived comments. +@JSClass struct Document { + /// Creates an instance of the element for the specified tag. + /// + /// - Parameters: + /// - tagName: The name of an element. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement) + @JSFunction func createElement(_ tagName: String, _ options: JSUndefinedOr) throws(JSException) -> Element + /// Returns an element with namespace namespace. Its namespace prefix will be everything before ":" (U+003E) in qualifiedName or null. Its local name will be everything after ":" (U+003E) in qualifiedName or qualifiedName. + /// + /// If localName does not match the Name production an "InvalidCharacterError" DOMException will be thrown. + /// + /// If one of the following conditions is true a "NamespaceError" DOMException will be thrown: + /// + /// localName does not match the QName production. + /// Namespace prefix is not null and namespace is the empty string. + /// Namespace prefix is "xml" and namespace is not the XML namespace. + /// qualifiedName or namespace prefix is "xmlns" and namespace is not the XMLNS namespace. + /// namespace is the XMLNS namespace and neither qualifiedName nor namespace prefix is "xmlns". + /// + /// When supplied, options's is can be used to create a customized built-in element. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElementNS) + @JSFunction func createElementNS(_ namespaceURI: Optional, _ qualifiedName: String, _ options: JSObject) throws(JSException) -> Element + /// Creates a TreeWalker object. + /// + /// - Parameters: + /// - root: The root element or node to start traversing on. + /// - whatToShow: The type of nodes or elements to appear in the node list + /// - filter: A custom NodeFilter function to use. For more information, see filter. Use null for no filter. + /// - Returns: The created TreeWalker. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createTreeWalker) + @JSFunction func createTreeWalker(_ root: Node, _ whatToShow: JSUndefinedOr, _ filter: JSUndefinedOr) throws(JSException) -> TreeWalker +} + +@JSClass struct ElementCreationOptions { + @JSGetter var \`is\`: JSUndefinedOr + @JSSetter func setIs(_ value: JSUndefinedOr) throws(JSException) +} + +@JSClass struct Element { + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) + @JSGetter var textContent: Optional + @JSSetter func setTextContent(_ value: Optional) throws(JSException) + /// Returns a copy of node. If deep is true, the copy also includes the node's descendants. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/cloneNode) + @JSFunction func cloneNode(_ subtree: JSObject) throws(JSException) -> Node +} + +/// Minimal stubs to mirror lib.dom.d.ts documentation converted from WebIDL. +@JSClass struct Node { + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) + @JSGetter var textContent: Optional + @JSSetter func setTextContent(_ value: Optional) throws(JSException) + /// Returns a copy of node. If deep is true, the copy also includes the node's descendants. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/cloneNode) + @JSFunction func cloneNode(_ subtree: JSObject) throws(JSException) -> Node +} + +@JSClass struct TreeWalker { + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/TreeWalker/currentNode) + @JSGetter var currentNode: Node + @JSSetter func setCurrentNode(_ value: Node) throws(JSException) + /// Moves the currentNode to the next visible node in the document order. + /// + /// [MDN Reference](https://developer.mozilla.org/docs/Web/API/TreeWalker/nextNode) + @JSFunction func nextNode() throws(JSException) -> Optional +} +" +`; diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/Documentation.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/Documentation.d.ts new file mode 100644 index 00000000..90ba064b --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/Documentation.d.ts @@ -0,0 +1,56 @@ +/** + * Return a greeting for a user. + * @param name The user's name. + * @returns The greeting message. + */ +export function greet(name: string): string; + +/** + * Represents a 2D point. + */ +export interface Point { + /** + * The horizontal position. + */ + x: number; + + /** + * Translate the point. + * @param dx Delta to apply on x. + * @param dy Delta to apply on y. + * @returns The moved point. + */ + translate(dx: number, dy: number): Point; +} + +export function origin(): Point; + +/** + * Move a point by the given delta. + * @param point The point to move. + * @param dx Delta to apply on x. + * @param dy Delta to apply on y. + */ +export function translatePoint(point: Point, dx: number, dy: number): Point; + +/** + * A greeter that keeps the target name. + */ +export class Greeter { + /** + * Create a greeter. + * @param name Name to greet. + */ + constructor(name: string); + + /** + * The configured name. + */ + readonly targetName: string; + + /** + * Say hello. + * @returns Greeting message. + */ + greet(): string; +} diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/WebIDLDOMDocs.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/WebIDLDOMDocs.d.ts new file mode 100644 index 00000000..ace4011a --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/WebIDLDOMDocs.d.ts @@ -0,0 +1,81 @@ +/** + * Minimal stubs to mirror lib.dom.d.ts documentation converted from WebIDL. + */ + +interface Node { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) */ + textContent: string | null; + /** + * Returns a copy of node. If deep is true, the copy also includes the node's descendants. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/cloneNode) + */ + cloneNode(subtree?: boolean): Node; +} + +interface Element extends Node {} +type HTMLElement = Element; +type SVGElement = Element; +type MathMLElement = Element; +type DocumentFragment = Node; + +interface ElementCreationOptions { + is?: string; +} + +/** + * A simple Document subset with WebIDL-derived comments. + */ +interface Document { + /** + * Creates an instance of the element for the specified tag. + * @param tagName The name of an element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement) + */ + createElement(tagName: string, options?: ElementCreationOptions): HTMLElement; + + /** + * Returns an element with namespace namespace. Its namespace prefix will be everything before ":" (U+003E) in qualifiedName or null. Its local name will be everything after ":" (U+003E) in qualifiedName or qualifiedName. + * + * If localName does not match the Name production an "InvalidCharacterError" DOMException will be thrown. + * + * If one of the following conditions is true a "NamespaceError" DOMException will be thrown: + * + * localName does not match the QName production. + * Namespace prefix is not null and namespace is the empty string. + * Namespace prefix is "xml" and namespace is not the XML namespace. + * qualifiedName or namespace prefix is "xmlns" and namespace is not the XMLNS namespace. + * namespace is the XMLNS namespace and neither qualifiedName nor namespace prefix is "xmlns". + * + * When supplied, options's is can be used to create a customized built-in element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElementNS) + */ + createElementNS(namespaceURI: string | null, qualifiedName: string, options?: string | ElementCreationOptions): Element; + + /** + * Creates a TreeWalker object. + * @param root The root element or node to start traversing on. + * @param whatToShow The type of nodes or elements to appear in the node list + * @param filter A custom NodeFilter function to use. For more information, see filter. Use null for no filter. + * @returns The created TreeWalker. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createTreeWalker) + */ + createTreeWalker(root: Node, whatToShow?: number, filter?: (node: Node) => number | null): TreeWalker; +} + +interface TreeWalker { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/TreeWalker/currentNode) */ + currentNode: Node; + /** + * Moves the currentNode to the next visible node in the document order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TreeWalker/nextNode) + */ + nextNode(): Node | null; +} + +/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document) */ +export const document: Document;