diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift index b1a87951..2ac5506c 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift @@ -370,8 +370,11 @@ extension FFMSwift2JavaGenerator { paramDecls.append("AllocatingSwiftArena swiftArena$") } - // TODO: we could copy the Swift method's documentation over here, that'd be great UX - printDeclDocumentation(&printer, decl) + TranslatedDocumentation.printDocumentation( + importedFunc: decl, + translatedDecl: translated, + in: &printer + ) printer.printBraceBlock( """ \(annotationsStr)\(modifiers) \(returnTy) \(methodName)(\(paramDecls.joined(separator: ", "))) @@ -386,19 +389,6 @@ extension FFMSwift2JavaGenerator { } } - private func printDeclDocumentation(_ printer: inout CodePrinter, _ decl: ImportedFunc) { - printer.print( - """ - /** - * Downcall to Swift: - * {@snippet lang=swift : - * \(decl.signatureString) - * } - */ - """ - ) - } - /// Print the actual downcall to the Swift API. /// /// This assumes that all the parameters are passed-in with appropriate names. diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 28269ecd..ab030e00 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -538,7 +538,11 @@ extension JNISwift2JavaGenerator { if shouldGenerateGlobalArenaVariation { if let importedFunc { - printDeclDocumentation(&printer, importedFunc) + TranslatedDocumentation.printDocumentation( + importedFunc: importedFunc, + translatedDecl: translatedDecl, + in: &printer + ) } var modifiers = modifiers @@ -564,7 +568,11 @@ extension JNISwift2JavaGenerator { parameters.append("SwiftArena swiftArena$") } if let importedFunc { - printDeclDocumentation(&printer, importedFunc) + TranslatedDocumentation.printDocumentation( + importedFunc: importedFunc, + translatedDecl: translatedDecl, + in: &printer + ) } let signature = "\(annotationsStr)\(modifiers.joined(separator: " ")) \(resultType) \(translatedDecl.name)(\(parameters.joined(separator: ", ")))\(throwsClause)" if skipMethodBody { @@ -633,19 +641,6 @@ extension JNISwift2JavaGenerator { } } - private func printDeclDocumentation(_ printer: inout CodePrinter, _ decl: ImportedFunc) { - printer.print( - """ - /** - * Downcall to Swift: - * {@snippet lang=swift : - * \(decl.signatureString) - * } - */ - """ - ) - } - private func printTypeMetadataAddressFunction(_ printer: inout CodePrinter, _ type: ImportedNominalType) { printer.print("private static native long $typeMetadataAddressDowncall();") diff --git a/Sources/JExtractSwiftLib/SwiftDocumentationParsing.swift b/Sources/JExtractSwiftLib/SwiftDocumentationParsing.swift new file mode 100644 index 00000000..f739a728 --- /dev/null +++ b/Sources/JExtractSwiftLib/SwiftDocumentationParsing.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftSyntax + +struct SwiftDocumentation: Equatable { + struct Parameter: Equatable { + var name: String + var description: String + } + + var summary: String? + var discussion: String? + var parameters: [Parameter] = [] + var returns: String? +} + +enum SwiftDocumentationParser { + private enum State { + case summary + case discussion + case parameter(Int) + case returns + } + + // TODO: Replace with Regex + // Capture Groups: 1=Tag, 2=Arg(Optional), 3=Description + private static let tagRegex = try! NSRegularExpression(pattern: "^-\\s*(\\w+)(?:\\s+([^:]+))?\\s*:\\s*(.*)$") + + static func parse(_ syntax: some SyntaxProtocol) -> SwiftDocumentation? { + // We must have at least one docline and newline, for this to be valid + guard syntax.leadingTrivia.count >= 2 else { return nil } + + var comments = [String]() + var pieces = syntax.leadingTrivia.pieces + + // We always expect a newline follows a docline comment + while case .newlines(1) = pieces.popLast(), case .docLineComment(let text) = pieces.popLast() { + comments.append(text) + } + + guard !comments.isEmpty else { return nil } + + return parse(comments.reversed()) + } + + private static func parse(_ doclines: [String]) -> SwiftDocumentation? { + var doc = SwiftDocumentation() + var state: State = .summary + + let lines = doclines.map { line -> String in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("///") ? String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) : trimmed + } + + // If no lines or all empty, we don't have any documentation. + if lines.isEmpty || lines.allSatisfy(\.isEmpty) { + return nil + } + + for line in lines { + if line.starts(with: "-"), let (tag, arg, content) = Self.parseTagHeader(line) { + switch tag.lowercased() { + case "parameter": + guard let arg else { continue } + doc.parameters.append( + SwiftDocumentation.Parameter( + name: arg, + description: content + ) + ) + state = .parameter(doc.parameters.count > 0 ? doc.parameters.count : 0) + + case "parameters": + state = .parameter(0) + + case "returns": + doc.returns = content + state = .returns + + default: + // Parameter names are marked like + // - myString: description + if case .parameter = state { + state = .parameter(doc.parameters.count > 0 ? doc.parameters.count : 0) + + doc.parameters.append( + SwiftDocumentation.Parameter( + name: tag, + description: content + ) + ) + } else { + state = .discussion + append(&doc.discussion, line) + } + } + } else if line.isEmpty { + // Any blank lines will move us to discussion + state = .discussion + if let discussion = doc.discussion, !discussion.isEmpty { + if !discussion.hasSuffix("\n") { + doc.discussion?.append("\n") + } + } + } else { + appendLineToState(state, line: line, doc: &doc) + } + } + + // Remove any trailing newlines in discussion + while doc.discussion?.last == "\n" { + doc.discussion?.removeLast() + } + + return doc + } + + private static func appendLineToState(_ state: State, line: String, doc: inout SwiftDocumentation) { + switch state { + case .summary: append(&doc.summary, line) + case .discussion: append(&doc.discussion, line) + case .returns: append(&doc.returns, line) + case .parameter(let index): + if index < doc.parameters.count { + append(&doc.parameters[index].description, line) + } + } + } + + private static func append(_ existing: inout String, _ new: String) { + existing += "\n" + new + } + + private static func append(_ existing: inout String?, _ new: String) { + if existing == nil { existing = new } + else { + existing! += "\n" + new + } + } + + private static func parseTagHeader(_ line: String) -> (type: String, arg: String?, description: String)? { + let range = NSRange(location: 0, length: line.utf16.count) + guard let match = Self.tagRegex.firstMatch(in: line, options: [], range: range) else { return nil } + + // Group 1: Tag Name + guard let typeRange = Range(match.range(at: 1), in: line) else { return nil } + let type = String(line[typeRange]) + + // Group 2: Argument (Optional) + var arg: String? = nil + let argRangeNs = match.range(at: 2) + if argRangeNs.location != NSNotFound, let argRange = Range(argRangeNs, in: line) { + arg = String(line[argRange]) + } + + // Group 3: Description (Always present, potentially empty) + guard let descRange = Range(match.range(at: 3), in: line) else { return nil } + let description = String(line[descRange]) + + return (type, arg, description) + } +} diff --git a/Sources/JExtractSwiftLib/TranslatedDocumentation.swift b/Sources/JExtractSwiftLib/TranslatedDocumentation.swift new file mode 100644 index 00000000..85f7bddf --- /dev/null +++ b/Sources/JExtractSwiftLib/TranslatedDocumentation.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +enum TranslatedDocumentation { + static func printDocumentation( + importedFunc: ImportedFunc, + translatedDecl: FFMSwift2JavaGenerator.TranslatedFunctionDecl, + in printer: inout CodePrinter + ) { + var documentation = SwiftDocumentationParser.parse(importedFunc.swiftDecl) + + if translatedDecl.translatedSignature.requiresSwiftArena { + documentation?.parameters.append( + SwiftDocumentation.Parameter( + name: "swiftArena$", + description: "the arena that will manage the lifetime and allocation of Swift objects" + ) + ) + } + + printDocumentation(documentation, syntax: importedFunc.swiftDecl, in: &printer) + } + + static func printDocumentation( + importedFunc: ImportedFunc, + translatedDecl: JNISwift2JavaGenerator.TranslatedFunctionDecl, + in printer: inout CodePrinter + ) { + var documentation = SwiftDocumentationParser.parse(importedFunc.swiftDecl) + + if translatedDecl.translatedFunctionSignature.requiresSwiftArena { + documentation?.parameters.append( + SwiftDocumentation.Parameter( + name: "swiftArena$", + description: "the arena that the the returned object will be attached to" + ) + ) + } + + printDocumentation(documentation, syntax: importedFunc.swiftDecl, in: &printer) + } + + private static func printDocumentation( + _ parsedDocumentation: SwiftDocumentation?, + syntax: some DeclSyntaxProtocol, + in printer: inout CodePrinter + ) { + var groups = [String]() + if let summary = parsedDocumentation?.summary { + groups.append("\(summary)") + } + + if let discussion = parsedDocumentation?.discussion { + let paragraphs = discussion.split(separator: "\n\n") + for paragraph in paragraphs { + groups.append("
\(paragraph)") + } + } + + groups.append( + """ + \(parsedDocumentation != nil ? "
" : "")Downcall to Swift: + {@snippet lang=swift : + \(syntax.signatureString) + } + """ + ) + + var annotationsGroup = [String]() + + for param in parsedDocumentation?.parameters ?? [] { + annotationsGroup.append("@param \(param.name) \(param.description)") + } + + if let returns = parsedDocumentation?.returns { + annotationsGroup.append("@return \(returns)") + } + + if !annotationsGroup.isEmpty { + groups.append(annotationsGroup.joined(separator: "\n")) + } + + printer.print("/**") + let oldIdentationText = printer.indentationText + printer.indentationText += " * " + for (idx, group) in groups.enumerated() { + printer.print(group) + if idx < groups.count - 1 { + printer.print("") + } + } + printer.indentationText = oldIdentationText + printer.print(" */") + + } +} diff --git a/Tests/JExtractSwiftTests/MethodImportTests.swift b/Tests/JExtractSwiftTests/MethodImportTests.swift index 6ba93016..9fed3fc3 100644 --- a/Tests/JExtractSwiftTests/MethodImportTests.swift +++ b/Tests/JExtractSwiftTests/MethodImportTests.swift @@ -91,7 +91,9 @@ final class MethodImportTests { expected: """ /** - * Downcall to Swift: + * Hello World! + * + *
Downcall to Swift: * {@snippet lang=swift : * public func helloWorld() * } diff --git a/Tests/JExtractSwiftTests/SwiftDocumentationParsingTests.swift b/Tests/JExtractSwiftTests/SwiftDocumentationParsingTests.swift new file mode 100644 index 00000000..91c1ae04 --- /dev/null +++ b/Tests/JExtractSwiftTests/SwiftDocumentationParsingTests.swift @@ -0,0 +1,516 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +struct SwiftDocumentationParsingTests { + @Test( + "Simple Swift func documentation", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f() + * } + */ + public static void f() { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f() + * } + */ + public static void f() { + """ + ] + ) + ] + ) + func simple(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// Simple summary + public func f() {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Swift file with lots of newlines", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Downcall to Swift: + * {@snippet lang=swift : + * public func f() + * } + */ + public static void f() { + """, + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func g() + * } + */ + public static void g() { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Downcall to Swift: + * {@snippet lang=swift : + * public func f() + * } + */ + public static void f() { + """, + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func g() + * } + */ + public static void g() { + """ + ] + ) + ] + ) + func swiftFileWithNewlines(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// Random comment + + + + + public func f() {} + + /// Random comment 2 + + /// Simple summary + public func g() {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Swift arena parameter", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f() -> MyClass + * } + * + * @param swiftArena$ the arena that the the returned object will be attached to + */ + public static MyClass f(SwiftArena swiftArena$) { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Simple summary + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f() -> MyClass + * } + * + * @param swiftArena$ the arena that will manage the lifetime and allocation of Swift objects + */ + public static MyClass f(AllocatingSwiftArena swiftArena$) + """ + ] + ) + ] + ) + func swiftArenaParam(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + public class MyClass {} + + /// Simple summary + public func f() -> MyClass {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Full Swift func docs with individual params", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Simple summary + * + *
Some information about this function + * that will span multiple lines + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * @param arg1 Description about arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Simple summary + * + *
Some information about this function + * that will span multiple lines + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * @param arg1 Description about arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ) + ] + ) + func full_individualParams(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// Simple summary + /// + /// Some information about this function + /// that will span multiple lines + /// + /// - Parameter arg0: Description about arg0 + /// - Parameter arg1: Description about arg1 + /// + /// - Returns: return value + public func f(arg0: String, arg1: String) {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Full Swift func docs with grouped params", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Simple summary + * + *
Some information about this function + * that will span multiple lines + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * @param arg1 Description about arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Simple summary + * + *
Some information about this function + * that will span multiple lines + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * @param arg1 Description about arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ) + ] + ) + func full_groupedParams(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// Simple summary + /// + /// Some information about this function + /// that will span multiple lines + /// + /// - Parameters: + /// - arg0: Description about arg0 + /// - arg1: Description about arg1 + /// + /// - Returns: return value + public func f(arg0: String, arg1: String) {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Complex Swift func docs", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + * Simple summary, that we have broken + * across multiple lines + * + *
Some information about this function + * that will span multiple lines + * + *
Some more disucssion... + * + *
And more... + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * that spans multiple lines + * @param arg1 Description about arg1 + * that spans multiple lines + * and even more? + * @return return value + * across multiple lines + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + * Simple summary, that we have broken + * across multiple lines + * + *
Some information about this function + * that will span multiple lines + * + *
Some more disucssion... + * + *
And more... + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 Description about arg0 + * that spans multiple lines + * @param arg1 Description about arg1 + * that spans multiple lines + * and even more? + * @return return value + * across multiple lines + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ) + ] + ) + func complex(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// Simple summary, that we have broken + /// across multiple lines + /// + /// Some information about this function + /// that will span multiple lines + /// + /// Some more disucssion... + /// + /// - Parameters: + /// - arg0: Description about arg0 + /// that spans multiple lines + /// - arg1: Description about arg1 + /// that spans multiple lines + /// and even more? + /// + /// And more... + /// + /// - Returns: return value + /// across multiple lines + public func f(arg0: String, arg1: String) {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } + + @Test( + "Random order docs", + arguments: [ + ( + JExtractGenerationMode.jni, + [ + """ + /** + *
Discussion? + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 this is arg0 + * @param arg1 this is arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ), + ( + JExtractGenerationMode.ffm, + [ + """ + /** + *
Discussion? + * + *
Downcall to Swift: + * {@snippet lang=swift : + * public func f(arg0: String, arg1: String) + * } + * + * @param arg0 this is arg0 + * @param arg1 this is arg1 + * @return return value + */ + public static void f(java.lang.String arg0, java.lang.String arg1) { + """ + ] + ) + ] + ) + func randomOrder(mode: JExtractGenerationMode, expectedJavaChunks: [String]) throws { + let text = + """ + /// - Parameter arg0: this is arg0 + /// - Returns: return value + /// - Parameter arg1: this is arg1 + /// + /// Discussion? + public func f(arg0: String, arg1: String) {} + """ + + try assertOutput( + input: text, + mode, .java, + expectedChunks: expectedJavaChunks + ) + } +}