Generate symlinks within a SPM package of objective-c code using build plugin

I have a SPM package of objective-c code. In order to expose its public interfaces I need to generate symlinks within include folder.

I noticed that when I include the generated symlinks within the repo of package it gives error as the symlinks generated hold incorrect references. So in order to make it working once the source code is pulled I run a bash script to generate the symlinks:

# deletes all files within include folder
rm -r Sources/include/*

# generates symlinks of all the .h files within $currentPackage into include folder
find `pwd`/Sources/$currentPackage -name '*.h' -exec ln -s "{}" `pwd`/Sources/include ';'

Now for the above purpose I would like to use the Swift Package Manager build plugin.

My questions are:

  1. Is this the right approach?
  2. Is it possible to execute symlinks generation script using swift code?

Symlinks can be relative or absolute, following the spelling they were created with; if you make yours relative, they won’t need a build script.

1 Like

Thanks @jrose !

I wanted to still give my original post a try as an exercise of build plugin. So I ended up creating following BuildToolPlugin:

import Foundation
import PackagePlugin

@main
struct SymlinkGenerationPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let outputDirectoryPath = context.package.directory.appending("Sources").appending("include")
        let outputDirectory = URL(fileURLWithPath: outputDirectoryPath.string)

        let fileManager = FileManager.default
        try? fileManager.removeItem(at: outputDirectory)
        try? fileManager.createDirectory(atPath: outputDirectoryPath.string, withItermediateDirectories: false)

        let inputDirectoryPath = context.package.directory.appending("Sources").appending("ObjCPackage")

        return [.buildCommand(displayName: "Generating Symlinks", executable: try context.tool(named: "SymlinkGEneration").path, arguments: [inputDirectoryPath, outputDirectoryPath], environment: [:])]
    }
}

Here is the code for SymlinkGeneration:

@main
@available(macOS 13.0.0, *)
struct SymlinkGeneration {
    static func main() async throws {
        guard CommandLine.arguments.count == 3 else {
            throw SymlinkGenerationError.invalidArguments
        }
        
        let input = URL(filePath: CommandLine.arguments[1])
        let output = URL(filePath: CommandLine.arguments[2])
        
        let fileManager = FileManager.default
        
        if let enumerator = fileManager.enumerator(at: input, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) {
            for case let fileURL as URL in enumerator {
                let fileName = fileURL.lastPathComponent
                let inputFilePath = fileURL.appending(path: fileName)
                let outputFilePath = output.appending(path: fileName)
                try? fileManager.createSymbolicLink(at: outputFilePath, withDestinationURL: inputFilePath)
            }
        }
    }
}

enum SymlinkGenerationError: Error {
    case invalidArguments
    case invalidData
}

Here is my package folder structure:

ObjCPackage:
   |- Package.swift
   |- Plugin:
          |- SymlinkGenerationPlugin.swift
   |- Sources:
          |- include: (Empty for now. Ideally this should contain generated symlinks)
          |- ObjCPackage:
                    |- Calculator.h
                    |- Calculator.m
   |- SymlinkGeneration:
          |- SymlinkGeneration.swift

Here is my Package.swift file:

import PackageDescription

let package = Package(
    name: "ObjCPackage",
    platforms: [.ios(.v13)],
    products: [
        .library(
            name: "ObjCPackage",
            targets: ["ObjCPackage"]
        ),
    ],
    targets: [
        .target(
            name: "ObjCPackage",
            dependencies: [],
            path: "Sources",
            publicHeaderPath: "include",
            plugins: [
                .plugin(name: "SymlinkGenerationPlugin")
            ]
        ),
        .executableTarget(
            name: "SymlinkGeneration",
            path: "SymlinkGeneration"
        ),
        .plugin(
            name: "SymlinkGenerationPlugin",
            capability: .buildTool(),
            dependencies: ["SymlinkGeneration"],
            path: "Plugin"
        )
    ]
)

The build is failing with error: 1359 Segmentation fault: 11 usr/bin/sandbox-exec -p

The other message printed during build phase is:

(deny file-write*
(subpath \"/users/me/Desktop/ObjcPackage")
)

On further search noticed that we might not be able to create a new file within package but may get it within build folder.

Now I have following questions:

  1. Is there any way to obtain desired result by using build plugin?
  2. In build plugin where are the created files stored?
  3. If symlinks are generated at a different path then how can I refer them in publicHeaderPath parameter within Package.swift?

I am able to get rid of previously reported error by making following changes:

a. SymlinkGenerationPlugin

@main
struct SymlinkGenerationPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let input = target.directory

        let inputDirectoryPath = input.appending(target.name)
        let outputDirectoryPath = input.appending("include")

        return [.buildCommand(displayName: "Generating Symlinks", executable: try context.tool(named: "SymlinkGEneration").path, arguments: [inputDirectoryPath, outputDirectoryPath], environment: [:])]
    }
}

b. SymlinkGeneration

@main
struct SymlinkGeneration {
    static func main() throws {
        let arguments = ProcessInfo().arguments
        guard arguments.count == 3 else {
            throw SymlinkGenerationError.invalidArguments
        }
        
        let (inputPath, outputPath) = (arguments[1], arguments[2])
        
        let input = URL(fileURLWithPath: inputPath)
        let output = URL(fileURLWithPath: outputPath)
        
        let fileManager = FileManager.default
        
        if let enumerator = fileManager.enumerator(at: input, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) {
            for case let fileURL as URL in enumerator {
                if fileURL.pathExtension == "h" {
                    let fileName = fileURL.lastPathComponent
                    let inputFilePath = fileURL
                    let outputFilePath = output.appending(path: fileName)
                    do {
                        try fileManager.createSymbolicLink(at: outputFilePath, withDestinationURL: inputFilePath)
                    } catch let error {
                        print("Error while creating symlinks: \(error.localizedDescription)")
                    }
                }
            }
        }
    }
}

Now I am getting the error:

You don't have permission to save the file "Calculations.h" in the folder "include".

I also encounter into this problem. And this post may help you

TL,DR: "build tool plugins only support generating Swift code"

Nevertheless, for some simple cases, I have a limited solution for them.

You can try it here:

1 Like

Thanks for the inputs and sample code at Github.

Noticed that you are obtaining the absolute path of SymlinkGeneration using below code:

let genPath = context.package.directory.appending(["..", "SymlinkGenerationPlugin", "bin", "SymlinkGeneration"])

This is failing for me in CI/ CD environment such as Jenkins where the workspace is constrained.

Is there any way to refer it using relative path?

Also I noticed that you are referring the unix commands in BuildToolPlugin using preBuildCommand:

.prebuildCommand(
                displayName: "Cleaning include directory",
                executable: try context.tool(named: "rm").path,
                arguments: ["-rf", outputDirectoryPath.string],
                outputFilesDirectory: context.pluginWorkDirectory
            )

I was wondering can we use a similar way to execute our own custom script?