diff --git a/Sources/ScreamURITemplateCompilerPlugin/ExprSyntax+Literals.swift b/Sources/ScreamURITemplateCompilerPlugin/ExprSyntax+Literals.swift index b693d82..7d8d198 100644 --- a/Sources/ScreamURITemplateCompilerPlugin/ExprSyntax+Literals.swift +++ b/Sources/ScreamURITemplateCompilerPlugin/ExprSyntax+Literals.swift @@ -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 + } } diff --git a/Sources/ScreamURITemplateCompilerPlugin/URITemplateCompilerPlugin.swift b/Sources/ScreamURITemplateCompilerPlugin/URITemplateCompilerPlugin.swift index 1e17c9e..09af1e4 100644 --- a/Sources/ScreamURITemplateCompilerPlugin/URITemplateCompilerPlugin.swift +++ b/Sources/ScreamURITemplateCompilerPlugin/URITemplateCompilerPlugin.swift @@ -17,5 +17,8 @@ import SwiftSyntaxMacros @main struct URITemplateCompilerPlugin: CompilerPlugin { - var providingMacros: [Macro.Type] = [URITemplateMacro.self] + var providingMacros: [Macro.Type] = [ + URITemplateMacro.self, + URLByExpandingURITemplateMacro.self, + ] } diff --git a/Sources/ScreamURITemplateCompilerPlugin/URLByExpandingURITemplateMacro.swift b/Sources/ScreamURITemplateCompilerPlugin/URLByExpandingURITemplateMacro.swift new file mode 100644 index 0000000..2df7d5b --- /dev/null +++ b/Sources/ScreamURITemplateCompilerPlugin/URLByExpandingURITemplateMacro.swift @@ -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()))!" + } +} diff --git a/Sources/ScreamURITemplateExample/main.swift b/Sources/ScreamURITemplateExample/main.swift index 097d488..97c86d3 100644 --- a/Sources/ScreamURITemplateExample/main.swift +++ b/Sources/ScreamURITemplateExample/main.swift @@ -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) diff --git a/Sources/ScreamURITemplateMacros/Macros.swift b/Sources/ScreamURITemplateMacros/Macros.swift index 6bedda6..2654e03 100644 --- a/Sources/ScreamURITemplateMacros/Macros.swift +++ b/Sources/ScreamURITemplateMacros/Macros.swift @@ -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 @@ -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) -> URL = #externalMacro(module: "ScreamURITemplateCompilerPlugin", type: "URLByExpandingURITemplateMacro") diff --git a/Tests/ScreamURITemplateTests/URITemplateMacroTests.swift b/Tests/ScreamURITemplateTests/URITemplateMacroTests.swift index d2bb43e..e9941d1 100644 --- a/Tests/ScreamURITemplateTests/URITemplateMacroTests.swift +++ b/Tests/ScreamURITemplateTests/URITemplateMacroTests.swift @@ -13,7 +13,7 @@ // limitations under the License. #if canImport(ScreamURITemplateCompilerPlugin) - import ScreamURITemplateCompilerPlugin + @testable import ScreamURITemplateCompilerPlugin import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport @@ -25,6 +25,11 @@ "URITemplate": URITemplateMacro.self, ] + func testMacroAvailability() { + let plugin = URITemplateCompilerPlugin() + XCTAssert(plugin.providingMacros.contains { $0 == URITemplateMacro.self }) + } + func testValid() throws { assertMacroExpansion( #""" diff --git a/Tests/ScreamURITemplateTests/URLByExpandingURITemplateMacroTests.swift b/Tests/ScreamURITemplateTests/URLByExpandingURITemplateMacroTests.swift new file mode 100644 index 0000000..86f5758 --- /dev/null +++ b/Tests/ScreamURITemplateTests/URLByExpandingURITemplateMacroTests.swift @@ -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 = ["owner": "SwiftScream"] + #URLByExpandingURITemplate("https://api.github.com/repos/{owner}/{repo}/collaborators/{username}", params) + """#, + expandedSource: + #""" + let params: KeyValue = ["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