Skip to content

Commit d315d3b

Browse files
committed
Implement Tool macro
1 parent 13f8640 commit d315d3b

File tree

10 files changed

+475
-7
lines changed

10 files changed

+475
-7
lines changed

Package.resolved

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,50 @@
1-
// swift-tools-version: 5.7
1+
// swift-tools-version: 6.0
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

4+
import CompilerPluginSupport
45
import PackageDescription
56

67
let package = Package(
78
name: "Ollama",
89
platforms: [
9-
.macOS(.v13)
10+
.macOS(.v14),
11+
.macCatalyst(.v14),
12+
.iOS(.v17),
13+
.watchOS(.v10),
14+
.tvOS(.v17),
15+
.visionOS(.v1),
1016
],
1117
products: [
1218
.library(
1319
name: "Ollama",
1420
targets: ["Ollama"])
1521
],
22+
dependencies: [
23+
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest")
24+
],
1625
targets: [
1726
.target(
18-
name: "Ollama"),
27+
name: "Ollama",
28+
dependencies: ["OllamaMacro"]),
1929
.testTarget(
2030
name: "OllamaTests",
2131
dependencies: ["Ollama"]),
32+
33+
.macro(
34+
name: "OllamaMacro",
35+
dependencies: [
36+
.product(name: "SwiftSyntax", package: "swift-syntax"),
37+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
38+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
39+
]
40+
),
41+
.testTarget(
42+
name: "OllamaMacroTests",
43+
dependencies: [
44+
"OllamaMacro",
45+
.product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
46+
.product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
47+
]
48+
),
2249
]
2350
)

Sources/Ollama/Client.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import Foundation
1010
/// allowing you to generate text, chat, create embeddings, and manage models.
1111
///
1212
/// - SeeAlso: [Ollama API Documentation](https://github.com/ollama/ollama/blob/main/docs/api.md)
13-
open class Client {
13+
open class Client: @unchecked Sendable {
1414
/// The default host URL for the Ollama API.
1515
public static let defaultHost = URL(string: "http://localhost:11434")!
1616

1717
/// A default client instance using the default host.
18-
public static let `default` = Client(host: Client.defaultHost)
18+
@MainActor public static let `default` = Client(host: Client.defaultHost)
1919

2020
/// The host URL for requests made by the client.
2121
public let host: URL

Sources/Ollama/Extensions/Data+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import RegexBuilder
33

44
/// Regex pattern for data URLs
5-
private let dataURLRegex = Regex {
5+
nonisolated(unsafe) private let dataURLRegex = Regex {
66
"data:"
77
Capture {
88
ZeroOrMore(.reluctant) {

Sources/Ollama/Tool.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Foundation
2+
3+
/// Macro that transforms functions and types into Tools
4+
@attached(peer, names: prefixed(Tool_))
5+
public macro Tool() = #externalMacro(module: "OllamaMacro", type: "ToolMacro")
6+
7+
public protocol Tool {
8+
associatedtype Input: Codable
9+
associatedtype Output: Codable
10+
11+
static var schema: [String: Value] { get }
12+
}

Sources/OllamaMacro/Tool.swift

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntax
3+
import SwiftSyntaxBuilder
4+
import SwiftSyntaxMacros
5+
6+
enum Error: Swift.Error {
7+
case unsupportedDeclaration
8+
}
9+
10+
public struct ToolMacro: PeerMacro {
11+
public static func expansion(
12+
of node: AttributeSyntax,
13+
providingPeersOf declaration: some DeclSyntaxProtocol,
14+
in context: some MacroExpansionContext
15+
) throws -> [DeclSyntax] {
16+
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
17+
throw Error.unsupportedDeclaration
18+
}
19+
20+
let toolName = "Tool_" + funcDecl.name.text
21+
22+
let inputType = generateInputType(
23+
from: funcDecl.signature.parameterClause,
24+
toolName: toolName
25+
)
26+
27+
let description = extractDescription(from: funcDecl)
28+
let parameterDescriptions = extractParameterDescriptions(from: funcDecl)
29+
30+
let tool = """
31+
enum \(toolName): Tool {
32+
\(inputType)
33+
typealias Output = \(funcDecl.signature.returnClause?.type.description ?? "Void")
34+
35+
static var schema: [String: Value] {
36+
[
37+
"name": "\(funcDecl.name.text)",
38+
"description": "\(description)",
39+
"parameters": \(generateParametersDictionary(from: funcDecl.signature.parameterClause, descriptions: parameterDescriptions))
40+
]
41+
}
42+
43+
static func call(\(generateCallParameters(from: funcDecl.signature.parameterClause))) throws -> Output {
44+
\(generateFunctionCall(funcDecl: funcDecl))
45+
}
46+
}
47+
"""
48+
49+
let toolDecl: DeclSyntax = "\(raw: tool)"
50+
return [toolDecl]
51+
}
52+
53+
private static func generateInputType(
54+
from parameters: FunctionParameterClauseSyntax,
55+
toolName: String
56+
) -> String {
57+
if parameters.parameters.count == 1 {
58+
let param = parameters.parameters.first!
59+
return "typealias Input = \(param.type)"
60+
}
61+
62+
let structFields = parameters.parameters.map { param in
63+
"let \(param.firstName.text): \(param.type)"
64+
}.joined(separator: "\n ")
65+
66+
let inputStruct = """
67+
struct Input: Codable {
68+
\(structFields)
69+
}
70+
"""
71+
72+
return inputStruct
73+
}
74+
75+
private static func generateCallParameters(from parameters: FunctionParameterClauseSyntax)
76+
-> String
77+
{
78+
return parameters.parameters.map { param in
79+
let paramName = param.secondName?.text ?? param.firstName.text
80+
return "\(paramName): \(param.type)"
81+
}.joined(separator: ", ")
82+
}
83+
84+
private static func generateParametersDictionary(
85+
from parameters: FunctionParameterClauseSyntax, descriptions: [String: String]
86+
) -> String {
87+
let parameterEntries = parameters.parameters.map { param in
88+
let paramName = param.secondName?.text ?? param.firstName.text
89+
let paramType = param.type.description
90+
if let description = descriptions[paramName] {
91+
return """
92+
"\(paramName)": [
93+
"type": "\(paramType)",
94+
"description": "\(description)"
95+
]
96+
"""
97+
} else {
98+
return """
99+
"\(paramName)": [
100+
"type": "\(paramType)"
101+
]
102+
"""
103+
}
104+
}.joined(separator: ",\n")
105+
106+
return "[\n\(parameterEntries)\n ]"
107+
}
108+
109+
private static func generateFunctionCall(funcDecl: FunctionDeclSyntax) -> String {
110+
let params = funcDecl.signature.parameterClause.parameters
111+
let arguments = params.map { param in
112+
let argName = param.secondName?.text ?? param.firstName.text
113+
return "\(param.firstName): \(argName)"
114+
}.joined(separator: ", ")
115+
116+
return "\(funcDecl.name.text)(\(arguments))"
117+
}
118+
119+
private static func extractDescription(from funcDecl: FunctionDeclSyntax) -> String {
120+
if let docComment = funcDecl.leadingTrivia.compactMap({ trivia in
121+
if case .docLineComment(let comment) = trivia { return comment }
122+
return nil
123+
}).first {
124+
return String(docComment.dropFirst(3).trimmingCharacters(in: .whitespaces))
125+
}
126+
return "Calls the \(funcDecl.name.text) function"
127+
}
128+
129+
private static func extractParameterDescriptions(from funcDecl: FunctionDeclSyntax) -> [String:
130+
String]
131+
{
132+
var descriptions: [String: String] = [:]
133+
let docComments = funcDecl.leadingTrivia.compactMap({ trivia in
134+
if case .docLineComment(let comment) = trivia { return comment }
135+
return nil
136+
})
137+
for comment in docComments {
138+
let trimmed = comment.dropFirst(3).trimmingCharacters(in: .whitespaces)
139+
if trimmed.starts(with: "- Parameter") {
140+
let parts = trimmed.dropFirst(12).split(separator: ":", maxSplits: 1)
141+
if parts.count == 2 {
142+
let paramName = parts[0].trimmingCharacters(in: .whitespaces)
143+
let description = parts[1].trimmingCharacters(in: .whitespaces)
144+
descriptions[paramName] = description
145+
}
146+
}
147+
}
148+
return descriptions
149+
}
150+
}
151+
152+
@main
153+
struct OllamaToolPlugin: CompilerPlugin {
154+
let providingMacros: [Macro.Type] = [
155+
ToolMacro.self
156+
]
157+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import SwiftSyntaxMacroExpansion
2+
import SwiftSyntaxMacros
3+
import SwiftSyntaxMacrosGenericTestSupport
4+
import Testing
5+
6+
func assertMacroExpansion(
7+
_ originalSource: String,
8+
expandedSource expectedExpandedSource: String,
9+
macros: [String: Macro.Type],
10+
fileID: StaticString = #fileID,
11+
filePath: StaticString = #filePath,
12+
line: UInt = #line,
13+
column: UInt = #column
14+
) {
15+
assertMacroExpansion(
16+
originalSource,
17+
expandedSource: expectedExpandedSource,
18+
macroSpecs: macros.mapValues { MacroSpec(type: $0) },
19+
indentationWidth: .spaces(4),
20+
failureHandler: { spec in
21+
Issue.record(
22+
"\(spec.message)",
23+
sourceLocation: SourceLocation(
24+
fileID: spec.location.fileID,
25+
filePath: spec.location.filePath,
26+
line: spec.location.line,
27+
column: spec.location.column
28+
)
29+
)
30+
},
31+
fileID: fileID,
32+
filePath: filePath,
33+
line: line,
34+
column: column
35+
)
36+
}

0 commit comments

Comments
 (0)