Skip to content
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

Add macro to perform compile-time template processing #84

Merged
merged 1 commit into from
Dec 5, 2024
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
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")
7 changes: 6 additions & 1 deletion Tests/ScreamURITemplateTests/URITemplateMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

#if canImport(ScreamURITemplateCompilerPlugin)
import ScreamURITemplateCompilerPlugin
@testable import ScreamURITemplateCompilerPlugin

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
Expand All @@ -25,6 +25,11 @@
"URITemplate": URITemplateMacro.self,
]

func testMacroAvailability() {
let plugin = URITemplateCompilerPlugin()
XCTAssert(plugin.providingMacros.contains { $0 == URITemplateMacro.self })
}

func testValid() throws {
assertMacroExpansion(
#"""
Expand Down
172 changes: 172 additions & 0 deletions Tests/ScreamURITemplateTests/URLByExpandingURITemplateMacroTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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)
@testable import ScreamURITemplateCompilerPlugin

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport

import XCTest

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

func testMacroAvailability() {
let plugin = URITemplateCompilerPlugin()
XCTAssert(plugin.providingMacros.contains { $0 == 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
Loading