Skip to content

BridgeJS: Macro extension to define namespace #405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,21 @@ class ExportSwift {
}

private func visitFunction(node: FunctionDeclSyntax) -> ExportedFunction? {
guard node.attributes.hasJSAttribute() else {
guard let jsAttribute = node.attributes.firstJSAttribute else {
return nil
}

let name = node.name.text
let namespace = extractNamespace(from: jsAttribute)

if namespace != nil, case .classBody = state {
diagnose(
node: jsAttribute,
message: "Namespace is only needed in top-level declaration",
hint: "Remove the namespace from @JS attribute or move this function to top-level"
)
}

var parameters: [Parameter] = []
for param in node.signature.parameterClause.parameters {
guard let type = self.parent.lookupType(for: param.type) else {
Expand Down Expand Up @@ -165,7 +176,8 @@ class ExportSwift {
abiName: abiName,
parameters: parameters,
returnType: returnType,
effects: effects
effects: effects,
namespace: namespace
)
}

Expand Down Expand Up @@ -193,12 +205,40 @@ class ExportSwift {
return Effects(isAsync: isAsync, isThrows: isThrows)
}

private func extractNamespace(
from jsAttribute: AttributeSyntax
) -> [String]? {
guard let arguments = jsAttribute.arguments?.as(LabeledExprListSyntax.self) else {
return nil
}

guard let namespaceArg = arguments.first(where: { $0.label?.text == "namespace" }),
let stringLiteral = namespaceArg.expression.as(StringLiteralExprSyntax.self),
let namespaceString = stringLiteral.segments.first?.as(StringSegmentSyntax.self)?.content.text
else {
return nil
}

return namespaceString.split(separator: ".").map(String.init)
}

override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
guard node.attributes.hasJSAttribute() else { return .skipChildren }
guard case .classBody(let name) = state else {
diagnose(node: node, message: "@JS init must be inside a @JS class")
return .skipChildren
}

if let jsAttribute = node.attributes.firstJSAttribute,
extractNamespace(from: jsAttribute) != nil
{
diagnose(
node: jsAttribute,
message: "Namespace is not supported for initializer declarations",
hint: "Remove the namespace from @JS attribute"
)
}

var parameters: [Parameter] = []
for param in node.signature.parameterClause.parameters {
guard let type = self.parent.lookupType(for: param.type) else {
Expand All @@ -225,13 +265,17 @@ class ExportSwift {

override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
let name = node.name.text

stateStack.push(state: .classBody(name: name))

guard node.attributes.hasJSAttribute() else { return .skipChildren }
guard let jsAttribute = node.attributes.firstJSAttribute else { return .skipChildren }

let namespace = extractNamespace(from: jsAttribute)
exportedClassByName[name] = ExportedClass(
name: name,
constructor: nil,
methods: []
methods: [],
namespace: namespace
)
exportedClassNames.append(name)
return .visitChildren
Expand Down Expand Up @@ -635,9 +679,13 @@ class ExportSwift {

extension AttributeListSyntax {
fileprivate func hasJSAttribute() -> Bool {
return first(where: {
firstJSAttribute != nil
}

fileprivate var firstJSAttribute: AttributeSyntax? {
first(where: {
$0.as(AttributeSyntax.self)?.attributeName.trimmedDescription == "JS"
}) != nil
})?.as(AttributeSyntax.self)
}
}

Expand Down
192 changes: 187 additions & 5 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ struct BridgeJSLink {
var classLines: [String] = []
var dtsExportLines: [String] = []
var dtsClassLines: [String] = []
var namespacedFunctions: [ExportedFunction] = []
var namespacedClasses: [ExportedClass] = []

if exportedSkeletons.contains(where: { $0.classes.count > 0 }) {
classLines.append(
Expand All @@ -83,10 +85,19 @@ struct BridgeJSLink {
exportsLines.append("\(klass.name),")
dtsExportLines.append(contentsOf: dtsExportEntry)
dtsClassLines.append(contentsOf: dtsType)

if klass.namespace != nil {
namespacedClasses.append(klass)
}
}

for function in skeleton.functions {
var (js, dts) = renderExportedFunction(function: function)

if function.namespace != nil {
namespacedFunctions.append(function)
}

js[0] = "\(function.name): " + js[0]
js[js.count - 1] += ","
exportsLines.append(contentsOf: js)
Expand All @@ -108,6 +119,36 @@ struct BridgeJSLink {
importObjectBuilders.append(importObjectBuilder)
}

let hasNamespacedItems = !namespacedFunctions.isEmpty || !namespacedClasses.isEmpty

let exportsSection: String
if hasNamespacedItems {
let namespaceSetupCode = renderGlobalNamespace(
namespacedFunctions: namespacedFunctions,
namespacedClasses: namespacedClasses
)
.map { $0.indent(count: 12) }.joined(separator: "\n")
exportsSection = """
\(classLines.map { $0.indent(count: 12) }.joined(separator: "\n"))
const exports = {
\(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n"))
};

\(namespaceSetupCode)

return exports;
},
"""
} else {
exportsSection = """
\(classLines.map { $0.indent(count: 12) }.joined(separator: "\n"))
return {
\(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n"))
};
},
"""
}

let outputJs = """
// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
// DO NOT EDIT.
Expand Down Expand Up @@ -169,15 +210,13 @@ struct BridgeJSLink {
/** @param {WebAssembly.Instance} instance */
createExports: (instance) => {
const js = swift.memory.heap;
\(classLines.map { $0.indent(count: 12) }.joined(separator: "\n"))
return {
\(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n"))
};
},
\(exportsSection)
}
}
"""

var dtsLines: [String] = []
dtsLines.append(contentsOf: namespaceDeclarations())
dtsLines.append(contentsOf: dtsClassLines)
dtsLines.append("export type Exports = {")
dtsLines.append(contentsOf: dtsExportLines.map { $0.indent(count: 4) })
Expand All @@ -204,6 +243,102 @@ struct BridgeJSLink {
return (outputJs, outputDts)
}

private func namespaceDeclarations() -> [String] {
var dtsLines: [String] = []
var namespaceFunctions: [String: [ExportedFunction]] = [:]
var namespaceClasses: [String: [ExportedClass]] = [:]

for skeleton in exportedSkeletons {
for function in skeleton.functions {
if let namespace = function.namespace {
let namespaceKey = namespace.joined(separator: ".")
if namespaceFunctions[namespaceKey] == nil {
namespaceFunctions[namespaceKey] = []
}
namespaceFunctions[namespaceKey]?.append(function)
}
}

for klass in skeleton.classes {
if let classNamespace = klass.namespace {
let namespaceKey = classNamespace.joined(separator: ".")
if namespaceClasses[namespaceKey] == nil {
namespaceClasses[namespaceKey] = []
}
namespaceClasses[namespaceKey]?.append(klass)
}
}
}

guard !namespaceFunctions.isEmpty || !namespaceClasses.isEmpty else { return dtsLines }

dtsLines.append("export {};")
dtsLines.append("")
dtsLines.append("declare global {")

let identBaseSize = 4

for (namespacePath, classes) in namespaceClasses.sorted(by: { $0.key < $1.key }) {
let parts = namespacePath.split(separator: ".").map(String.init)

for i in 0..<parts.count {
dtsLines.append("namespace \(parts[i]) {".indent(count: identBaseSize * (i + 1)))
}

for klass in classes {
dtsLines.append("class \(klass.name) {".indent(count: identBaseSize * (parts.count + 1)))

if let constructor = klass.constructor {
let constructorSignature =
"constructor(\(constructor.parameters.map { "\($0.name): \($0.type.tsType)" }.joined(separator: ", ")));"
dtsLines.append("\(constructorSignature)".indent(count: identBaseSize * (parts.count + 2)))
}

for method in klass.methods {
let methodSignature =
"\(method.name)\(renderTSSignature(parameters: method.parameters, returnType: method.returnType));"
dtsLines.append("\(methodSignature)".indent(count: identBaseSize * (parts.count + 2)))
}

dtsLines.append("}".indent(count: identBaseSize * (parts.count + 1)))
}

for i in (0..<parts.count).reversed() {
dtsLines.append("}".indent(count: identBaseSize * (i + 1)))
}
}

for (namespacePath, functions) in namespaceFunctions.sorted(by: { $0.key < $1.key }) {
let parts = namespacePath.split(separator: ".").map(String.init)

var namespaceExists = false
if namespaceClasses[namespacePath] != nil {
namespaceExists = true
} else {
for i in 0..<parts.count {
dtsLines.append("namespace \(parts[i]) {".indent(count: identBaseSize * (i + 1)))
}
}

for function in functions {
let signature =
"function \(function.name)\(renderTSSignature(parameters: function.parameters, returnType: function.returnType));"
dtsLines.append("\(signature)".indent(count: identBaseSize * (parts.count + 1)))
}

if !namespaceExists {
for i in (0..<parts.count).reversed() {
dtsLines.append("}".indent(count: identBaseSize * (i + 1)))
}
}
}

dtsLines.append("}")
dtsLines.append("")

return dtsLines
}

class ExportedThunkBuilder {
var bodyLines: [String] = []
var cleanupLines: [String] = []
Expand Down Expand Up @@ -396,6 +531,53 @@ struct BridgeJSLink {
return (jsLines, dtsTypeLines, dtsExportEntryLines)
}

func renderGlobalNamespace(namespacedFunctions: [ExportedFunction], namespacedClasses: [ExportedClass]) -> [String]
{
var lines: [String] = []
var uniqueNamespaces: [String] = []
var seen = Set<String>()

let functionNamespacePaths: Set<[String]> = Set(
namespacedFunctions
.compactMap { $0.namespace }
)
let classNamespacePaths: Set<[String]> = Set(
namespacedClasses
.compactMap { $0.namespace }
)

let allNamespacePaths =
functionNamespacePaths
.union(classNamespacePaths)

allNamespacePaths.forEach { namespacePath in
namespacePath.makeIterator().enumerated().forEach { (index, _) in
let path = namespacePath[0...index].joined(separator: ".")
if seen.insert(path).inserted {
uniqueNamespaces.append(path)
}
}
}

uniqueNamespaces.sorted().forEach { namespace in
lines.append("if (typeof globalThis.\(namespace) === 'undefined') {")
lines.append(" globalThis.\(namespace) = {};")
lines.append("}")
}

namespacedClasses.forEach { klass in
let namespacePath: String = klass.namespace?.joined(separator: ".") ?? ""
lines.append("globalThis.\(namespacePath).\(klass.name) = exports.\(klass.name);")
}

namespacedFunctions.forEach { function in
let namespacePath: String = function.namespace?.joined(separator: ".") ?? ""
lines.append("globalThis.\(namespacePath).\(function.name) = exports.\(function.name);")
}

return lines
}

class ImportedThunkBuilder {
var bodyLines: [String] = []
var parameterNames: [String] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,21 @@ struct ExportedFunction: Codable {
var parameters: [Parameter]
var returnType: BridgeType
var effects: Effects
var namespace: [String]?
}

struct ExportedClass: Codable {
var name: String
var constructor: ExportedConstructor?
var methods: [ExportedFunction]
var namespace: [String]?
}

struct ExportedConstructor: Codable {
var abiName: String
var parameters: [Parameter]
var effects: Effects
var namespace: [String]?
}

struct ExportedSkeleton: Codable {
Expand Down
Loading
Loading