How to wrap a C library in a swift package
There are a few great C/C++ libraries out there that you might want to use in your Swift application. Most of the time you'll find a wrapper already, but not all of the time. Or maybe you want something that is carefully tailored to your project needs.
Chris Eidhof from objc.io has a wrapper around the cmark library: CommonMark-Swift. Let's take that as an example and see how this can be achieved.
Create the library package
mkdir ~/CommonMark
cd ~/CommonMark
swift package init --type library
Before we can actually use the cmark library it needs to be installed. I use brew:
brew install cmark
Now let's edit the Package.swift
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "CommonMark",
platforms: [
.macOS("11")
],
products: [
.library(name: "CommonMark", targets: ["CommonMark"]),
.library(name: "Ccmark", targets: ["Ccmark"])
],
dependencies: [
],
targets: [
.target(name: "CommonMark", dependencies: [
"Ccmark"
]),
.systemLibrary(
name: "Ccmark",
pkgConfig: "libcmark",
providers: [
.brew(["commonmark"])
]),
.testTarget(
name: "CommonMarkTests",
dependencies: ["CommonMark"]),
]
)
So we're building two products:
- CommonMark
- Ccmark
In the target section you can see that Ccmark is the name that we'll use for libcmark. The leading uppercase C seems to be a standard.
For this to work we need to create a directory Ccmark
below our Sources
directory and create the file: module.modulemap
with the following content:
module Ccmark [system] {
header "/usr/local/include/cmark.h"
link "libcmark"
export *
}
This tells the system where to find the header for libcmark and the library itself. The [system]
attribute tells the compiler that cmark.h
is a system header and more warnings will be ignored.
Voila! That's it. We can now use the cmark library in our Swift code.
Create a Swift API
Chris created another target: CommonMark
that gives the user a nicer API to work with. Let's build a minimal version of that.
Edit Sources/CommonMark/CommonMark.swift
like this:
import Foundation
import Ccmark // this wraps libcmark
public class Node {
let node: OpaquePointer
public init(_ node: OpaquePointer) {
self.node = node
}
public init?(markdown: String) {
guard let node = cmark_parse_document(markdown, markdown.utf8.count, CMARK_OPT_DEFAULT) else {
return nil
}
self.node = node
}
deinit {
guard type == CMARK_NODE_DOCUMENT else { return }
cmark_node_free(node)
}
public var type: cmark_node_type {
return cmark_node_get_type(node)
}
public var typeString: String {
return String(cString: cmark_node_get_type_string(node))
}
public var children: [Node] {
var result: [Node] = []
// cmark_node_first_child can return nil
var child = cmark_node_first_child(node)
while let unwrapped = child {
result.append(Node(unwrapped))
child = cmark_node_next(child)
}
return result
}
}
This is pretty straight forward. The class Node encapsulates a pointer to the cmark node type. It gets initialized in the failable initializer through the call to the function cmark_parse_document
. For this to work you need to import Ccmark.
If you want to read the documentation for libcmark you can open the man-page with
man 3 cmark
. The3
opens the library documentation as opposed to the implicit1
which would open the cmark commands man page.
This will not compile yet, because of the testExample
test. Let's create a useful test:
import XCTest
@testable import CommonMark
final class CommonMarkTests: XCTestCase {
func testCaption() {
let markdown = "# Caption"
let node = Node(markdown: markdown)!
let heading = node.children.first
XCTAssertEqual(heading?.typeString, "heading")
}
}
Include the library with your app
If you want to include the dylib
with your app bundle you can create a Frameworks
subdirectory below Contents
and copy the library there. You can tell your app where it can find the library with the following command:
install_name_tool -change /usr/local/opt/cmark/lib/libcmark.0.29.0.dylib "@executable_path/../Frameworks/libcmark.dylib" ./<YourApp>.app/Contents/MacOS/Scratched
You can find out the the standard path of the library with the following command:
otool -L /usr/local/lib/libcmark.dylib