Skip to content

Commit

Permalink
Add macro to perform compile-time template processing
Browse files Browse the repository at this point in the history
  • Loading branch information
alexdeem committed Dec 4, 2024
1 parent 5f4950f commit 79901e6
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 1 deletion.
17 changes: 17 additions & 0 deletions Sources/ScreamURITemplateCompilerPlugin/ExprSyntax+Literals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,21 @@ extension ExprSyntax {

return literalSegment.content.text
}

func dictionaryLiteral() -> [String: String]? {
guard let elements = self.as(DictionaryExprSyntax.self)?.content.as(DictionaryElementListSyntax.self) else {
return nil
}

var result: [String: String] = [:]
for element in elements {
guard let key = element.key.stringLiteral(),
let value = element.value.stringLiteral() else {
return nil
}
result[key] = value
}

return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@ import SwiftSyntaxMacros

@main
struct URITemplateCompilerPlugin: CompilerPlugin {
var providingMacros: [Macro.Type] = [URITemplateMacro.self]
var providingMacros: [Macro.Type] = [
URITemplateMacro.self,
URLByExpandingURITemplateMacro.self,
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2018-2024 Alex Deem
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation
import SwiftCompilerPlugin
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxMacros

import ScreamURITemplate

public struct URLByExpandingURITemplateMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in _: some MacroExpansionContext) throws -> ExprSyntax {
guard let templateArgument = node.arguments.first?.expression,
let uriTemplateString = templateArgument.stringLiteral() else {
throw DiagnosticsError(diagnostics: [
Diagnostic(node: node,
message: MacroExpansionErrorMessage("#URLByExpandingURITemplate requires a static string literal for the first argument")),
])
}

guard let paramsArgument = node.arguments.last?.expression,
let params = paramsArgument.dictionaryLiteral() else {
throw DiagnosticsError(diagnostics: [
Diagnostic(node: node,
message: MacroExpansionErrorMessage("#URLByExpandingURITemplate requires a Dictionary Literal of string literals for the second argument")),
])
}

let template: URITemplate
do {
template = try URITemplate(string: uriTemplateString)
} catch {
throw DiagnosticsError(diagnostics: [
Diagnostic(node: templateArgument,
message: MacroExpansionErrorMessage("Invalid URI template: \(error.reason) at \"\(uriTemplateString.suffix(from: error.position).prefix(50))\"")),
])
}

let processedTemplate: String
do {
processedTemplate = try template.process(variables: params)
} catch {
throw DiagnosticsError(diagnostics: [
Diagnostic(node: node,
message: MacroExpansionErrorMessage("Failed to process template: \(error.reason)")),
])
}

guard URL(string: processedTemplate) != nil else {
throw DiagnosticsError(diagnostics: [
Diagnostic(node: node,
message: MacroExpansionErrorMessage("Processed template does not form a valid URL\n\(processedTemplate)")),
])
}

return "URL(string: \(processedTemplate.makeLiteralSyntax()))!"
}
}
4 changes: 4 additions & 0 deletions Sources/ScreamURITemplateExample/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ print(url.absoluteString)

let macroExpansion = #URITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}")
print(macroExpansion)

let urlExpansion = #URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}",
with: ["owner": "SwiftScream", "repo": "URITemplate", "username": "alexdeem"])
print(urlExpansion)
13 changes: 13 additions & 0 deletions Sources/ScreamURITemplateMacros/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation
import ScreamURITemplate

/// Macro providing compile-time validation of a URITemplate represented by a string literal
Expand All @@ -24,3 +25,15 @@ import ScreamURITemplate
/// - Returns: A `URITemplate` constructed from the string literal
@freestanding(expression)
public macro URITemplate(_ stringLiteral: StaticString) -> URITemplate = #externalMacro(module: "ScreamURITemplateCompilerPlugin", type: "URITemplateMacro")

/// Macro providing compile-time validation and processing of a URITemplate and parameters entirely represented by string literals
/// Example:
/// ```swift
/// let template = #URLByExpandingURITemplate("https://api.github.com/repos/{owner}", with: ["owner": "SwiftScream"])
/// ```
/// - Parameters:
/// - : A string literal representing the URI Template
/// - with: The parameters to use to process the template, represented by a dictionary literal where the keys and values are all string literals
/// - Returns: A `URL` constructed from the result of processing the template with the parameters
@freestanding(expression)
public macro URLByExpandingURITemplate(_ stringLiteral: StaticString, with: KeyValuePairs<StaticString, StaticString>) -> URL = #externalMacro(module: "ScreamURITemplateCompilerPlugin", type: "URLByExpandingURITemplateMacro")
167 changes: 167 additions & 0 deletions Tests/ScreamURITemplateTests/URLByExpandingURITemplateMacroTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright 2018-2024 Alex Deem
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#if canImport(ScreamURITemplateCompilerPlugin)
import ScreamURITemplateCompilerPlugin

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport

import XCTest

class URLByExpandingURITemplateMacroTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"URLByExpandingURITemplate": URLByExpandingURITemplateMacro.self,
]

func testValid() throws {
assertMacroExpansion(
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
"repo": "URITemplate",
"username": "alexdeem",
])
"""#,
expandedSource:
#"""
URL(string: "https://api.github.com/repos/SwiftScream/URITemplate/collaborators/alexdeem")!
"""#,
diagnostics: [],
macros: testMacros)
}

func testInvalidTemplate() throws {
assertMacroExpansion(
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
"repo": "URITemplate",
"username": "alexdeem",
])
"""#,
expandedSource:
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
"repo": "URITemplate",
"username": "alexdeem",
])
"""#,
diagnostics: [
DiagnosticSpec(message: "Invalid URI template: Empty Variable Name at \"}/{repo}/collaborators/{username}\"", line: 1, column: 28),
],
macros: testMacros)
}

func testInvalidURL() throws {
assertMacroExpansion(
#"""
#URLByExpandingURITemplate("{nope}", ["nope": ""])
"""#,
expandedSource:
#"""
#URLByExpandingURITemplate("{nope}", ["nope": ""])
"""#,
diagnostics: [
DiagnosticSpec(message: "Processed template does not form a valid URL\n", line: 1, column: 1),
],
macros: testMacros)
}

func testMisusedTemplate() throws {
assertMacroExpansion(
#"""
let s: StaticString = "https://api.github.com/repos/{owner}"
#URLByExpandingURITemplate(s, [
"owner": "SwiftScream",
])
"""#,
expandedSource:
#"""
let s: StaticString = "https://api.github.com/repos/{owner}"
#URLByExpandingURITemplate(s, [
"owner": "SwiftScream",
])
"""#,
diagnostics: [
DiagnosticSpec(message: "#URLByExpandingURITemplate requires a static string literal for the first argument", line: 2, column: 1),
],
macros: testMacros)
}

func testMisusedParams() throws {
assertMacroExpansion(
#"""
let params: KeyValue<StaticString, StaticString> = ["owner": "SwiftScream"]
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", params)
"""#,
expandedSource:
#"""
let params: KeyValue<StaticString, StaticString> = ["owner": "SwiftScream"]
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", params)
"""#,
diagnostics: [
DiagnosticSpec(message: "#URLByExpandingURITemplate requires a Dictionary Literal of string literals for the second argument", line: 2, column: 1),
],
macros: testMacros)
}

func testMisusedParamKey() throws {
assertMacroExpansion(
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
123: "URITemplate",
"username": "alexdeem",
])
"""#,
expandedSource:
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
123: "URITemplate",
"username": "alexdeem",
])
"""#,
diagnostics: [
DiagnosticSpec(message: "#URLByExpandingURITemplate requires a Dictionary Literal of string literals for the second argument", line: 1, column: 1),
],
macros: testMacros)
}

func testMisusedParamValue() throws {
assertMacroExpansion(
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
"repo": 12345,
"username": "alexdeem",
])
"""#,
expandedSource:
#"""
#URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", [
"owner": "SwiftScream",
"repo": 12345,
"username": "alexdeem",
])
"""#,
diagnostics: [
DiagnosticSpec(message: "#URLByExpandingURITemplate requires a Dictionary Literal of string literals for the second argument", line: 1, column: 1),
],
macros: testMacros)
}
}
#endif

0 comments on commit 79901e6

Please sign in to comment.