Skip to content

Commit cfb4b7b

Browse files
authored
jextract: extract swift func docs as Java docs (#497)
1 parent 12e2eeb commit cfb4b7b

File tree

6 files changed

+818
-31
lines changed

6 files changed

+818
-31
lines changed

Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,11 @@ extension FFMSwift2JavaGenerator {
370370
paramDecls.append("AllocatingSwiftArena swiftArena$")
371371
}
372372

373-
// TODO: we could copy the Swift method's documentation over here, that'd be great UX
374-
printDeclDocumentation(&printer, decl)
373+
TranslatedDocumentation.printDocumentation(
374+
importedFunc: decl,
375+
translatedDecl: translated,
376+
in: &printer
377+
)
375378
printer.printBraceBlock(
376379
"""
377380
\(annotationsStr)\(modifiers) \(returnTy) \(methodName)(\(paramDecls.joined(separator: ", ")))
@@ -386,19 +389,6 @@ extension FFMSwift2JavaGenerator {
386389
}
387390
}
388391

389-
private func printDeclDocumentation(_ printer: inout CodePrinter, _ decl: ImportedFunc) {
390-
printer.print(
391-
"""
392-
/**
393-
* Downcall to Swift:
394-
* {@snippet lang=swift :
395-
* \(decl.signatureString)
396-
* }
397-
*/
398-
"""
399-
)
400-
}
401-
402392
/// Print the actual downcall to the Swift API.
403393
///
404394
/// This assumes that all the parameters are passed-in with appropriate names.

Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,11 @@ extension JNISwift2JavaGenerator {
538538

539539
if shouldGenerateGlobalArenaVariation {
540540
if let importedFunc {
541-
printDeclDocumentation(&printer, importedFunc)
541+
TranslatedDocumentation.printDocumentation(
542+
importedFunc: importedFunc,
543+
translatedDecl: translatedDecl,
544+
in: &printer
545+
)
542546
}
543547
var modifiers = modifiers
544548

@@ -564,7 +568,11 @@ extension JNISwift2JavaGenerator {
564568
parameters.append("SwiftArena swiftArena$")
565569
}
566570
if let importedFunc {
567-
printDeclDocumentation(&printer, importedFunc)
571+
TranslatedDocumentation.printDocumentation(
572+
importedFunc: importedFunc,
573+
translatedDecl: translatedDecl,
574+
in: &printer
575+
)
568576
}
569577
let signature = "\(annotationsStr)\(modifiers.joined(separator: " ")) \(resultType) \(translatedDecl.name)(\(parameters.joined(separator: ", ")))\(throwsClause)"
570578
if skipMethodBody {
@@ -633,19 +641,6 @@ extension JNISwift2JavaGenerator {
633641
}
634642
}
635643

636-
private func printDeclDocumentation(_ printer: inout CodePrinter, _ decl: ImportedFunc) {
637-
printer.print(
638-
"""
639-
/**
640-
* Downcall to Swift:
641-
* {@snippet lang=swift :
642-
* \(decl.signatureString)
643-
* }
644-
*/
645-
"""
646-
)
647-
}
648-
649644
private func printTypeMetadataAddressFunction(_ printer: inout CodePrinter, _ type: ImportedNominalType) {
650645
printer.print("private static native long $typeMetadataAddressDowncall();")
651646

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import SwiftSyntax
17+
18+
struct SwiftDocumentation: Equatable {
19+
struct Parameter: Equatable {
20+
var name: String
21+
var description: String
22+
}
23+
24+
var summary: String?
25+
var discussion: String?
26+
var parameters: [Parameter] = []
27+
var returns: String?
28+
}
29+
30+
enum SwiftDocumentationParser {
31+
private enum State {
32+
case summary
33+
case discussion
34+
case parameter(Int)
35+
case returns
36+
}
37+
38+
// TODO: Replace with Regex
39+
// Capture Groups: 1=Tag, 2=Arg(Optional), 3=Description
40+
private static let tagRegex = try! NSRegularExpression(pattern: "^-\\s*(\\w+)(?:\\s+([^:]+))?\\s*:\\s*(.*)$")
41+
42+
static func parse(_ syntax: some SyntaxProtocol) -> SwiftDocumentation? {
43+
// We must have at least one docline and newline, for this to be valid
44+
guard syntax.leadingTrivia.count >= 2 else { return nil }
45+
46+
var comments = [String]()
47+
var pieces = syntax.leadingTrivia.pieces
48+
49+
// We always expect a newline follows a docline comment
50+
while case .newlines(1) = pieces.popLast(), case .docLineComment(let text) = pieces.popLast() {
51+
comments.append(text)
52+
}
53+
54+
guard !comments.isEmpty else { return nil }
55+
56+
return parse(comments.reversed())
57+
}
58+
59+
private static func parse(_ doclines: [String]) -> SwiftDocumentation? {
60+
var doc = SwiftDocumentation()
61+
var state: State = .summary
62+
63+
let lines = doclines.map { line -> String in
64+
let trimmed = line.trimmingCharacters(in: .whitespaces)
65+
return trimmed.hasPrefix("///") ? String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) : trimmed
66+
}
67+
68+
// If no lines or all empty, we don't have any documentation.
69+
if lines.isEmpty || lines.allSatisfy(\.isEmpty) {
70+
return nil
71+
}
72+
73+
for line in lines {
74+
if line.starts(with: "-"), let (tag, arg, content) = Self.parseTagHeader(line) {
75+
switch tag.lowercased() {
76+
case "parameter":
77+
guard let arg else { continue }
78+
doc.parameters.append(
79+
SwiftDocumentation.Parameter(
80+
name: arg,
81+
description: content
82+
)
83+
)
84+
state = .parameter(doc.parameters.count > 0 ? doc.parameters.count : 0)
85+
86+
case "parameters":
87+
state = .parameter(0)
88+
89+
case "returns":
90+
doc.returns = content
91+
state = .returns
92+
93+
default:
94+
// Parameter names are marked like
95+
// - myString: description
96+
if case .parameter = state {
97+
state = .parameter(doc.parameters.count > 0 ? doc.parameters.count : 0)
98+
99+
doc.parameters.append(
100+
SwiftDocumentation.Parameter(
101+
name: tag,
102+
description: content
103+
)
104+
)
105+
} else {
106+
state = .discussion
107+
append(&doc.discussion, line)
108+
}
109+
}
110+
} else if line.isEmpty {
111+
// Any blank lines will move us to discussion
112+
state = .discussion
113+
if let discussion = doc.discussion, !discussion.isEmpty {
114+
if !discussion.hasSuffix("\n") {
115+
doc.discussion?.append("\n")
116+
}
117+
}
118+
} else {
119+
appendLineToState(state, line: line, doc: &doc)
120+
}
121+
}
122+
123+
// Remove any trailing newlines in discussion
124+
while doc.discussion?.last == "\n" {
125+
doc.discussion?.removeLast()
126+
}
127+
128+
return doc
129+
}
130+
131+
private static func appendLineToState(_ state: State, line: String, doc: inout SwiftDocumentation) {
132+
switch state {
133+
case .summary: append(&doc.summary, line)
134+
case .discussion: append(&doc.discussion, line)
135+
case .returns: append(&doc.returns, line)
136+
case .parameter(let index):
137+
if index < doc.parameters.count {
138+
append(&doc.parameters[index].description, line)
139+
}
140+
}
141+
}
142+
143+
private static func append(_ existing: inout String, _ new: String) {
144+
existing += "\n" + new
145+
}
146+
147+
private static func append(_ existing: inout String?, _ new: String) {
148+
if existing == nil { existing = new }
149+
else {
150+
existing! += "\n" + new
151+
}
152+
}
153+
154+
private static func parseTagHeader(_ line: String) -> (type: String, arg: String?, description: String)? {
155+
let range = NSRange(location: 0, length: line.utf16.count)
156+
guard let match = Self.tagRegex.firstMatch(in: line, options: [], range: range) else { return nil }
157+
158+
// Group 1: Tag Name
159+
guard let typeRange = Range(match.range(at: 1), in: line) else { return nil }
160+
let type = String(line[typeRange])
161+
162+
// Group 2: Argument (Optional)
163+
var arg: String? = nil
164+
let argRangeNs = match.range(at: 2)
165+
if argRangeNs.location != NSNotFound, let argRange = Range(argRangeNs, in: line) {
166+
arg = String(line[argRange])
167+
}
168+
169+
// Group 3: Description (Always present, potentially empty)
170+
guard let descRange = Range(match.range(at: 3), in: line) else { return nil }
171+
let description = String(line[descRange])
172+
173+
return (type, arg, description)
174+
}
175+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import SwiftSyntax
16+
17+
enum TranslatedDocumentation {
18+
static func printDocumentation(
19+
importedFunc: ImportedFunc,
20+
translatedDecl: FFMSwift2JavaGenerator.TranslatedFunctionDecl,
21+
in printer: inout CodePrinter
22+
) {
23+
var documentation = SwiftDocumentationParser.parse(importedFunc.swiftDecl)
24+
25+
if translatedDecl.translatedSignature.requiresSwiftArena {
26+
documentation?.parameters.append(
27+
SwiftDocumentation.Parameter(
28+
name: "swiftArena$",
29+
description: "the arena that will manage the lifetime and allocation of Swift objects"
30+
)
31+
)
32+
}
33+
34+
printDocumentation(documentation, syntax: importedFunc.swiftDecl, in: &printer)
35+
}
36+
37+
static func printDocumentation(
38+
importedFunc: ImportedFunc,
39+
translatedDecl: JNISwift2JavaGenerator.TranslatedFunctionDecl,
40+
in printer: inout CodePrinter
41+
) {
42+
var documentation = SwiftDocumentationParser.parse(importedFunc.swiftDecl)
43+
44+
if translatedDecl.translatedFunctionSignature.requiresSwiftArena {
45+
documentation?.parameters.append(
46+
SwiftDocumentation.Parameter(
47+
name: "swiftArena$",
48+
description: "the arena that the the returned object will be attached to"
49+
)
50+
)
51+
}
52+
53+
printDocumentation(documentation, syntax: importedFunc.swiftDecl, in: &printer)
54+
}
55+
56+
private static func printDocumentation(
57+
_ parsedDocumentation: SwiftDocumentation?,
58+
syntax: some DeclSyntaxProtocol,
59+
in printer: inout CodePrinter
60+
) {
61+
var groups = [String]()
62+
if let summary = parsedDocumentation?.summary {
63+
groups.append("\(summary)")
64+
}
65+
66+
if let discussion = parsedDocumentation?.discussion {
67+
let paragraphs = discussion.split(separator: "\n\n")
68+
for paragraph in paragraphs {
69+
groups.append("<p>\(paragraph)")
70+
}
71+
}
72+
73+
groups.append(
74+
"""
75+
\(parsedDocumentation != nil ? "<p>" : "")Downcall to Swift:
76+
{@snippet lang=swift :
77+
\(syntax.signatureString)
78+
}
79+
"""
80+
)
81+
82+
var annotationsGroup = [String]()
83+
84+
for param in parsedDocumentation?.parameters ?? [] {
85+
annotationsGroup.append("@param \(param.name) \(param.description)")
86+
}
87+
88+
if let returns = parsedDocumentation?.returns {
89+
annotationsGroup.append("@return \(returns)")
90+
}
91+
92+
if !annotationsGroup.isEmpty {
93+
groups.append(annotationsGroup.joined(separator: "\n"))
94+
}
95+
96+
printer.print("/**")
97+
let oldIdentationText = printer.indentationText
98+
printer.indentationText += " * "
99+
for (idx, group) in groups.enumerated() {
100+
printer.print(group)
101+
if idx < groups.count - 1 {
102+
printer.print("")
103+
}
104+
}
105+
printer.indentationText = oldIdentationText
106+
printer.print(" */")
107+
108+
}
109+
}

Tests/JExtractSwiftTests/MethodImportTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ final class MethodImportTests {
9191
expected:
9292
"""
9393
/**
94-
* Downcall to Swift:
94+
* Hello World!
95+
*
96+
* <p>Downcall to Swift:
9597
* {@snippet lang=swift :
9698
* public func helloWorld()
9799
* }

0 commit comments

Comments
 (0)