From d51beaf1736013b530576ace13a16d6d1a63742c Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 6 Mar 2024 18:29:28 +0600 Subject: [PATCH] #URL macro (#2) Task URL: https://app.asana.com/0/42792087274227/1206542455948401/f BSK PR: duckduckgo/BrowserServicesKit#657 macOS PR: duckduckgo/macos-browser#2179 iOS PR: duckduckgo/iOS#2540 --- .gitignore | 1 + .swiftlint.yml | 82 +++++++ Package.resolved | 32 +++ Package.swift | 32 ++- Sources/Macros/MacroDefinitions.swift | 37 ++++ Sources/MacrosImplementation/MacroError.swift | 32 +++ .../MacrosCompilerPlugin.swift | 27 +++ Sources/MacrosImplementation/URLMacro.swift | 65 ++++++ Tests/.swiftlint.yml | 17 ++ Tests/MacrosTests/URLMacroTests.swift | 208 ++++++++++++++++++ 10 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 Package.resolved create mode 100644 Sources/Macros/MacroDefinitions.swift create mode 100644 Sources/MacrosImplementation/MacroError.swift create mode 100644 Sources/MacrosImplementation/MacrosCompilerPlugin.swift create mode 100644 Sources/MacrosImplementation/URLMacro.swift create mode 100644 Tests/.swiftlint.yml create mode 100644 Tests/MacrosTests/URLMacroTests.swift diff --git a/.gitignore b/.gitignore index 7aafd7b..1e0d8d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store xcuserdata/ .vscode +.swiftpm/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b24e766 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,82 @@ +allow_zero_lintable_files: true + +disabled_rules: + - no_space_in_method_call + - multiple_closures_with_trailing_closure + - block_based_kvo + - compiler_protocol_init + - unused_setter_value + - line_length + - type_name + - implicit_getter + - function_parameter_count + - trailing_comma + - nesting + - opening_brace + +opt_in_rules: + - file_header + - explicit_init + +custom_rules: + explicit_non_final_class: + included: ".*\\.swift" + name: "Implicitly non-final class" + regex: "^\\s*(class) (?!func|var)" + capture_group: 0 + match_kinds: + - keyword + message: "Classes should be `final` by default, use explicit `internal` or `public` for non-final classes." + severity: error + enforce_os_log_wrapper: + included: ".*\\.swift" + name: "Use `import Common` for os_log instead of `import os.log`" + regex: "^(import (?:os\\.log|os|OSLog))$" + capture_group: 0 + message: "os_log wrapper ensures log args are @autoclosures (computed when needed) and to be able to use String Interpolation." + severity: error + +analyzer_rules: # Rules run by `swiftlint analyze` + - explicit_self + +# Rule Config +identifier_name: + min_length: 1 + max_length: 1000 +file_length: + warning: 1200 + error: 1200 +type_body_length: + warning: 500 + error: 500 +large_tuple: + warning: 4 + error: 5 +file_header: + required_pattern: | + \/\/ + \/\/ SWIFTLINT_CURRENT_FILENAME + \/\/ + \/\/ Copyright Β© \d{4} DuckDuckGo\. All rights reserved\. + \/\/ + \/\/ 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\. + \/\/ + +# General Config +excluded: + - Package.swift + - .build + - scripts/ + - Sources/RemoteMessaging/Model/AnyDecodable.swift + - Sources/Common/Concurrency/AsyncStream.swift + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..53c4612 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing.git", + "state" : { + "revision" : "10dcef36314ddfea6f60442169b0b320204cbd35", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "e7b77228b34057041374ebef00c0fd7739d71a2b", + "version" : "1.15.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 0fc6a59..20f19c6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,8 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription -import Foundation let package = Package( name: "AppleToolbox", @@ -12,8 +12,12 @@ let package = Package( ], products: [ .plugin(name: "SwiftLintPlugin", targets: ["SwiftLintPlugin"]), + .library(name: "Macros", targets: ["Macros"]), ], dependencies: [ + // Depend on the Swift 5.9 release of SwiftSyntax + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.2.2"), ], targets: [ .plugin( @@ -28,5 +32,29 @@ let package = Package( url: "https://github.com/realm/SwiftLint/releases/download/0.54.0/SwiftLintBinary-macos.artifactbundle.zip", checksum: "963121d6babf2bf5fd66a21ac9297e86d855cbc9d28322790646b88dceca00f1" ), + // Macro implementation that performs the source transformation of a macro. + .macro( + name: "MacrosImplementation", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ], + plugins: [.plugin(name: "SwiftLintPlugin")] + ), + // Library that exposes a macro as part of its API, which is used in client programs. + .target( + name: "Macros", + dependencies: ["MacrosImplementation"], + plugins: [.plugin(name: "SwiftLintPlugin")] + ), + .testTarget( + name: "MacrosTests", + dependencies: [ + "MacrosImplementation", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + .product(name: "MacroTesting", package: "swift-macro-testing"), + ], + plugins: [.plugin(name: "SwiftLintPlugin")] + ), ] ) diff --git a/Sources/Macros/MacroDefinitions.swift b/Sources/Macros/MacroDefinitions.swift new file mode 100644 index 0000000..a62e300 --- /dev/null +++ b/Sources/Macros/MacroDefinitions.swift @@ -0,0 +1,37 @@ +// +// MacroDefinitions.swift +// +// Copyright Β© 2024 DuckDuckGo. All rights reserved. +// +// 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 + +/// Compile-time validated OS version independent URL instantiation from String Literal. +/// +/// Used to statically validate preset URLs. +/// The idea is to disable any flaky URL conversions like punycode or no-scheme urls, only 1-1 mapped String Literals can be used. +/// +/// Usage: `let url = #URL("https://duckduckgo.com")` +/// +/// - Note: Strings like "http://πŸ’©.la" or "1" are not valid #URL parameter values. +/// To instantiate a parametrized URL use `URL.appendingPathComponent(_:)` or `URL.appendingParameters(_:allowedReservedCharacters:)` +/// To instantiate a URL from a String format, use `URL(string:)` +/// +/// - Parameter string: valid URL String Literal with URL scheme +/// - Returns: URL instance if provided string argument is a valid URL +/// - Throws: Compile-time error if provided string argument is not a valid URL +/// +@freestanding(expression) +public macro URL(_ string: StaticString) -> URL = #externalMacro(module: "MacrosImplementation", type: "URLMacro") diff --git a/Sources/MacrosImplementation/MacroError.swift b/Sources/MacrosImplementation/MacroError.swift new file mode 100644 index 0000000..dd006cd --- /dev/null +++ b/Sources/MacrosImplementation/MacroError.swift @@ -0,0 +1,32 @@ +// +// MacroError.swift +// +// Copyright Β© 2024 DuckDuckGo. All rights reserved. +// +// 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 + +enum MacroError: Error, CustomStringConvertible { + + case message(String) + + var description: String { + switch self { + case .message(let text): + return text + } + } + +} diff --git a/Sources/MacrosImplementation/MacrosCompilerPlugin.swift b/Sources/MacrosImplementation/MacrosCompilerPlugin.swift new file mode 100644 index 0000000..bed3945 --- /dev/null +++ b/Sources/MacrosImplementation/MacrosCompilerPlugin.swift @@ -0,0 +1,27 @@ +// +// MacrosCompilerPlugin.swift +// +// Copyright Β© 2024 DuckDuckGo. All rights reserved. +// +// 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 SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct MacrosCompilerPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + URLMacro.self, + ] +} diff --git a/Sources/MacrosImplementation/URLMacro.swift b/Sources/MacrosImplementation/URLMacro.swift new file mode 100644 index 0000000..038da0a --- /dev/null +++ b/Sources/MacrosImplementation/URLMacro.swift @@ -0,0 +1,65 @@ +// +// URLMacro.swift +// +// Copyright Β© 2024 DuckDuckGo. All rights reserved. +// +// 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 SwiftSyntax +import SwiftSyntaxMacros + +/// Compile-time validated URL instantiation +/// - Returns: URL instance if provided string argument is a valid URL +/// - Throws: Compile-time error if provided string argument is not a valid URL +/// - Usage: `let url = #URL("https://duckduckgo.com")` +public struct URLMacro: ExpressionMacro { + + static let invalidCharacters = CharacterSet.urlQueryAllowed + .union(CharacterSet(charactersIn: "%+?#[]")) + .inverted + static let urlSchemeAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + + public static func expansion(of node: some SwiftSyntax.FreestandingMacroExpansionSyntax, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> SwiftSyntax.ExprSyntax { + + guard node.argumentList.count == 1 else { + throw MacroError.message("#URL macro should have one String literal argument") + } + guard let literal = node.argumentList.first?.expression.as(StringLiteralExprSyntax.self), + literal.segments.count == 1, + let string = literal.segments.first?.as(StringSegmentSyntax.self)?.content else { + throw MacroError.message("#URL argument should be a String literal") + } + + if let idx = string.text.rangeOfCharacter(from: Self.invalidCharacters)?.lowerBound { + throw MacroError.message("\"\(string.text)\" has invalid character at index \(string.text.distance(from: string.text.startIndex, to: idx)) (\(string.text[idx]))") + } + guard let scheme = string.text.range(of: ":").map({ string.text[..<$0.lowerBound] }), + scheme.rangeOfCharacter(from: Self.urlSchemeAllowedCharacters.inverted) == nil else { + throw MacroError.message("URL must contain a scheme") + } + guard let url = URL(string: string.text) else { + throw MacroError.message("\"\(string.text)\" is not a valid URL") + } + guard url.scheme == String(scheme) else { + throw MacroError.message("URL must contain a scheme") + } + guard url.absoluteString == string.text else { + throw MacroError.message("Resulting URL \"\(url.absoluteString)\" is not equal to \"\(string.text)\"") + } + + return "URL(string: \"\(raw: string.text)\")!" + } + +} diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml new file mode 100644 index 0000000..bf8a565 --- /dev/null +++ b/Tests/.swiftlint.yml @@ -0,0 +1,17 @@ +disabled_rules: + - file_length + - unused_closure_parameter + - type_name + - force_cast + - force_try + - function_body_length + - cyclomatic_complexity + - identifier_name + - blanket_disable_command + - type_body_length + - explicit_non_final_class + - enforce_os_log_wrapper + +large_tuple: + warning: 6 + error: 10 diff --git a/Tests/MacrosTests/URLMacroTests.swift b/Tests/MacrosTests/URLMacroTests.swift new file mode 100644 index 0000000..bfe0344 --- /dev/null +++ b/Tests/MacrosTests/URLMacroTests.swift @@ -0,0 +1,208 @@ +// +// URLMacroTests.swift +// +// Copyright Β© 2024 DuckDuckGo. All rights reserved. +// +// 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 MacrosImplementation +import MacroTesting +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class URLMacroTests: XCTestCase { + + private let macros: [String: Macro.Type] = ["URL": URLMacro.self] + + func testWhenURLMacroAppliedToValidURLs_UrlIniIsGenerated() { + let startLine = #line + 2 + let urls = [ + "http://example.com", + "https://example.com", + "http://localhost", + "http://localdomain", + "https://dax%40duck.com:123%3A456A@www.duckduckgo.com/te-st.php?test=S&info=test#fragment", + "user:@something.local:9100", + "user:passwOrd@localhost:5000", + "user:%20@localhost:5000", + "https://user:@something.local:9100", + "https://user:passwOrd@localhost:5000", + "https://user:%20@localhost:5000", + "mailto:test@example.com", + "http://192.168.1.1", + "http://sheep%2B:P%40%24swrd@192.168.1.1", + "data:text/vnd-example+xyz;foo=bar;base64,R0lGODdh", + "http://192.168.0.1", + "http://203.0.113.0", + "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", + "http://[2001:0db8::1]", + "http://[::]:8080", + "about:blank", + "duck://newtab", + "duck://welcome", + "duck://settings", + "duck://bookmarks", + "duck://dbp", + "about:newtab", + "duck://home", + "about:welcome", + "about:home", + "about:settings", + "about:preferences", + "duck://preferences", + "about:config", + "duck://config", + "about:bookmarks", + "about:user:pass@blank", + "data:user:pass@text/vnd-example+xyz;foo=bar;base64,R0lGODdh", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + ] + + for (idx, url) in urls.enumerated() { + assertMacroExpansion( + """ + #URL("\(url)") + """, + expandedSource: + """ + URL(string: "\(url)")! + """, + macros: macros, + line: UInt(startLine + idx) + ) + } + } + + func testWhenURLMacroAppliedToURLWithoutScheme_diagnosticsErrorIsReturned() { + let startLine = #line + 2 + let urls = [ + "user@somehost.local:9091/index.html", + "192.168.1.1", + "duckduckgo.com", + "example.com", + "localhost", + "localdomain", + "user%40local:pa%24%24s@localhost:5000", + "user%40local:pa%24%24s@localhost:5000", + "sheep%2B:P%40%24swrd@192.168.1.1", + "sheep%2B:P%40%24swrd@192.168.1.1/", + "sheep%2B:P%40%24swrd@192.168.1.1:8900/", + "sheep%2B:P%40%24swrd@xn--ls8h.la/?arg=b#1", + ] + + for (idx, url) in urls.enumerated() { + assertMacroExpansion( + """ + #URL("\(url)") + """, + expandedSource: + """ + #URL("\(url)") + """, + diagnostics: [ + DiagnosticSpec(message: "URL must contain a scheme", line: 1, column: 1) + ], + macros: macros, + line: UInt(startLine + idx) + ) + } + } + + func testWhenURLMacroAppliedToURLsWithInvalidCharacter_diagnosticsErrorIsReturned() { + let startLine = #line + 2 + let urls: [(String, invalidCharacter: Character)] = [ + ("sheep%2B:P%40%24swrd@πŸ’©.la?arg=b#1", "πŸ’©"), + ("https://www.duckduckgo .com/html?q=search", " "), + ("ΠΌΠ²Π΄.Ρ€Ρ„", "ΠΌ"), + ("https://ΠΌΠ²Π΄.Ρ€Ρ„", "ΠΌ"), + ("https://sheep%2B:P%40%24swrd@πŸ’©.la", "πŸ’©"), + ("https://www.duckduckgo.com/html?q =search", " "), + ] + + for (idx, (url, char)) in urls.enumerated() { + assertMacroExpansion( + """ + #URL("\(url)") + """, + expandedSource: + """ + #URL("\(url)") + """, + diagnostics: [ + DiagnosticSpec(message: """ + "\(url)" has invalid character at index \(url.distance(from: url.startIndex, to: url.firstIndex(of: char)!)) (\(char)) + """, line: 1, column: 1) + ], + macros: macros, + line: UInt(startLine + idx) + ) + } + } + + func testWhenInvalidArgumentProvided_URLMacroFails() { + assertMacro(macros, record: false) { + """ + let s = "duckduckgo.com/" + let _=#URL(s) + """ + } diagnostics: { + """ + let s = "duckduckgo.com/" + let _=#URL(s) + ┬────── + ╰─ πŸ›‘ #URL argument should be a String literal + """ + } + + assertMacro(macros, record: false) { + """ + #URL(duckduckgo) + """ + } diagnostics: { + """ + #URL(duckduckgo) + ┬─────────────── + ╰─ πŸ›‘ #URL argument should be a String literal + """ + } + + assertMacro(macros, record: false) { + """ + #URL(1) + """ + } diagnostics: { + """ + #URL(1) + ┬────── + ╰─ πŸ›‘ #URL argument should be a String literal + """ + } + } + + func testWhenTooManyArgsProvided_URLMacroFails() { + assertMacro(macros, record: false) { + """ + #URL("duckduckgo.com", "duckduckgo.com") + """ + } diagnostics: { + """ + #URL("duckduckgo.com", "duckduckgo.com") + ┬─────────────────────────────────────── + ╰─ πŸ›‘ #URL macro should have one String literal argument + """ + } + } + +}