diff --git a/Makefile b/Makefile index e7fab3f56..dac577380 100644 --- a/Makefile +++ b/Makefile @@ -7,36 +7,53 @@ EXECUTABLE_PATH_RELEASE = $(BUILD_PATH_RELEASE)/Toolkit BUILD_PATH_DEBUG = $(TOOLKIT_PATH)/.build/debug EXECUTABLE_PATH_DEBUG = $(BUILD_PATH_DEBUG)/Toolkit +.PHONY: clean clean: rm -rf $(TOOLKIT_PATH)/.build $(EXECUTABLE_NAME) +.PHONY: build build: clean swift build -c release --disable-sandbox --package-path $(TOOLKIT_PATH) ln -s $(EXECUTABLE_PATH_RELEASE) $(EXECUTABLE_NAME) +.PHONY: build-debug build-debug: if [ -f $(EXECUTABLE_NAME) ]; then rm $(EXECUTABLE_NAME); fi swift build --package-path $(TOOLKIT_PATH) ln -s $(EXECUTABLE_PATH_DEBUG) $(EXECUTABLE_NAME) +.PHONY: gen-docs gen-docs: ./$(EXECUTABLE_NAME) generate-documentation +.PHONY: gen-docs-and-commit gen-docs-and-commit: gen-docs ./$(TOOLKIT_PATH)/integration.sh commit_documentation +.PHONY: set-executable set-executable: ./$(EXECUTABLE_NAME) set-executable +.PHONY: set-executable-and-commit set-executable-and-commit: set-executable ./$(TOOLKIT_PATH)/integration.sh commit_executable +.PHONY: lint lint: - swiftlint lint + @echo "Linting code format with SwiftLint" + @swiftlint $(TOOLKIT_PATH) --config $(TOOLKIT_PATH)/.swiftlint.yml +.PHONY: fix fix: - swiftlint --fix + @echo "Fixing code format with SwiftLint" + @swiftlint $(TOOLKIT_PATH) --fix --config $(TOOLKIT_PATH)/.swiftlint.yml +.PHONY: format +format: + @echo "Fixing code format with SwiftFormat" + @swiftformat $(TOOLKIT_PATH) --config $(TOOLKIT_PATH)/.swiftformat + +.PHONY: open open: open -a /Applications/Xcode.app $(TOOLKIT_PATH) diff --git a/README.md b/README.md index fcccd6f14..c37130628 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ **✨ Looking to build richer extensions?** Check out the Extensions API [here](https://github.com/raycast/extensions). + 🚨 For anything that is not related to script commands, please [send us an email](mailto:feedback@raycast.com), use the feedback command within Raycast, or join the [Slack community](https://www.raycast.com/community).
@@ -35,20 +36,20 @@ To install new commands, follow these steps: 1. Choose a script from the [community repo](https://github.com/raycast/script-commands/tree/master/commands#apps) and save it into a new directory. - + Scripts containing the word `.template.` in the filename require some values to be set (check [the troubleshooting section](#troubleshooting-and-faqs) for more information). - + Alternatively, instead of creating a new directory you can reuse the repo's [`_enable-commands` folder](https://github.com/raycast/script-commands/tree/master/_enabled-commands). -3. Open the Extensions tab in the Raycast preferences -4. Click the plus button -5. Click `Add Script Directory` -6. Select directories containing your Script Commands + +2. Open the Extensions tab in the Raycast preferences +3. Click the plus button +4. Click `Add Script Directory` +5. Select directories containing your Script Commands **💡 Hint:** We recommend that you don't directly load the community script directories into Raycast to avoid potential restructuring and new script commands suddenly appearing in Raycast. ![Add directory](/images/screenshots/add-directory.png) - ## Create your own Script Commands To write your own custom Script Commands, go over the following steps: @@ -57,7 +58,7 @@ To write your own custom Script Commands, go over the following steps: 2. Write and edit your script using your favourite code editor 3. Run your Script Command from the Raycast root search -**💡 Hint:** If you choose to write your script in `Bash`, we highly recommend using the [Shellcheck](https://marketplace.visualstudio.com/items?itemName=timonwong.shellcheck) linter as this will ensure smooth running of your script. All scripts uploaded to GitHub will need to have been run through ShellCheck. +**💡 Hint:** If you choose to write your script in `Bash`, we highly recommend using the [Shellcheck](https://marketplace.visualstudio.com/items?itemName=timonwong.shellcheck) linter as this will ensure smooth running of your script. All scripts uploaded to GitHub will need to have been run through ShellCheck. ![Create Script Command](/images/screenshots/Create-Script-Command.png) @@ -65,30 +66,31 @@ To write your own custom Script Commands, go over the following steps: These parameters are available for you to customize your Script Command in Raycast. For practical examples of how these should be used, as well as best practices and supported languages, please browse our templates and community-built scripts. -| Name | Description | Required | App Version | -|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------------| -|schemaVersion | Schema version to prepare for future changes in the API. Currently there is only version 1 available. | Yes | 0.29+ | -| title | Display name of the Script Command that is shown as title in the root search. | Yes | 0.29+ | -| mode | Specifies how the script is executed and how the output is presented. [Details of the options for this parameter can be viewed here](https://github.com/raycast/script-commands/blob/master/documentation/OUTPUTMODES.md) | Yes | 0.29+ | -| packageName | Display name of the package that is shown as subtitle in the root search. When not provided, the name will be inferred from the script directory name. | No | 0.29+ | -| icon | Icon that is displayed in the root search. Can be an emoji, a file path (relative or full) or a remote URL (only https). Supported formats for images are PNG and JPEG. Please make sure to use small icons, recommended size - 64px. | No | 0.29+ | -| iconDark | Same as `icon`, but for dark theme. If not specified, then `icon` will be used in both themes. | No | 1.3.0+ | -| currentDirectoryPath | Path from which the script is executed. Default is the path of the script. | No | 0.29+ | -| needsConfirmation | Specify `true` if you would like to show confirmation alert dialog before running the script. Can be helpful with destructive scripts like "Quit All Apps" or "Empty Trash". Default value is `false`. | No | 0.30+ | -| refreshTime | Specify a refresh interval for inline mode scripts in seconds, minutes, hours or days. Examples: 10s, 1m, 12h, 1d. Note that the actual times can vary depending on how the OS prioritises scheduled work. The minimum refresh interval is 10 seconds. If you have more than 10 inline commands, only the first 10 will be refreshed automatically; the rest have to be manually refreshed by navigating to them and pressing `return`.| No | 0.31+ | -| argument[1...3] | [Custom arguments, see Passing Arguments page](https://github.com/raycast/script-commands/blob/master/documentation/ARGUMENTS.md) for detail of how to use this field | No | 1.2.0+ | -| author | Define an author name to be part of the script commands documentation | No | | -| authorURL | Author social media, website, email or anything to help the users to get in touch | No | | -| description | A brief description about the script command to be presented in the documentation | No | | +| Name | Description | Required | App Version | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------- | +| schemaVersion | Schema version to prepare for future changes in the API. Currently there is only version 1 available. | Yes | 0.29+ | +| title | Display name of the Script Command that is shown as title in the root search. | Yes | 0.29+ | +| mode | Specifies how the script is executed and how the output is presented. [Details of the options for this parameter can be viewed here](https://github.com/raycast/script-commands/blob/master/documentation/OUTPUTMODES.md) | Yes | 0.29+ | +| packageName | Display name of the package that is shown as subtitle in the root search. When not provided, the name will be inferred from the script directory name. | No | 0.29+ | +| icon | Icon that is displayed in the root search. Can be an emoji, a file path (relative or full) or a remote URL (only https). Supported formats for images are PNG and JPEG. Please make sure to use small icons, recommended size - 64px. | No | 0.29+ | +| iconDark | Same as `icon`, but for dark theme. If not specified, then `icon` will be used in both themes. | No | 1.3.0+ | +| currentDirectoryPath | Path from which the script is executed. Default is the path of the script. | No | 0.29+ | +| needsConfirmation | Specify `true` if you would like to show confirmation alert dialog before running the script. Can be helpful with destructive scripts like "Quit All Apps" or "Empty Trash". Default value is `false`. | No | 0.30+ | +| refreshTime | Specify a refresh interval for inline mode scripts in seconds, minutes, hours or days. Examples: 10s, 1m, 12h, 1d. Note that the actual times can vary depending on how the OS prioritises scheduled work. The minimum refresh interval is 10 seconds. If you have more than 10 inline commands, only the first 10 will be refreshed automatically; the rest have to be manually refreshed by navigating to them and pressing `return`. | No | 0.31+ | +| argument[1...3] | [Custom arguments, see Passing Arguments page](https://github.com/raycast/script-commands/blob/master/documentation/ARGUMENTS.md) for detail of how to use this field | No | 1.2.0+ | +| author | Define an author name to be part of the Script Commands documentation | No | | +| authorURL | Author social media, website, email or anything to help the users to get in touch | No | | +| description | A brief description about the script command to be presented in the documentation | No | | +| platform | Platform supported by the Script Command. Currently values supported: `macos` (default) and `windows` | No | | ### Output Mode You can use the standard output to present messages in Raycast. Depending on the `mode`, the standard output of your scripts is differently presented.`fullOutput` and `inline` modes support ANSI Escape codes allowing to color generated output by changing its background and foreground color. [You can view the different output mode options as well as their various forms and color options here.](https://github.com/raycast/script-commands/blob/master/documentation/OUTPUTMODES.md) - ### Error Handling If the script exits with a status code not equal to 0, Raycast interprets it as failed and shows a toast that the script failed to run. If this script has inline or compact mode, the last line of the output will be used as an error message. Consider this example for a bash script: + ```bash if ! [[ $value =~ $regex ]] ; then echo "Invalid value provided" @@ -98,20 +100,21 @@ else ``` ## Troubleshooting and FAQs +
Why isn't my script appearing in Raycast? -* Ensure the filename doesn't contain `.template.` string -* Check that all required metadata parameters are provided. See the table above which parameters are required. -* Ensure you use either `#` or `//` comments for metadata parameters -* If nothing helps, try to go step by step from a [template](https://github.com/raycast/script-commands/tree/master/templates) Script Command or use one of the examples in this repo. +- Ensure the filename doesn't contain `.template.` string +- Check that all required metadata parameters are provided. See the table above which parameters are required. +- Ensure you use either `#` or `//` comments for metadata parameters +- If nothing helps, try to go step by step from a [template](https://github.com/raycast/script-commands/tree/master/templates) Script Command or use one of the examples in this repo.
Why isn't my Shell script working? -* Ensure the filename doesn't contain `.template.` string -* Run your code through [ShellCheck](https://www.shellcheck.net/) to check for syntax errors or unexpected characters +- Ensure the filename doesn't contain `.template.` string +- Run your code through [ShellCheck](https://www.shellcheck.net/) to check for syntax errors or unexpected characters
@@ -119,6 +122,7 @@ else **We only allow Script Commands that run in a non-login shell in this repository as agreed on in our [contribution guidelines](https://github.com/raycast/script-commands/blob/master/CONTRIBUTING.md), due to any dependencies.** However, if you need to run your local script as login-shell, you can specify an argument after shebang, e.g. `#!/bin/bash -l` for bash. We also append `/usr/local/bin` to `$PATH` variable so you can use your local shell commands without any additional steps. If this is not enough, you can always extend `$PATH` by adding `export PATH='/some/extra/path:$PATH'` at the top of your script. +
## Community diff --git a/Tools/Toolkit/.editorconfig b/Tools/Toolkit/.editorconfig new file mode 100644 index 000000000..b3cc92190 --- /dev/null +++ b/Tools/Toolkit/.editorconfig @@ -0,0 +1,11 @@ +[*.swift] +indent_style = space +indent_size = 2 +tab_width = 2 +end_of_line = lf +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[*.ps1] +end_of_line = crlf diff --git a/Tools/Toolkit/.swift-version b/Tools/Toolkit/.swift-version new file mode 100644 index 000000000..e0ea36fee --- /dev/null +++ b/Tools/Toolkit/.swift-version @@ -0,0 +1 @@ +6.0 diff --git a/Tools/Toolkit/.swiftformat b/Tools/Toolkit/.swiftformat new file mode 100644 index 000000000..423c5217b --- /dev/null +++ b/Tools/Toolkit/.swiftformat @@ -0,0 +1,162 @@ +--acronyms ID,URL,UUID +--allman false +--anonymousforeach convert +--assetliterals visual-width +--asynccapturing +--beforemarks +--binarygrouping 4,8 +--callsiteparen default +--categorymark "MARK: %c" +--classthreshold 0 +--closingparen balanced +--closurevoid remove +--commas always +--complexattrs preserve +--computedvarattrs preserve +--condassignment after-property +--conflictmarkers reject +--dateformat system +--decimalgrouping 3,6 +--doccomments before-declarations +--emptybraces no-space +--enumnamespaces always +--enumthreshold 0 +--equatablemacro none +--exponentcase lowercase +--exponentgrouping disabled +--extensionacl on-extension +--extensionlength 0 +--extensionmark "MARK: - %t + %c" +--filemacro "#file" +--fractiongrouping disabled +--fragment false +--funcattributes prev-line +--generictypes +--groupblanklines true +--groupedextension "MARK: - %c" +--guardelse next-line +--header strip +--hexgrouping 4,8 +--hexliteralcase uppercase +--ifdef indent +--importgrouping alpha +--indent 2 +--indentcase false +--indentstrings false +--inferredtypes always +--initcodernil false +--inlinedforeach ignore +--lifecycle +--lineaftermarks true +--linebreaks lf +--markcategories true +--markextensions always +--marktypes always +--maxwidth 120 +--modifierorder +--nevertrailing +--nilinit remove +--noncomplexattrs +--nospaceoperators +--nowrapoperators +--octalgrouping 4,8 +--operatorfunc spaced +--organizationmode visibility +--organizetypes actor,class,enum,struct +--patternlet hoist +--preservedecls +--preserveacronyms +--preservedsymbols Package +--propertytypes infer-locals-only +--ranges spaced +--self remove +--selfrequired +--semicolons inline +--shortoptionals except-properties +--smarttabs enabled +--someany true +--sortedpatterns +--sortswiftuiprops none +--storedvarattrs preserve +--stripunusedargs always +--structthreshold 0 +--tabwidth unspecified +--throwcapturing +--timezone system +--trailingclosures +--trimwhitespace always +--typeattributes prev-line +--typeblanklines remove +--typedelimiter space-after +--typemark "MARK: - %t" +--typemarks +--typeorder +--visibilitymarks +--visibilityorder +--voidtype void +--wraparguments before-first +--wrapcollections before-first +--wrapconditions preserve +--wrapeffects preserve +--wrapenumcases always +--wrapparameters default +--wrapreturntype preserve +--wrapternary default +--wraptypealiases preserve +--xcodeindentation disabled +--xctestsymbols +--yodaswap always + +--enable acronyms +--enable blankLineAfterSwitchCase +--enable isEmpty +--enable markTypes +--enable redundantVariable +--enable sortSwitchCases +--enable wrapConditionalBodies +--enable wrapEnumCases +--enable wrapSwitchCases + +--disable assertionFailures +--disable blankLinesAtStartOfScope +--disable blankLinesBetweenChainedFunctions +--disable braces +--disable docCommentsBeforeModifiers +--disable extensionAccessControl +--disable genericExtensions +--disable headerFileName +--disable initCoderUnavailable +--disable leadingDelimiters +--disable linebreakAtEndOfFile +--disable linebreaks +--disable modifierOrder +--disable numberFormatting +--disable opaqueGenericParameters +--disable preferForLoop +--disable redundantNilInit +--disable redundantType +--disable redundantVoidReturnType +--disable spaceAroundBraces +--disable spaceAroundBrackets +--disable spaceAroundComments +--disable spaceAroundGenerics +--disable spaceAroundOperators +--disable spaceAroundParens +--disable spaceInsideBraces +--disable spaceInsideBrackets +--disable spaceInsideGenerics +--disable strongOutlets +--disable strongifiedSelf +--disable typeSugar +--disable unusedArguments +--disable void +--disable wrap +--disable wrapArguments +--disable wrapAttributes +--disable wrapLoopBodies +--disable wrapMultilineStatementBraces +--disable wrapSingleLineComments +--disable yodaConditions + +--enable fileHeader +--header \nMIT License\nCopyright (c) 2020-{year} Raycast. All rights reserved.\n diff --git a/.swiftlint.yml b/Tools/Toolkit/.swiftlint.yml similarity index 90% rename from .swiftlint.yml rename to Tools/Toolkit/.swiftlint.yml index 4b8ae8cdb..1b0a72847 100644 --- a/.swiftlint.yml +++ b/Tools/Toolkit/.swiftlint.yml @@ -1,11 +1,7 @@ indentation: 2 -included: - - Tools/Toolkit excluded: - - Tools/Toolkit/.build + - .build - _enabled-commands - - commands - - templates disabled_rules: - cyclomatic_complexity - file_length @@ -14,7 +10,6 @@ disabled_rules: - todo # Use custom_todo - unused_setter_value - generic_type_name - - identifier_name - function_parameter_count - type_name - function_body_length @@ -33,11 +28,12 @@ opt_in_rules: - file_header - overridden_super_call - sorted_imports - - unused_declaration - - unused_import - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - yoda_condition +analyzer_rules: + - unused_declaration + - unused_import indentation_width: indentation_width: 2 trailing_comma: diff --git a/Tools/Toolkit/Package.resolved b/Tools/Toolkit/Package.resolved index b28930b2b..9bac5bd61 100644 --- a/Tools/Toolkit/Package.resolved +++ b/Tools/Toolkit/Package.resolved @@ -1,25 +1,15 @@ { - "object": { - "pins": [ - { - "package": "swift-argument-parser", - "repositoryURL": "https://github.com/apple/swift-argument-parser.git", - "state": { - "branch": null, - "revision": "d2930e8fcf9c33162b9fcc1d522bc975e2d4179b", - "version": "1.0.1" - } - }, - { - "package": "swift-tools-support-core", - "repositoryURL": "https://github.com/apple/swift-tools-support-core.git", - "state": { - "branch": null, - "revision": "f9bbd6b80d67408021576adf6247e17c2e957d92", - "version": "0.2.4" - } + "originHash" : "47c7783adcb33e6ba4c3c4934f278dd5fa5bced40c9b4e101b6ea9a83858ca5c", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" } - ] - }, - "version": 1 + } + ], + "version" : 3 } diff --git a/Tools/Toolkit/Package.swift b/Tools/Toolkit/Package.swift index 2d91c0bc5..db1f7f606 100644 --- a/Tools/Toolkit/Package.swift +++ b/Tools/Toolkit/Package.swift @@ -1,39 +1,34 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "toolkit", platforms: [ - .macOS(.v11), + .macOS(.v13), ], dependencies: [ - .package( - url: "https://github.com/apple/swift-tools-support-core.git", - .upToNextMinor(from: "0.2.4") - ), .package( url: "https://github.com/apple/swift-argument-parser.git", - .upToNextMinor(from: "1.0.0") + .upToNextMajor(from: "1.5.0"), ), ], targets: [ - .target( + .executableTarget( name: "Toolkit", dependencies: [ "ToolkitLibrary", - .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] + ], ), .target( name: "ToolkitLibrary", - dependencies: [ - .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), - ] + dependencies: [], ), .testTarget( name: "ToolkitLibraryTests", - dependencies: ["ToolkitLibrary"]), - ] + dependencies: ["ToolkitLibrary"], + ), + ], + swiftLanguageModes: [.v6], ) diff --git a/Tools/Toolkit/Sources/Toolkit/SubCommands/GenerateDocumentation.swift b/Tools/Toolkit/Sources/Toolkit/SubCommands/GenerateDocumentation.swift index 97fe64d4a..480621618 100644 --- a/Tools/Toolkit/Sources/Toolkit/SubCommands/GenerateDocumentation.swift +++ b/Tools/Toolkit/Sources/Toolkit/SubCommands/GenerateDocumentation.swift @@ -1,15 +1,15 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import ArgumentParser import ToolkitLibrary extension ToolkitCommand { - struct GenerateDocumentation: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Generate the documentation in JSON and Markdown format" + struct GenerateDocumentation: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate the documentation in JSON and Markdown format", ) @Argument(help: "Path of the Raycast extensions folder.\n") @@ -18,23 +18,21 @@ extension ToolkitCommand { @Argument(help: "Output file name for the Markdown documentation.\n") var outputMarkdownFilename: String = "README.md" - @Argument(help: "Output file name for the Markdown documentation.\n") + @Argument(help: "Output file name for the JSON documentation.\n") var outputJSONFilename: String = "extensions.json" - func run() throws { + func run() async throws { do { let dataManager = try DataManager( extensionsPath: path, - extensionsFilename: outputJSONFilename + extensionsFilename: outputJSONFilename, ) - let toolkit = Toolkit( - dataManager: dataManager - ) + let toolkit = Toolkit(dataManager: dataManager) - try toolkit.generateDocumentation( + try await toolkit.generateDocumentation( outputJSONFilename: outputJSONFilename, - outputMarkdownFilename: outputMarkdownFilename + outputMarkdownFilename: outputMarkdownFilename, ) Toolkit.raycastDescription() diff --git a/Tools/Toolkit/Sources/Toolkit/SubCommands/Report.swift b/Tools/Toolkit/Sources/Toolkit/SubCommands/Report.swift deleted file mode 100644 index d59ebee8f..000000000 --- a/Tools/Toolkit/Sources/Toolkit/SubCommands/Report.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import ArgumentParser -import SwiftUI -import ToolkitLibrary - -extension ToolkitCommand { - struct Report: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Generate a report about the health of the Script Commands" - ) - - @Option( - name: [ - .customShort("t"), - .customLong("type"), - ], - help: "\(Toolkit.ReportType.allOptions)\n " - ) - var reportType: Toolkit.ReportType = .allScripts - - @Argument( - help: "Path of the Raycast extensions folder.\n " - ) - var path: String = "./commands" - - @Flag(help: "Print report without colors") - var noColor: Bool = false - - func run() throws { - do { - let dataManager = try DataManager( - extensionsPath: path - ) - - let toolkit = Toolkit( - dataManager: dataManager - ) - - try toolkit.report( - type: reportType, - noColor: noColor - ) - } catch { - Toolkit.raycastDescription() - Console.shared.writeRed("Error: \(error)") - } - } - } -} - -// MARK: - Expressible By Argument - -extension ToolkitLibrary.Toolkit.ReportType: ExpressibleByArgument { - static var allOptions: String { - Self.allValueStrings.joined(separator: "|") - } -} diff --git a/Tools/Toolkit/Sources/Toolkit/SubCommands/SetExecutable.swift b/Tools/Toolkit/Sources/Toolkit/SubCommands/SetExecutable.swift index 551b66fbe..cd9c4f731 100644 --- a/Tools/Toolkit/Sources/Toolkit/SubCommands/SetExecutable.swift +++ b/Tools/Toolkit/Sources/Toolkit/SubCommands/SetExecutable.swift @@ -1,31 +1,26 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import ArgumentParser import ToolkitLibrary extension ToolkitCommand { - struct SetExecutable: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Set file mode \"executable\" to Script Commands" + struct SetExecutable: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Set file mode \"executable\" to Script Commands", ) @Argument(help: "Path of the Raycast extensions folder.\n") var path: String = "./commands" - func run() throws { + func run() async throws { do { - let dataManager = try DataManager( - extensionsPath: path - ) + let dataManager = try DataManager(extensionsPath: path) + let toolkit = Toolkit(dataManager: dataManager) - let toolkit = Toolkit( - dataManager: dataManager - ) - - try toolkit.setScriptCommandsAsExecutable() + try await toolkit.setScriptCommandsAsExecutable() } catch { Toolkit.raycastDescription() Console.shared.writeRed("Error: \(error)") diff --git a/Tools/Toolkit/Sources/Toolkit/SubCommands/Version.swift b/Tools/Toolkit/Sources/Toolkit/SubCommands/Version.swift index 9ebf25086..ea6f0b18c 100644 --- a/Tools/Toolkit/Sources/Toolkit/SubCommands/Version.swift +++ b/Tools/Toolkit/Sources/Toolkit/SubCommands/Version.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import ArgumentParser @@ -8,8 +8,8 @@ import ToolkitLibrary extension ToolkitCommand { struct Version: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Print the current Toolkit version" + static let configuration = CommandConfiguration( + abstract: "Print the current Toolkit version", ) func run() throws { diff --git a/Tools/Toolkit/Sources/Toolkit/main.swift b/Tools/Toolkit/Sources/Toolkit/ToolkitCommand.swift similarity index 53% rename from Tools/Toolkit/Sources/Toolkit/main.swift rename to Tools/Toolkit/Sources/Toolkit/ToolkitCommand.swift index 09a50a1d7..2ab31e577 100644 --- a/Tools/Toolkit/Sources/Toolkit/main.swift +++ b/Tools/Toolkit/Sources/Toolkit/ToolkitCommand.swift @@ -1,25 +1,21 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // -import Foundation - import ArgumentParser +import Foundation import ToolkitLibrary -import TSCBasic -struct ToolkitCommand: ParsableCommand { - static var configuration = CommandConfiguration( +@main +struct ToolkitCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( commandName: "toolkit", abstract: "A tool to generate automatized documentation", subcommands: [ GenerateDocumentation.self, - Report.self, SetExecutable.self, Version.self, - ] + ], ) } - -ToolkitCommand.main() diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift index b5bd32aac..667ae9757 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift @@ -1,47 +1,69 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic -public final class Console { - private var noColor: Bool - private let terminalController: TerminalController? +// MARK: - Console + +public final class Console: @unchecked Sendable { + private enum ANSI { + static let bold = "\u{001B}[1m" + static let red = "\u{001B}[31m" + static let green = "\u{001B}[32m" + static let yellow = "\u{001B}[33m" + static let reset = "\u{001B}[0m" + } public static let shared = Console() - init(noColor: Bool = false) { - self.noColor = noColor - self.terminalController = TerminalController(stream: stdoutStream) - } + init() {} public func writeRed(_ message: String, bold: Bool = false, endLine: Bool = true) { - write(string: message, color: .red, bold: bold, endLine: endLine) + write(message, color: ANSI.red, bold: bold, endLine: endLine) } public func writeYellow(_ message: String, bold: Bool = false, endLine: Bool = true) { - write(string: message, color: .yellow, bold: bold, endLine: endLine) + write(message, color: ANSI.yellow, bold: bold, endLine: endLine) } public func writeGreen(_ message: String, bold: Bool = false, endLine: Bool = true) { - write(string: message, color: .green, bold: bold, endLine: endLine) + write(message, color: ANSI.green, bold: bold, endLine: endLine) } public func write(_ message: String, bold: Bool = false, endLine: Bool = true) { - write(string: message, color: .noColor, bold: bold, endLine: endLine) + write(message, color: nil, bold: bold, endLine: endLine) } - public func write(string: String, color: TerminalController.Color, bold: Bool = false, endLine: Bool = true) { - terminalController?.write(string, inColor: noColor ? .noColor : color, bold: bold) + public func endLine() { + print() + } +} - if endLine { - terminalController?.endLine() +// MARK: - Private + +private extension Console { + func write(_ message: String, color: String?, bold: Bool, endLine: Bool) { + var output = "" + + if bold { + output += ANSI.bold + } + if let color { + output += color } - } - public func endLine() { - terminalController?.endLine() + output += message + + if bold || color != nil { + output += ANSI.reset + } + + if endLine { + print(output) + } else { + print(output, terminator: "") + } } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Documentation/Documentation.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Documentation/Documentation.swift index 0d07a8739..ebd4ce957 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Documentation/Documentation.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Documentation/Documentation.swift @@ -1,22 +1,21 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic -final class Documentation { - private let fileSystem = TSCBasic.localFileSystem +// MARK: - Documentation - private let path: AbsolutePath +final class Documentation { + private let path: URL private let markdownFilename: String private let jsonFilename: String - init(path: AbsolutePath, jsonFilename: String, markdownFilename: String) { - self.path = path - self.jsonFilename = jsonFilename + init(path: URL, jsonFilename: String, markdownFilename: String) { + self.path = path + self.jsonFilename = jsonFilename self.markdownFilename = markdownFilename } @@ -30,31 +29,19 @@ final class Documentation { private extension Documentation { func generateMarkdown(for raycastData: RaycastData) throws { - let documentFilePath = path.appending( - component: markdownFilename - ) + let documentFilePath = path.appendingPathComponent(markdownFilename) guard let data = markdownData(for: raycastData) else { return } - try fileSystem.writeFileContents( - documentFilePath, - bytes: ByteString(data.uint8Array) - ) + try data.write(to: documentFilePath, options: .atomic) } func generateJSON(for raycastData: RaycastData) throws { - let documentFilePath = path.appending( - component: jsonFilename - ) - + let documentFilePath = path.appendingPathComponent(jsonFilename) let data = try raycastData.toData() - - try fileSystem.writeFileContents( - documentFilePath, - bytes: ByteString(data.uint8Array) - ) + try data.write(to: documentFilePath, options: .atomic) } func markdownData(for raycastData: RaycastData) -> Data? { @@ -70,40 +57,35 @@ private extension Documentation { sortedGroups.forEach { group in contentString += .newLine + group.sectionTitle - contentString += renderMarkdown(for: group) } let markdown = """ - - \(renderBadges()) + + \(renderBadges()) - # Raycast Script Commands + # Raycast Script Commands - [Raycast](https://raycast.com) lets you control your tools with a few keystrokes - and Script Commands makes it possible to execute scripts from anywhere on your desktop. - They are a great way to speed up every-day tasks such as converting data, opening bookmarks - or triggering dev workflows. + [Raycast](https://raycast.com) lets you control your tools with a few keystrokes + and Script Commands makes it possible to execute scripts from anywhere on your desktop. + They are a great way to speed up every-day tasks such as converting data, opening bookmarks + or triggering dev workflows. - This repository contains sample commands and documentation to write your own ones. + This repository contains sample commands and documentation to write your own ones. - ### Categories - \(tableOfContents)\(contentString) + ### Categories + \(tableOfContents)\(contentString) - ## Community + ## Community - This is a shared place and we're always looking for new Script Commands or other ways to improve Raycast. - If you have anything cool to show, please send us a pull request. If we screwed something up, - please report a bug. Join our - [Slack community](https://www.raycast.com/community) - to brainstorm ideas with like-minded folks. - """ - - guard let contentData = markdown.data(using: .utf8) else { - return nil - } + This is a shared place and we're always looking for new Script Commands or other ways to improve Raycast. + If you have anything cool to show, please send us a pull request. If we screwed something up, + please report a bug. Join our + [Slack community](https://www.raycast.com/community) + to brainstorm ideas with like-minded folks. + """ - return contentData + return markdown.data(using: .utf8) } func renderMarkdown(for group: Group, headline: Bool = false) -> String { @@ -116,8 +98,10 @@ private extension Documentation { } contentString += .newLine - contentString += .newLine + "| Icon | Title | Description | Author | Args | Templ | Lang |" - contentString += .newLine + "| :--: | ----- | ----------- | :----: | :--: | :---: | :--: |" + contentString += + .newLine + "| Icon | Title | Description | Author | Args | Templ | Lang | Platform |" + contentString += + .newLine + "| :--: | ----- | ----------- | :----: | :--: | :---: | :--: | :------: |" for scriptCommand in group.scriptCommands.sorted() { contentString += scriptCommand.markdownDescription @@ -128,7 +112,7 @@ private extension Documentation { for subGroup in subGroups { contentString += renderMarkdown( for: subGroup, - headline: true + headline: true, ) } } @@ -137,14 +121,16 @@ private extension Documentation { } func renderBadges() -> String { - let logo = "data:image/png;base64,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" + let logo = + "data:image/png;base64,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" - let style = "for-the-badge" + let style = "for-the-badge" let labelColor = "202123" - let dataURL = "https:%2F%2Fraw.githubusercontent.com%2Fraycast%2Fscript-commands%2Fmaster%2Fcommands%2Fextensions.json" - let jsonPath = "$.totalScriptCommands" + let dataURL = + "https:%2F%2Fraw.githubusercontent.com%2Fraycast%2Fscript-commands%2Fmaster%2Fcommands%2Fextensions.json" + let jsonPath = "$.totalScriptCommands" - let badges = """ + return """
GitHub contributors @@ -160,7 +146,5 @@ private extension Documentation {
""" - - return badges } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/GitShell.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/GitShell.swift index d66aaf365..192d75f8f 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/GitShell.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/GitShell.swift @@ -1,60 +1,57 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // -import TSCBasic -import TSCUtility +import Foundation + +// MARK: - GitError struct GitError: Error { - let result: ProcessResult + let description: String } -struct GitShell { - init() {} - - func run(_ args: String..., environment: [String: String] = Git.environment, path: AbsolutePath) throws -> String { - do { - return try execute( - ["-C", path.dirname] + args, - environment: environment - ) - } catch { - throw error - } - } - - private func execute(_ args: [String], environment: [String: String] = Git.environment) throws -> String { - let process = Process(arguments: [Git.tool] + args, environment: environment) - let result: ProcessResult - - do { - try process.launch() - result = try process.waitUntilExit() +// MARK: - GitShell - guard result.exitStatus == .terminated(code: 0) else { - throw GitError( - result: result - ) +struct GitShell { + func run(_ args: String..., path: URL) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", path.deletingLastPathComponent().path] + args + + var environment = ProcessInfo.processInfo.environment + environment["GIT_TERMINAL_PROMPT"] = "0" + process.environment = environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.terminationHandler = { proc in + let output = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .newlines) ?? "" + + if proc.terminationStatus == 0 { + continuation.resume(returning: output) + } else { + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let detail = stderr.isEmpty ? "no output" : stderr + continuation.resume( + throwing: GitError( + description: "git exited with status \(proc.terminationStatus): \(detail)", + ), + ) + } } - let content = try result.utf8Output().spm_chomp() - - return content - } catch let error as GitError { - throw error - } catch { - let result = ProcessResult( - arguments: process.arguments, - environment: process.environment, - exitStatus: .terminated(code: -1), - output: .failure(error), - stderrOutput: .failure(error) - ) - - throw GitError( - result: result - ) + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } } } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/RegEx.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/RegEx.swift index 80e6e0de2..baad7b092 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/RegEx.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/RegEx.swift @@ -1,13 +1,15 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation typealias NSTextCheckingResults = [NSTextCheckingResult] -final class RegEx { +// MARK: - RegEx + +enum RegEx { static func checkingResults(for regex: String, in text: String) -> NSTextCheckingResults { do { let regex = try NSRegularExpression( @@ -15,7 +17,7 @@ final class RegEx { options: [ .caseInsensitive, .anchorsMatchLines, - ] + ], ) let range = NSRange(text.startIndex..., in: text) @@ -27,6 +29,6 @@ final class RegEx { } static func checkingResult(for regex: String, in text: String) -> NSTextCheckingResult? { - return checkingResults(for: regex, in: text).first + checkingResults(for: regex, in: text).first } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Report/Report.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Report/Report.swift deleted file mode 100644 index 876644cd3..000000000 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Report/Report.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import Foundation -import TSCBasic - -final class Report { - private lazy var scriptCommands = ScriptCommands() - private let console: Console - - private let data: RaycastData - private let type: Toolkit.ReportType - - init(data: RaycastData, type: Toolkit.ReportType, noColor: Bool) { - self.console = Console( - noColor: noColor - ) - - self.data = data - self.type = type - } - - func showResult() { - data.groups.sorted().forEach { group in - filter( - for: group, - by: type - ) - } - - renderReport( - for: scriptCommands - ) - } -} - -// MARK: - Signs -extension Report { - enum Divider { - static let pipe = "|" - static let plus = "+" - static let minus = "-" - static let space = " " - } -} - -// MARK: - Private methods - -private extension Report { - typealias Title = (value: String, color: TerminalController.Color, bold: Bool) - typealias Titles = [Title] - typealias Cell = (title: String, length: Int, color: TerminalController.Color, bold: Bool) - typealias Cells = [Cell] - - func filter(for group: Group, by type: Toolkit.ReportType) { - if group.scriptCommands.isEmpty == false { - for scriptCommand in group.scriptCommands.sorted() { - switch (type, scriptCommand.isExecutable) { - case (.executable, true): - self.scriptCommands.append(scriptCommand) - case (.nonExecutable, false): - self.scriptCommands.append(scriptCommand) - case (.allScripts, _): - self.scriptCommands.append(scriptCommand) - default: - break - } - } - } - - if let subGroups = group.subGroups?.sorted() { - for subGroup in subGroups { - filter( - for: subGroup, - by: type - ) - } - } - } - - func renderReport(for scriptCommands: ScriptCommands) { - let raycast = "Raycast" - let cellMargin = 2 - var firstColumnLength = 0 - var secondColumnLength = 0 - let thirdColumnLength = 10 - - scriptCommands.forEach { - let author: String = $0.authors?.description ?? raycast - - if author.count >= firstColumnLength { - firstColumnLength = author.count - } - - if $0.fullPath.count >= secondColumnLength { - secondColumnLength = $0.fullPath.count - } - } - - let columnsLength = [firstColumnLength, secondColumnLength, thirdColumnLength] - - let titleCells = [ - Title(value: raycast, color: .red, bold: true), - Title(value: "Script Commands", color: .green, bold: true), - ] - - let descriptionCells = [ - Cell(title: "Author", length: firstColumnLength, color: .noColor, bold: false), - Cell(title: "Path", length: secondColumnLength, color: .noColor, bold: false), - Cell(title: "Executable", length: thirdColumnLength, color: .noColor, bold: false), - ] - - let headerWidth = columnsLength.reduce(0, +) + (descriptionCells.count * cellMargin) - - renderDivider(with: columnsLength) - renderHeader(with: headerWidth, titles: titleCells) - renderDivider(with: columnsLength) - renderRow(for: descriptionCells) - renderDivider(with: columnsLength) - - scriptCommands.forEach { - let author = $0.authors?.description ?? raycast - - let executableColor: TerminalController.Color = $0.isExecutable ? .cyan : .yellow - - let rowCells = [ - Cell(title: author, length: firstColumnLength, color: .green, bold: true), - Cell(title: $0.fullPath, length: secondColumnLength, color: .noColor, bold: false), - Cell(title: String($0.isExecutable), length: thirdColumnLength, color: executableColor, bold: !$0.isExecutable), - ] - - renderRow(for: rowCells) - } - - renderDivider(with: columnsLength) - console.write(" Total of", endLine: false) - console.write(string: " \(scriptCommands.count) ", color: .cyan, bold: true, endLine: false) - console.write("script commands") - } - - func renderHeader(with maxWidth: Int, titles: Titles) { - let titleCount = titles.map { $0.value }.joined(separator: " ").count - - let titleLength = titleCount % 2 == 0 ? titleCount : titleCount + 1 - let halfMaxWidth = maxWidth / 2 - let halfTitleWidth = titleLength / 2 - - let leadingOffset = halfMaxWidth - halfTitleWidth - let titleLeadingMargin = Divider.space.`repeat`(by: leadingOffset) - - let trailingOffset = maxWidth - (leadingOffset + titleCount) - let titleTrailingMargin = Divider.space.`repeat`(by: trailingOffset) - - console.write(Divider.pipe, endLine: false) - console.write(titleLeadingMargin, endLine: false) - - titles.enumerated().forEach { (i, title) in - if i > 0 { - console.write(Divider.space, endLine: false) - } - - console.write( - string: title.value, - color: title.color, - bold: title.bold, - endLine: false - ) - } - - console.write(titleTrailingMargin, endLine: false) - console.write(Divider.pipe) - } - - func renderRow(for cells: Cells) { - console.write(Divider.pipe, endLine: false) - - cells.forEach { cell in - let length = cell.length - cell.title.count - - var cellString = String.empty - cellString += Divider.space - cellString += cell.title - cellString += Divider.space.`repeat`(by: length) - - console.write( - string: cellString, - color: cell.color, - bold: cell.bold, - endLine: false - ) - console.write(Divider.pipe, endLine: false) - } - - console.endLine() - } - - func renderDivider(with maxWidthList: [Int]) { - var divisor = Divider.plus - - maxWidthList.forEach { maxWidth in - divisor += Divider.minus.`repeat`(by: maxWidth + 1) - divisor += Divider.plus - } - - console.write(divisor, endLine: true) - } -} - -// MARK: - Extension for Array - -private extension Array where Element == ScriptCommand.Author { - /// Return the name of the author or in case of multiple authors, just "Multiple" - var description: String { - var author = String.empty - - if count == 1 { - author = self[0].name ?? "Raycast" - } else if count > 1 { - author = "Multiple" - } - - return author - } -} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Stores/DataManager.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Stores/DataManager.swift index 9339abad9..230075789 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Stores/DataManager.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Stores/DataManager.swift @@ -1,13 +1,12 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // -import TSCBasic +import Foundation -public final class DataManager { +public actor DataManager { private var total: Int = 0 { - // FIXME: Data racing didSet { data.totalScriptCommands = total } @@ -15,33 +14,26 @@ public final class DataManager { var data = RaycastData() - let extensionsFilePath: AbsolutePath - let extensionsPath: AbsolutePath - - let fileSystem: FileSystem + let extensionsFilePath: URL + let extensionsPath: URL let ignoreGitInformation: Bool var isMetadataEmpty: Bool { data.metadata.isEmpty } - var extensionsPathString: String { - extensionsPath.pathString - } - - public init(extensionsPath: String, extensionsFilename: String = "") throws { - let fileSystem = TSCBasic.localFileSystem - let path = fileSystem.absolutePath(for: extensionsPath) - let extensionsFilePath = path.appending(RelativePath(extensionsFilename)) + public init(extensionsPath path: String, extensionsFilename: String = "") throws { + let resolvedPath = URL.resolvingPath(path) - guard fileSystem.exists(path) else { - throw ToolkitError.folderNotFound(path.pathString) + guard resolvedPath.isDirectory else { + throw ToolkitError.folderNotFound(resolvedPath.path) } - self.fileSystem = fileSystem - self.extensionsPath = path - self.extensionsFilePath = extensionsFilePath - self.ignoreGitInformation = extensionsFilename.isEmpty + extensionsPath = resolvedPath + extensionsFilePath = extensionsFilename.isEmpty + ? resolvedPath + : resolvedPath.appendingPathComponent(extensionsFilename) + ignoreGitInformation = extensionsFilename.isEmpty } func increaseTotal() { @@ -50,20 +42,26 @@ public final class DataManager { func addLanguage(_ language: String) { data.languages.insert( - Language.Information(name: language) + Language.Information(name: language), ) } + func setGroups(_ groups: Groups) { + data.groups = groups + } + func loadContent() { - if let byteString = try? fileSystem.readFileContents(extensionsPath) { - let data = byteString.contents.data + guard + !ignoreGitInformation, + let fileData = try? Data(contentsOf: extensionsFilePath) + else { + data = RaycastData() + return + } - do { - self.data = try data.decode() - } catch { - self.data = RaycastData() - } - } else { + do { + data = try fileData.decode() + } catch { data = RaycastData() } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Constants.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Constants.swift index 4558f2628..18c3b91d9 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Constants.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Constants.swift @@ -1,10 +1,9 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic public extension Toolkit { var blockedFolderList: [String] { @@ -30,7 +29,7 @@ public extension Toolkit { static var information: (name: String, version: String) { ( name: "Raycast Toolkit", - version: "0.4.0" + version: "0.5.0", ) } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+GenerateDocumentation.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+GenerateDocumentation.swift index e91945c56..fcb8ab0ac 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+GenerateDocumentation.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+GenerateDocumentation.swift @@ -1,29 +1,28 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic extension Toolkit { - public func generateDocumentation(outputJSONFilename: String, outputMarkdownFilename: String) throws { - dataManager.loadContent() + public func generateDocumentation(outputJSONFilename: String, outputMarkdownFilename: String) async throws { + await dataManager.loadContent() - try readFolderContent( + let content = try await readFolderContent( path: dataManager.extensionsPath, - parentGroups: &dataManager.data.groups, - ignoreFilesInDir: true + ignoreFilesInDir: true, ) + await dataManager.setGroups(content.subGroups) + let documentation = Documentation( path: dataManager.extensionsPath, jsonFilename: outputJSONFilename, - markdownFilename: outputMarkdownFilename + markdownFilename: outputMarkdownFilename, ) - try documentation.generateDocuments( - for: dataManager.data - ) + let data = await dataManager.data + try documentation.generateDocuments(for: data) } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Mode.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Mode.swift index f90bfd244..c40d8b77f 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Mode.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Mode.swift @@ -1,74 +1,61 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic extension Toolkit { - public func setScriptCommandsAsExecutable() throws { - var data = RaycastData() - - try readFolderContent( + public func setScriptCommandsAsExecutable() async throws { + let content = try await readFolderContent( path: dataManager.extensionsPath, - parentGroups: &data.groups, - ignoreFilesInDir: true + ignoreFilesInDir: true, ) var scriptCommands = ScriptCommands() - data.groups.forEach { group in - filter( - for: group, - scriptCommands: &scriptCommands - ) + content.subGroups.forEach { group in + filter(for: group, scriptCommands: &scriptCommands) } let rawCount = scriptCommands.count var newModeCount = 0 - scriptCommands.sorted().forEach { scriptCommand in - let filePath = dataManager.extensionsPath.appending(RelativePath(scriptCommand.fullPath)) + for scriptCommand in scriptCommands.sorted() { + let filePath = dataManager.extensionsPath + .appendingPathComponent(scriptCommand.fullPath) do { - try fileSystem.chmod(.executable, path: filePath) + try filePath.setExecutable() newModeCount += 1 } catch { - return + continue } } - let console = Console(noColor: false) - Toolkit.raycastDescription() if newModeCount > 0 { - console.write("Result:", endLine: false) - console.writeYellow(" \(newModeCount) ", bold: true, endLine: false) - console.write("of", endLine: false) - console.writeGreen(" \(rawCount) ", bold: true, endLine: false) - console.write("Script Commands was set as \"executable\".") + Console.shared.write("Result:", endLine: false) + Console.shared.writeYellow(" \(newModeCount) ", bold: true, endLine: false) + Console.shared.write("of", endLine: false) + Console.shared.writeGreen(" \(rawCount) ", bold: true, endLine: false) + Console.shared.write("Script Commands was set as \"executable\".") } else { - console.write("✅ Nothing to be done.") + Console.shared.write("✅ Nothing to be done.") } } } private extension Toolkit { func filter(for group: Group, scriptCommands: inout ScriptCommands) { - if group.scriptCommands.isEmpty == false { - for scriptCommand in group.scriptCommands where scriptCommand.isExecutable == false { - scriptCommands.append(scriptCommand) - } + for scriptCommand in group.scriptCommands where !scriptCommand.isExecutable { + scriptCommands.append(scriptCommand) } if let subGroups = group.subGroups { for subGroup in subGroups { - filter( - for: subGroup, - scriptCommands: &scriptCommands - ) + filter(for: subGroup, scriptCommands: &scriptCommands) } } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReadContent.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReadContent.swift index d8000d9f2..6851796c1 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReadContent.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReadContent.swift @@ -1,163 +1,159 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic extension Toolkit { - typealias FolderContent = (scriptCommands: ScriptCommands, readmePath: String?, groupName: String) - - @discardableResult - func readFolderContent(path: AbsolutePath, parentGroups: inout Groups, ignoreFilesInDir: Bool = false) throws -> FolderContent { - var scriptCommands = ScriptCommands() - - for directory in onlyDirectories(at: path) { - guard blockedFolderList.contains(directory.basename) == false else { - continue - } - - var group = Group( - name: directory.socialBasename, - path: directory.basenameWithoutExt - ) - - var subGroups = Groups() + typealias FolderContent = ( + scriptCommands: ScriptCommands, + readmePath: String?, + groupName: String, + subGroups: Groups, + ) + + func readFolderContent(path: URL, ignoreFilesInDir: Bool = false) async throws -> FolderContent { + let directories = onlyDirectories(at: path) + .filter { !blockedFolderList.contains($0.lastPathComponent) } + + // Process subdirectories concurrently + let subGroups: Groups = try await withThrowingTaskGroup(of: Group?.self) { taskGroup in + for directory in directories { + taskGroup.addTask { + let content = try await self.readFolderContent(path: directory) + + var group = Group( + name: directory.socialBasename, + path: directory.lastPathComponent, + ) + + if !content.groupName.isEmpty, + content.groupName.lowercased() == group.name.lowercased() { + group.name = content.groupName + } - let (scriptCommands, readmePath, groupName) = try readFolderContent(path: directory, parentGroups: &subGroups) + if !content.scriptCommands.isEmpty { + group.scriptCommands = content.scriptCommands + } - if groupName.isEmpty == false, groupName.lowercased() == group.name.lowercased() { - group.name = groupName - } + if !content.subGroups.isEmpty { + group.subGroups = content.subGroups + } - if scriptCommands.isEmpty == false { - group.scriptCommands = scriptCommands - } + if let readmePath = content.readmePath { + group.readme = readmePath + } - if subGroups.isEmpty == false { - group.subGroups = subGroups - } + guard !content.scriptCommands.isEmpty || !content.subGroups.isEmpty else { + return nil + } - if let readmePath = readmePath { - group.readme = readmePath + return group + } } - if scriptCommands.isEmpty == false || subGroups.isEmpty == false { - parentGroups.append(group) + var results = Groups() + for try await group in taskGroup { + if let group { + results.append(group) + } } + return results.sorted() } - let directoryFiles = onlyFiles(at: path) - + var scriptCommands = ScriptCommands() var groupName = "" var readmePath: String? - for file in directoryFiles where directoryFiles.isEmpty == false { - guard ignoreFilesInDir == false else { - continue - } + if !ignoreFilesInDir { + for file in onlyFiles(at: path) { + let ext = file.pathExtension - guard - let fileExtension = file.extension, - blockedFilesExtensionsList.contains(fileExtension) == false else { - continue - } - - if file.basenameWithoutExt.lowercased() == "readme" { - guard let fileContent = readContentFile(from: file), fileContent.count > 0 else { + guard !ext.isEmpty, !blockedFilesExtensionsList.contains(ext) else { continue } - let pathCount = dataManager.extensionsPathString.count + 1 - readmePath = String(file.pathString.dropFirst(pathCount)) - } else if var scriptCommand = readScriptCommand(from: file) { - // This is to avoid data racing - DispatchQueue.global(qos: .userInitiated).async { - self.dataManager.increaseTotal() - self.dataManager.addLanguage(scriptCommand.language) - } + if file.deletingPathExtension().lastPathComponent.lowercased() == "readme" { + guard let content = readContentFile(from: file), !content.isEmpty else { + continue + } + readmePath = file.relativePath(from: dataManager.extensionsPath) + } else if var scriptCommand = await readScriptCommand(from: file) { + await dataManager.increaseTotal() + await dataManager.addLanguage(scriptCommand.language) - scriptCommand.configure( - isExecutable: fileSystem.isExecutableFile(file) - ) + scriptCommand.configure(isExecutable: file.isExecutableFile) - if let packageName = scriptCommand.packageName { - groupName = packageName - } + if let packageName = scriptCommand.packageName { + groupName = packageName + } - scriptCommands.append(scriptCommand) + scriptCommands.append(scriptCommand) + } } } return ( - scriptCommands: scriptCommands, + scriptCommands: scriptCommands.sorted(), readmePath: readmePath, - groupName: groupName + groupName: groupName, + subGroups: subGroups, ) } - func readContentFile(from path: AbsolutePath) -> String? { - guard let byteString = try? fileSystem.readFileContents(path) else { + func readContentFile(from url: URL) -> String? { + guard let data = try? Data(contentsOf: url) else { return nil } - - let data = byteString.contents.data - let content = String(data: data, encoding: .utf8) - - return content + return String(data: data, encoding: .utf8) } - func extractGitDates(from filePath: AbsolutePath) -> [String]? { + func extractGitDates(from fileURL: URL) async -> [String]? { do { - let dates = try git.run( - "log", "--format=%aI", "--follow", filePath.basename, - path: filePath + let dates = try await git.run( + "log", "--format=%aI", "--follow", fileURL.lastPathComponent, + path: fileURL, ) - return dates.splitByNewLine } catch { return nil } } - func readScriptCommand(from filePath: AbsolutePath) -> ScriptCommand? { - guard fileSystem.isFile(filePath) else { + func readScriptCommand(from fileURL: URL) async -> ScriptCommand? { + guard fileURL.isFile else { return nil } - guard let fileContent = readContentFile(from: filePath) else { + guard let fileContent = readContentFile(from: fileURL) else { return nil } - let dictionary = keyValue( + let dictionary = await keyValue( for: fileContent, - filename: filePath.basename, - path: filePath + filename: fileURL.lastPathComponent, + fileURL: fileURL, ) - return ScriptCommand( - from: dictionary - ) + return ScriptCommand(from: dictionary) } - func keyValue(for content: String, filename: String, path: AbsolutePath) -> [String: Any] { + func keyValue(for content: String, filename: String, fileURL: URL) async -> [String: Any] { let filenameKey = ScriptCommand.CodingKeys.filename.rawValue let packageNameKey = ScriptCommand.CodingKeys.packageName.rawValue var dictionary = readKeyValues(of: content) dictionary[filenameKey] = filename - let pathCount = dataManager.extensionsPathString.count + 1 - let scriptPath = path.dirname.dropFirst(pathCount) + let scriptPath = fileURL.deletingLastPathComponent().relativePath(from: dataManager.extensionsPath) dictionary["path"] = "\(scriptPath)/" - if dataManager.ignoreGitInformation == false { - if let dates = extractGitDates(from: path), dates.isEmpty == false { - if let updateAt = dates.first { - dictionary["updatedAt"] = updateAt + if !dataManager.ignoreGitInformation { + if let dates = await extractGitDates(from: fileURL), !dates.isEmpty { + if let updatedAt = dates.first { + dictionary["updatedAt"] = updatedAt } - if let createdAt = dates.last { dictionary["createdAt"] = createdAt } @@ -170,7 +166,8 @@ extension Toolkit { dictionary["isTemplate"] = filename.contains("template") if dictionary[packageNameKey] == nil { - dictionary[packageNameKey] = path.basenameWithoutExt.sanitize.capitalized + dictionary[packageNameKey] = fileURL.deletingPathExtension().lastPathComponent + .sanitize.capitalized } return dictionary @@ -187,12 +184,12 @@ extension Toolkit { } let authors = extractAuthors(from: content, using: results) - if authors.isEmpty == false { + if !authors.isEmpty { dictionary["authors"] = authors } let icons = extractIcons(from: content, using: results) - if icons.isEmpty == false { + if !icons.isEmpty { dictionary["icon"] = icons } @@ -201,13 +198,18 @@ extension Toolkit { for result in results { let keyValue = readKeyValue(from: result, content: content) - guard keyValue.authorKeys == false && keyValue.iconKeys == false else { + guard !keyValue.authorKeys, !keyValue.iconKeys else { continue } dictionary.merge(keyValue) { $1 } } + // Normalize platform value to lowercase for case-insensitive enum matching + if let platformValue = dictionary["platform"] as? String { + dictionary["platform"] = platformValue.lowercased() + } + return dictionary } @@ -236,25 +238,20 @@ extension Toolkit { : values.first ?? "" } - let language = Language(String(software)) + let name = String(software).trimmedString + let language = Language(name) return language.name } func extractArguments(from content: String, using results: NSTextCheckingResults) -> Bool { - var hasArguments = false - for result in results { let dictionary = readKeyValue(from: result, content: content) - - guard dictionary.argumentsKeys else { - continue + if dictionary.argumentsKeys { + return true } - - hasArguments = true } - - return hasArguments + return false } func extractIcons(from content: String, using results: NSTextCheckingResults) -> [String: String] { @@ -299,11 +296,7 @@ extension Toolkit { continue } - guard authors.contains( - where: { - $0[key] == value - } - ) == false else { + guard !authors.contains(where: { $0[key] == value }) else { currentAuthor = [:] continue } @@ -326,7 +319,8 @@ extension Toolkit { let keyRange = result.range(withName: "key") let valueRange = result.range(withName: "value") - if let key = self.content(of: keyRange, on: content), let value = self.content(of: valueRange, on: content) { + if let key = self.content(of: keyRange, on: content), + let value = self.content(of: valueRange, on: content) { if let intValue = Int(value) { dictionary[key] = intValue } else if let boolValue = Bool(value) { @@ -340,17 +334,17 @@ extension Toolkit { } func content(of range: NSRange, on content: String) -> String? { - var value: String? - - if range.location != NSNotFound, range.length > 0, let rangeString = Range(range, in: content) { - value = String(content[rangeString]) + guard range.location != NSNotFound, + range.length > 0, + let rangeString = Range(range, in: content) + else { + return nil } - - return value + return String(content[rangeString]) } } -// MARK: - Filter Extensions +// MARK: - Filter Helpers private extension Toolkit { enum ContentDirType { @@ -358,40 +352,31 @@ private extension Toolkit { case files } - func onlyFiles(at path: AbsolutePath) -> [AbsolutePath] { - return folderContent(type: .files, for: path) + func onlyFiles(at path: URL) -> [URL] { + folderContent(type: .files, for: path) } - func onlyDirectories(at path: AbsolutePath) -> [AbsolutePath] { - return folderContent(type: .directories, for: path) + func onlyDirectories(at path: URL) -> [URL] { + folderContent(type: .directories, for: path) } - func folderContent(type: ContentDirType, for path: AbsolutePath) -> [AbsolutePath] { - do { - let directoryContent = try fileSystem.getDirectoryContents(path) - - let pathsForType: [AbsolutePath] = directoryContent.compactMap { - let contentPath = path.appending(component: $0) - - guard check(type, for: contentPath) else { - return nil - } - - return contentPath - } - - return pathsForType - } catch { + func folderContent(type: ContentDirType, for path: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: path, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles], + ) + else { return [] } + + return contents.filter { check(type, for: $0) } } - private func check(_ type: ContentDirType, for path: AbsolutePath) -> Bool { + func check(_ type: ContentDirType, for url: URL) -> Bool { switch type { - case .directories: - return fileSystem.isDirectory(path) - case .files: - return fileSystem.isFile(path) + case .directories: url.isDirectory + case .files: url.isFile } } } @@ -401,33 +386,24 @@ private extension Toolkit { private extension Dictionary where Key == String { var authorKeys: Bool { typealias Keys = ScriptCommand.Author.InputCodingKeys - let authorNameKey = Keys.name.rawValue - let authorURLKey = Keys.url.rawValue - guard let key = keys.first else { return false } - - return key == authorNameKey || key == authorURLKey + return key == Keys.name.rawValue || key == Keys.url.rawValue } var iconKeys: Bool { typealias Keys = ScriptCommand.Icon.InputCodingKeys - let iconKey = Keys.icon.rawValue - let iconDarkKey = Keys.iconDark.rawValue - guard let key = keys.first else { return false } - - return key == iconKey || key == iconDarkKey + return key == Keys.icon.rawValue || key == Keys.iconDark.rawValue } var argumentsKeys: Bool { guard let key = keys.first else { return false } - return key == "argument1" || key == "argument2" || key == "argument3" } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Report.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Report.swift deleted file mode 100644 index e534dbe11..000000000 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+Report.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import Foundation -import TSCBasic - -extension Toolkit { - public func report(type: ReportType, noColor: Bool) throws { - try readFolderContent( - path: dataManager.extensionsPath, - parentGroups: &dataManager.data.groups, - ignoreFilesInDir: true - ) - - let report = Report( - data: dataManager.data, - type: type, - noColor: noColor - ) - - report.showResult() - } -} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReportType.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReportType.swift deleted file mode 100644 index d229789c5..000000000 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit+ReportType.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import Foundation - -public extension Toolkit { - enum ReportType: String, CaseIterable, CustomStringConvertible { - case executable - case nonExecutable = "non-executable" - case allScripts = "all-scripts" - - public var description: String { - return rawValue - } - } -} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit.swift index f6be9d8de..aa25e9b7b 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Core/Toolkit/Toolkit.swift @@ -1,16 +1,12 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation -import TSCBasic - -public final class Toolkit { - lazy var fileSystem = TSCBasic.localFileSystem - - var dataManager: DataManager +public final class Toolkit: Sendable { + let dataManager: DataManager let git = GitShell() public init(dataManager: DataManager) { diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Errors/Reader+Error.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Errors/Reader+Error.swift index 40e0f9362..eae3948af 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Errors/Reader+Error.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Errors/Reader+Error.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -11,10 +11,10 @@ enum ToolkitError: Swift.Error, CustomStringConvertible, LocalizedError { var description: String { switch self { - case .folderNotFound(let folder): - return "Folder not found. Expected: \(folder)" - case .fileNotFound(let file): - return "File \"\(file)\" not found" + case let .folderNotFound(folder): + "Folder not found. Expected: \(folder)" + case let .fileNotFound(file): + "File \"\(file)\" not found" } } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+Metadata.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+Metadata.swift index 9c046c82f..dcfca6bfc 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+Metadata.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+Metadata.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+UInt8.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+UInt8.swift index c5ddba6bf..af12cff2d 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+UInt8.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Array/Array+UInt8.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -11,7 +11,7 @@ extension Array where Element == UInt8 { return Data( bytes: &array, - count: array.count + count: array.count, ) } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+Emoji.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+Emoji.swift index 59e9f7041..d9df40ba2 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+Emoji.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+Emoji.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+String.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+String.swift index eaaac0f5e..c89e6c0b6 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+String.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Character/Character+String.swift @@ -1,10 +1,10 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation extension Character { - static var newLine = Character(.newLine) + static let newLine = Character(.newLine) } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Codable/Encodable+Data.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Codable/Encodable+Data.swift index 8d42f9a85..9fae18d78 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Codable/Encodable+Data.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Codable/Encodable+Data.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Bytes.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Bytes.swift index b4daa2098..eea48eeb2 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Bytes.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Bytes.swift @@ -1,12 +1,12 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation extension Data { var uint8Array: [UInt8] { - return [UInt8](self) + [UInt8](self) } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Decodable.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Decodable.swift index aa62939f4..79c262305 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Decodable.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Data/Data+Decodable.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -10,8 +10,6 @@ extension Data { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let object = try decoder.decode(type, from: self) - - return object + return try decoder.decode(type, from: self) } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Dictionary/Dictionary+Codable.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Dictionary/Dictionary+Codable.swift index 07a17ca14..03536be7e 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Dictionary/Dictionary+Codable.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Dictionary/Dictionary+Codable.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -9,7 +9,7 @@ extension Dictionary where Key == String, Value: Any { func encodeToStruct() -> T? { do { let data = try JSONSerialization.data( - withJSONObject: self + withJSONObject: self, ) let decoder = JSONDecoder() diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/Int+Indent.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/Int+Indent.swift index 40dbfc6ad..5e570a5e0 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/Int+Indent.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/Int+Indent.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/UInt8+Data.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/UInt8+Data.swift index dc756df2a..8a73ac60c 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/UInt8+Data.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/Integer/UInt8+Data.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -11,7 +11,7 @@ extension UInt8 { return Data( bytes: &int, - count: MemoryLayout.size + count: MemoryLayout.size, ) } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Emoji.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Emoji.swift index c82d49936..c7de2f758 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Emoji.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Emoji.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Interpolation.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Interpolation.swift index c445cfb9a..458900ba6 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Interpolation.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+Interpolation.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+URL.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+URL.swift index 273beb8b4..1d7c07708 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+URL.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String+URL.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -12,7 +12,7 @@ extension String { } if let match = detector.firstMatch(in: self, options: [], range: NSRange(location: 0, length: utf16.count)) { - return match.range.length == self.utf16.count + return match.range.length == utf16.count } return false diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String.swift index 34d99a41e..7774131e6 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/String/String.swift @@ -1,11 +1,13 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import CryptoKit import Foundation +// MARK: - StringError + enum StringError: Error { case convertStringToData } @@ -26,7 +28,7 @@ extension String { text = text.replacingOccurrences( of: entity, - with: " " + with: " ", ) } @@ -70,11 +72,9 @@ extension String { let digest = Insecure.MD5.hash(data: data) - let value = digest.map { + return digest.map { String(format: "%02hhx", $0) } .joined() - - return value } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/AbsolutePath+SocialBasename.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/AbsolutePath+SocialBasename.swift deleted file mode 100644 index 6abee512e..000000000 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/AbsolutePath+SocialBasename.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import TSCBasic - -extension AbsolutePath { - var socialBasename: String { - basenameWithoutExt.sanitize.capitalized - } -} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/FileSystem+AbsolutePath.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/FileSystem+AbsolutePath.swift deleted file mode 100644 index a0488d6bd..000000000 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/TSCBasic/FileSystem+AbsolutePath.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. -// - -import TSCBasic - -extension FileSystem { - func absolutePath(for path: String) -> AbsolutePath { - if let path = try? AbsolutePath(validating: path) { - return path - } else if - let path = try? RelativePath(validating: path), - let currentWorkingDirectory = localFileSystem.currentWorkingDirectory { - return AbsolutePath( - path.pathString, - relativeTo: currentWorkingDirectory - ) - } - - return localFileSystem.homeDirectory.appending( - RelativePath(path) - ) - } -} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/URL/URL+FileSystem.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/URL/URL+FileSystem.swift new file mode 100644 index 000000000..57db1c92c --- /dev/null +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Extensions/URL/URL+FileSystem.swift @@ -0,0 +1,63 @@ +// +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. +// + +import Foundation + +extension URL { + /// The last path component without its file extension, with hyphens/underscores replaced + /// by spaces and the result capitalized. Mirrors the old `AbsolutePath.socialBasename`. + var socialBasename: String { + deletingPathExtension().lastPathComponent.sanitize.capitalized + } + + var isFile: Bool { + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + return exists && !isDir.boolValue + } + + var isDirectory: Bool { + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + return exists && isDir.boolValue + } + + var isExecutableFile: Bool { + FileManager.default.isExecutableFile(atPath: path) + } + + func setExecutable() throws { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: path, + ) + } + + /// Returns the path of `self` relative to `base`, with both sides standardized + /// so that `./`-prefixed or symlinked inputs produce the same result. + func relativePath(from base: URL) -> String { + let basePath = base.standardized.path + let selfPath = standardized.path + guard selfPath.hasPrefix(basePath) else { return selfPath } + let stripped = selfPath.dropFirst(basePath.count) + return String(stripped.hasPrefix("/") ? stripped.dropFirst() : stripped) + } + + /// Resolves a path string to an absolute URL. + /// Handles absolute paths, tilde-prefixed paths, and relative-to-cwd paths. + static func resolvingPath(_ path: String) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + + if path.hasPrefix("~") { + let expanded = NSString(string: path).expandingTildeInPath + return URL(fileURLWithPath: expanded) + } + + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + return cwd.appendingPathComponent(path) + } +} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Group.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Group.swift index 288c91759..0914e2cbc 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Group.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Group.swift @@ -1,12 +1,14 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation typealias Groups = [Group] +// MARK: - Group + struct Group: Codable { var name: String let path: String @@ -15,7 +17,7 @@ struct Group: Codable { var subGroups: Groups? } -// MARK: - MarkdownDescription Protocol +// MARK: - MarkdownDescriptionProtocol extension Group: MarkdownDescriptionProtocol { var sectionTitle: String { @@ -36,19 +38,19 @@ extension Group: MarkdownDescriptionProtocol { if let subGroups = group.subGroups?.sorted() { description += renderItem( for: group, - level: level + level: level, ) for subGroup in subGroups { description += renderTree( for: subGroup, - level: level + 1 + level: level + 1, ) } } else { description += renderItem( for: group, - level: level + level: level, ) } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language+Information.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language+Information.swift index bbe8df004..a09d49742 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language+Information.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language+Information.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // extension Language { @@ -15,12 +15,14 @@ extension Language { let language = Language(name) self.name = language.name - self.displayName = language.displayName - self.icon = language.icon + displayName = language.displayName + icon = language.icon } } } +// MARK: - Language.Information + Hashable + extension Language.Information: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(name) diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language.swift index 66ba09e5f..77cea210b 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Language.swift @@ -1,19 +1,22 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation +// MARK: - Language + enum Language { case applescript case bash + case dotnet + case node + case php + case powershell case python case ruby case swift - case node - case php - case dotnet case custom(String) private struct Meta { @@ -21,25 +24,68 @@ enum Language { let displayName: String let icon: String? let aliases: [String] + + init(name: String, displayName: String, icon: String? = nil, aliases: [String] = []) { + self.name = name + self.displayName = displayName + self.icon = icon + self.aliases = aliases + } + + static func notIdentified() -> Self { + .init( + name: .empty, + displayName: "Language Not Identified", + icon: nil, + aliases: [], + ) + } } - private static let knownCases: [(Language, Meta)] = [ - (.applescript, Meta(name: "applescript", displayName: "AppleScript", icon: "icon-applescript.png", aliases: ["osascript"])), - (.bash, Meta(name: "bash", displayName: "Bash", icon: "icon-bash.png", aliases: ["zsh", "sh"])), - (.python, Meta(name: "python", displayName: "Python", icon: "icon-python.png", aliases: ["python2", "python3"])), - (.ruby, Meta(name: "ruby", displayName: "Ruby", icon: "icon-ruby.png", aliases: [])), - (.swift, Meta(name: "swift", displayName: "Swift", icon: "icon-swift.png", aliases: [])), - (.node, Meta(name: "node", displayName: "Node", icon: "icon-nodejs.png", aliases: ["js", "zx"])), - (.php, Meta(name: "php", displayName: "PHP", icon: "icon-php.png", aliases: [])), - (.dotnet, Meta(name: "dotnet", displayName: ".NET", icon: "icon-dotnet.png", aliases: ["cs"])), + private static let knownCases: [(language: Language, meta: Meta)] = [ + ( + .applescript, + Meta(name: "applescript", displayName: "AppleScript", icon: "icon-applescript.png", aliases: ["osascript"]), + ), + ( + .bash, + Meta(name: "bash", displayName: "Bash", icon: "icon-bash.png", aliases: ["zsh", "sh"])), + ( + .dotnet, + Meta(name: "dotnet", displayName: ".NET", icon: "icon-dotnet.png", aliases: ["cs"])), + ( + .node, + Meta(name: "node", displayName: "Node", icon: "icon-nodejs.png", aliases: ["js", "zx"])), + ( + .php, + Meta(name: "php", displayName: "PHP", icon: "icon-php.png")), + ( + .powershell, + Meta(name: "pwsh", displayName: "PowerShell", icon: "icon-powershell.png", aliases: ["ps1"])), + ( + .python, + Meta(name: "python", displayName: "Python", icon: "icon-python.png", aliases: ["python2", "python3"]), + ), + ( + .ruby, + Meta(name: "ruby", displayName: "Ruby", icon: "icon-ruby.png"), + ), + ( + .swift, + Meta(name: "swift", displayName: "Swift", icon: "icon-swift.png"), + ), ] private var meta: Meta { switch self { - case .custom(let value): - return Meta(name: value, displayName: value, icon: nil, aliases: []) + case let .custom(language): + Meta(name: language, displayName: language.capitalized) + default: - return Language.knownCases.first { $0.0 == self }!.1 + Language.knownCases.first { + $0.language == self + }?.meta + ?? .notIdentified() // `.notIdentified()` should never be used } } @@ -56,19 +102,29 @@ enum Language { self = .custom(value) } - var name: String { meta.name } - var displayName: String { meta.displayName } - var icon: String? { meta.icon } + var name: String { + meta.name + } + + var displayName: String { + meta.displayName + } + + var icon: String? { + meta.icon + } } -// MARK: - +// MARK: - Equatable extension Language: Equatable {} +// MARK: - MarkdownDescriptionProtocol + extension Language: MarkdownDescriptionProtocol { var markdownDescription: String { - if let iconFilename = icon { - return "" + if let icon { + return "" } return displayName diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Metadata.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Metadata.swift index 1ece440da..6ebe2a4de 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/Metadata.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/Metadata.swift @@ -1,10 +1,12 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation +// MARK: - Metadata + struct Metadata: Codable { let date: Date var identifiers: Identifiers diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/RaycastData.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/RaycastData.swift index c78d8056f..7288bad20 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/RaycastData.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/RaycastData.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -27,11 +27,11 @@ struct RaycastData: Codable { } init() { - self.groups = .init() - self.updatedAt = Date() - self.totalScriptCommands = 0 - self.metadata = [] - self.languages = [] + groups = .init() + updatedAt = Date() + totalScriptCommands = 0 + metadata = [] + languages = [] } init(from decoder: Decoder) throws { @@ -45,15 +45,15 @@ struct RaycastData: Codable { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - self.updatedAt = dateFormatter.date(from: value) ?? Date() + updatedAt = dateFormatter.date(from: value) ?? Date() } else { - self.updatedAt = Date() + updatedAt = Date() } if let metadata = try container.decodeIfPresent([Metadata].self, forKey: .metadata) { self.metadata = metadata } else { - self.metadata = [] + metadata = [] } } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Author.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Author.swift index 0cc538cb4..b877bb80f 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Author.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Author.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation @@ -42,7 +42,7 @@ extension ScriptCommand.Author { } } -// MARK: - Comparable +// MARK: - ScriptCommand.Author + Comparable extension ScriptCommand.Author: Comparable { static func < (lhs: ScriptCommand.Author, rhs: ScriptCommand.Author) -> Bool { @@ -62,15 +62,15 @@ extension ScriptCommand.Author: Comparable { } } -// MARK: - MarkdownDescription Protocol +// MARK: - ScriptCommand.Author + MarkdownDescriptionProtocol extension ScriptCommand.Author: MarkdownDescriptionProtocol { var markdownDescription: String { - if let name = name, let url = url { + if let name, let url { return "[\(name)](\(url))" - } else if let name = name { + } else if let name { return name - } else if let url = url { + } else if let url { return url } @@ -82,7 +82,7 @@ extension ScriptCommand.Author: MarkdownDescriptionProtocol { } } -// MARK: - Authors +// MARK: - Array + MarkdownDescriptionProtocol extension Array: MarkdownDescriptionProtocol where Element == ScriptCommand.Author { var sectionTitle: String { @@ -93,7 +93,7 @@ extension Array: MarkdownDescriptionProtocol where Element == ScriptCommand.Auth var authors = String.empty for author in self { - let separator = self.separator(for: author.name ?? .empty) + let separator = separator(for: author.name ?? .empty) authors += separator + author.markdownDescription } @@ -111,6 +111,8 @@ extension Array: MarkdownDescriptionProtocol where Element == ScriptCommand.Auth } } +// MARK: - ScriptCommand.Authors.Separator + extension ScriptCommand.Authors { enum Separator { static let and = " and " diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Icon.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Icon.swift index be1e4518b..6d9e8a73f 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Icon.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Icon.swift @@ -1,10 +1,12 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation +// MARK: - ScriptCommand.Icon + extension ScriptCommand { struct Icon: Codable { let light: String? @@ -43,59 +45,58 @@ extension ScriptCommand.Icon { // MARK: - HTML Render extension ScriptCommand.Icon { - private func htmlImageTag(for lightFilepath: String?, darkFilepath: String?, path: String) -> String { - if let iconLight = lightFilepath, let iconDark = darkFilepath { - var darkURL: String { iconDark.isValidURL ? iconDark : path + iconDark } - var lightURL: String { iconLight.isValidURL ? iconLight : path + iconLight } + private func htmlImageTagFor(lightPath: String?, darkPath: String?, folderPath: String) -> String { + func resolvedURL(_ filepath: String) -> String { + filepath.isValidURL ? filepath : folderPath + filepath + } + if let lightPath, let darkPath { // This is the way to make modern HTML change images based on the theme (light or dark) used by the user - return "" - } else if let icon = lightFilepath { - var url: String { icon.isValidURL ? icon : path + icon } - return "" - } else if let icon = darkFilepath { - var url: String { icon.isValidURL ? icon : path + icon } - return "" + return "" + } else if let lightPath { + return "" + } else if let darkPath { + return "" } return .empty } func imageTag(with path: String) -> String { - if let iconLight = light, let iconDark = dark { - if iconLight.isEmoji && iconDark.isEmoji { - return iconLight - } else if iconLight.isImage && iconDark.isImage || iconLight.isValidURL && iconDark.isValidURL { - let tag = htmlImageTag( - for: iconLight, - darkFilepath: iconDark, - path: path - ) - return tag - } - } else if let iconLight = light, iconLight.isEmoji { - return iconLight - } else if let iconDark = dark, iconDark.isEmoji { - return iconDark - } else if let icon = light, icon.isImage || icon.isValidURL { - let tag = htmlImageTag( - for: icon, - darkFilepath: nil, - path: path + switch (light, dark) { + case let (light?, dark?) where light.isEmoji && dark.isEmoji: + light + + case let (light?, dark?) where (light.isImage && dark.isImage) || (light.isValidURL && dark.isValidURL): + htmlImageTagFor( + lightPath: light, + darkPath: dark, + folderPath: path, + ) + + case let (light?, nil) where light.isEmoji: + light + + case let (nil, dark?) where dark.isEmoji: + dark + + case let (light?, nil) where light.isImage || light.isValidURL: + htmlImageTagFor( + lightPath: light, + darkPath: nil, + folderPath: path, ) - return tag - } else if let icon = dark, icon.isImage || icon.isValidURL { - let tag = htmlImageTag( - for: nil, - darkFilepath: icon, - path: path + case let (nil, dark?) where dark.isImage || dark.isValidURL: + htmlImageTagFor( + lightPath: nil, + darkPath: dark, + folderPath: path, ) - return tag + default: + .empty } - - return .empty } } @@ -103,6 +104,6 @@ extension ScriptCommand.Icon { private extension String { var isImage: Bool { - hasSuffix(".png") || hasSuffix(".jpeg") || hasSuffix(".jpg") || hasSuffix(".gif") + hasSuffix(".png") || hasSuffix(".jpeg") || hasSuffix(".jpg") || hasSuffix(".gif") || hasSuffix(".svg") } } diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Mode.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Mode.swift index dcb66afaf..b0b44fe08 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Mode.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Mode.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Platform.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Platform.swift new file mode 100644 index 000000000..122332fbb --- /dev/null +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand+Platform.swift @@ -0,0 +1,30 @@ +// +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. +// + +import Foundation + +// MARK: - ScriptCommand.Platform + +extension ScriptCommand { + enum Platform: String, Codable { + case macOS = "macos" + case windows + } +} + +// MARK: - ScriptCommand.Platform + MarkdownDescriptionProtocol + +extension ScriptCommand.Platform: MarkdownDescriptionProtocol { + var markdownDescription: String { + switch self { + case .macOS: "macOS" + case .windows: "Windows" + } + } + + var sectionTitle: String { + .empty + } +} diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand.swift index 58265b460..7b2968d1f 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Models/ScriptCommand.swift @@ -1,12 +1,14 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation typealias ScriptCommands = [ScriptCommand] +// MARK: - ScriptCommand + struct ScriptCommand: Codable { let identifier: String let schemaVersion: Int @@ -26,6 +28,7 @@ struct ScriptCommand: Codable { let createdAt: String let updatedAt: String var path: String + let platform: Platform? private(set) var isExecutable: Bool = false @@ -48,20 +51,17 @@ struct ScriptCommand: Codable { case createdAt case updatedAt case path + case platform } var iconDescription: String { - guard let icon = self.icon else { + guard let icon else { return .empty } - let path = "https://raw.githubusercontent.com/raycast/script-commands/master/commands/\(self.path)" - - let tag = icon.imageTag( - with: path - ) + let path = "https://raw.githubusercontent.com/raycast/script-commands/master/commands/\(path)" - return tag + return icon.imageTag(with: path) } var fullPath: String { @@ -85,42 +85,42 @@ extension ScriptCommand { } init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container(keyedBy: CodingKeys.self) // Required - self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) - self.title = try container.decode(String.self, forKey: .title) - self.language = try container.decode(String.self, forKey: .language) - self.isTemplate = try container.decode(Bool.self, forKey: .isTemplate) - self.hasArguments = try container.decode(Bool.self, forKey: .hasArguments) - self.path = try container.decode(String.self, forKey: .path) - - let filename = try container.decode(String.self, forKey: .filename) - let createdAt = try container.decode(String.self, forKey: .createdAt) - let updatedAt = try container.decode(String.self, forKey: .updatedAt) - - self.filename = filename + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + title = try container.decode(String.self, forKey: .title) + language = try container.decode(String.self, forKey: .language) + isTemplate = try container.decode(Bool.self, forKey: .isTemplate) + hasArguments = try container.decode(Bool.self, forKey: .hasArguments) + path = try container.decode(String.self, forKey: .path) + + let filename = try container.decode(String.self, forKey: .filename) + let createdAt = try container.decode(String.self, forKey: .createdAt) + let updatedAt = try container.decode(String.self, forKey: .updatedAt) + + self.filename = filename self.createdAt = createdAt - self.updatedAt = updatedAt + self.updatedAt = updatedAt do { let value = "\(createdAt.description)\(filename)" let identifier = try value.convertToMD5() - self.identifier = identifier } catch let error as StringError { fatalError(error.localizedDescription) } // Optionals - self.mode = try container.decodeIfPresent(Mode.self, forKey: .mode) - self.packageName = try container.decodeIfPresent(String.self, forKey: .packageName) - self.icon = try container.decodeIfPresent(Icon.self, forKey: .icon) - self.details = try container.decodeIfPresent(String.self, forKey: .details) - self.currentDirectoryPath = try container.decodeIfPresent(String.self, forKey: .currentDirectoryPath) - self.needsConfirmation = try container.decodeIfPresent(Bool.self, forKey: .needsConfirmation) - self.refreshTime = try container.decodeIfPresent(String.self, forKey: .refreshTime) - self.authors = try container.decodeIfPresent(Authors.self, forKey: .authors) + mode = try container.decodeIfPresent(Mode.self, forKey: .mode) + packageName = try container.decodeIfPresent(String.self, forKey: .packageName) + icon = try container.decodeIfPresent(Icon.self, forKey: .icon) + details = try container.decodeIfPresent(String.self, forKey: .details) + currentDirectoryPath = try container.decodeIfPresent(String.self, forKey: .currentDirectoryPath) + needsConfirmation = try container.decodeIfPresent(Bool.self, forKey: .needsConfirmation) + refreshTime = try container.decodeIfPresent(String.self, forKey: .refreshTime) + authors = try container.decodeIfPresent(Authors.self, forKey: .authors) + platform = try container.decodeIfPresent(Platform.self, forKey: .platform) } func encode(to encoder: Encoder) throws { @@ -144,6 +144,7 @@ extension ScriptCommand { try container.encode(createdAt, forKey: .createdAt) try container.encode(updatedAt, forKey: .updatedAt) try container.encode(path, forKey: .path) + try container.encodeIfPresent(platform, forKey: .platform) } } @@ -161,16 +162,14 @@ extension ScriptCommand: Comparable { } } -// MARK: - MarkdownDescription Protocol +// MARK: - MarkdownDescriptionProtocol extension ScriptCommand: MarkdownDescriptionProtocol { var markdownDescription: String { - var content: String = .empty - var author = "Raycast" var details = "N/A" - if let value = self.authors { + if let value = authors { author = value.markdownDescription } @@ -178,16 +177,13 @@ extension ScriptCommand: MarkdownDescriptionProtocol { details = value.replacingOccurrences(of: "|", with: #"\|"#) } - let language = Language(self.language).markdownDescription + let language = Language(language).markdownDescription + let platformDisplay = (platform ?? .macOS).markdownDescription let scriptPath = "\(path)\(filename)" - let header = """ - | \(iconDescription) | [\(title)](\(scriptPath)) | \(details) | \(author) | \(hasArguments ? "✅" : "") | \(isTemplate ? "✅" : "") | \(language) | - """ - - content += .newLine + header + let header = "| \(iconDescription) | [\(title)](\(scriptPath)) | \(details) | \(author) | \(hasArguments ? "✅" : "") | \(isTemplate ? "✅" : "") | \(language) | \(platformDisplay) |" - return content + return .newLine + header } var sectionTitle: String { diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Protocols/MarkdownDescriptionProtocol.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Protocols/MarkdownDescriptionProtocol.swift index 988875e7a..2715b8f1e 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Protocols/MarkdownDescriptionProtocol.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Protocols/MarkdownDescriptionProtocol.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import Foundation diff --git a/Tools/Toolkit/Sources/ToolkitLibrary/Typealiases/Identifier.swift b/Tools/Toolkit/Sources/ToolkitLibrary/Typealiases/Identifier.swift index 4c4761cba..b8df65d07 100644 --- a/Tools/Toolkit/Sources/ToolkitLibrary/Typealiases/Identifier.swift +++ b/Tools/Toolkit/Sources/ToolkitLibrary/Typealiases/Identifier.swift @@ -1,6 +1,6 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // typealias Identifier = String diff --git a/Tools/Toolkit/Tests/LinuxMain.swift b/Tools/Toolkit/Tests/LinuxMain.swift index cd9eb7d75..79bbb64b4 100644 --- a/Tools/Toolkit/Tests/LinuxMain.swift +++ b/Tools/Toolkit/Tests/LinuxMain.swift @@ -1,11 +1,10 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // -import XCTest - import ToolkitLibraryTests +import XCTest var tests = [XCTestCaseEntry]() tests += ToolkitLibraryTests.allTests() diff --git a/Tools/Toolkit/Tests/ToolkitLibraryTests/ScriptCommandPlatformTests.swift b/Tools/Toolkit/Tests/ToolkitLibraryTests/ScriptCommandPlatformTests.swift new file mode 100644 index 000000000..84fe0f3b2 --- /dev/null +++ b/Tools/Toolkit/Tests/ToolkitLibraryTests/ScriptCommandPlatformTests.swift @@ -0,0 +1,111 @@ +// +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. +// + +import XCTest +@testable import ToolkitLibrary + +final class ScriptCommandPlatformTests: XCTestCase { + // MARK: - Raw Values + + func testMacOSRawValue() { + XCTAssertEqual(ScriptCommand.Platform.macOS.rawValue, "macos") + } + + func testWindowsRawValue() { + XCTAssertEqual(ScriptCommand.Platform.windows.rawValue, "windows") + } + + // MARK: - Initialisation from raw value + + func testInitFromLowercasedRawValue() { + XCTAssertEqual(ScriptCommand.Platform(rawValue: "macos"), .macOS) + XCTAssertEqual(ScriptCommand.Platform(rawValue: "windows"), .windows) + } + + func testInitFromNonLowercasedRawValueFails() { + // The keyValue parser lowercases the platform string before decoding, + // so uppercase raw values should never reach the decoder — but the enum + // itself must NOT silently accept them. + XCTAssertNil(ScriptCommand.Platform(rawValue: "macOS")) + XCTAssertNil(ScriptCommand.Platform(rawValue: "Windows")) + XCTAssertNil(ScriptCommand.Platform(rawValue: "MACOS")) + } + + // MARK: - Markdown description + + func testMacOSMarkdownDescription() { + XCTAssertEqual(ScriptCommand.Platform.macOS.markdownDescription, "macOS") + } + + func testWindowsMarkdownDescription() { + XCTAssertEqual(ScriptCommand.Platform.windows.markdownDescription, "Windows") + } + + func testNilPlatformDefaultsToMacOSInMarkdown() { + // ScriptCommand.markdownDescription uses `(platform ?? .macOS).markdownDescription` + let platform: ScriptCommand.Platform? = nil + XCTAssertEqual((platform ?? .macOS).markdownDescription, "macOS") + } + + // MARK: - Codable round-trip + + func testCodableRoundTrip() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + for platform in [ScriptCommand.Platform.macOS, .windows] { + let data = try encoder.encode(platform) + let decoded = try decoder.decode(ScriptCommand.Platform.self, from: data) + XCTAssertEqual(decoded, platform) + } + } + + // MARK: - ScriptCommand JSON decoding with platform field + + func testScriptCommandDecodesWithMacOSPlatform() throws { + let command = try makeScriptCommand(platform: "macos") + XCTAssertEqual(command.platform, .macOS) + } + + func testScriptCommandDecodesWithWindowsPlatform() throws { + let command = try makeScriptCommand(platform: "windows") + XCTAssertEqual(command.platform, .windows) + } + + func testScriptCommandDecodesWithoutPlatform() throws { + let command = try makeScriptCommand(platform: nil) + XCTAssertNil(command.platform) + } +} + +// MARK: - Helpers + +private extension ScriptCommandPlatformTests { + func makeScriptCommand(platform: String?) throws -> ScriptCommand { + var json = """ + { + "schemaVersion": 1, + "title": "Test Command", + "language": "bash", + "isTemplate": false, + "hasArguments": false, + "path": "system/", + "filename": "test.sh", + "createdAt": "2024-01-01T00:00:00+0000", + "updatedAt": "2024-01-01T00:00:00+0000" + """ + + if let platform { + json += ",\n \"platform\": \"\(platform)\"" + } + + json += "\n}" + + let data = try XCTUnwrap(json.data(using: .utf8)) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(ScriptCommand.self, from: data) + } +} diff --git a/Tools/Toolkit/Tests/ToolkitLibraryTests/ToolkitLibraryTests.swift b/Tools/Toolkit/Tests/ToolkitLibraryTests/ToolkitLibraryTests.swift index f7db1178f..a61855b7c 100644 --- a/Tools/Toolkit/Tests/ToolkitLibraryTests/ToolkitLibraryTests.swift +++ b/Tools/Toolkit/Tests/ToolkitLibraryTests/ToolkitLibraryTests.swift @@ -1,10 +1,10 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // -import XCTest import class Foundation.Bundle +import XCTest final class ToolkitLibraryTests: XCTestCase { func testExample() throws { @@ -22,22 +22,22 @@ final class ToolkitLibraryTests: XCTestCase { let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) - XCTAssertEqual(output, "OVERVIEW: A tool to generate automatized documentation\n\nUSAGE: toolkit \n\nOPTIONS:\n -h, --help Show help information.\n\nSUBCOMMANDS:\n generate-documentation Generate the documentation in JSON and Markdown format\n version Print the current Toolkit version\n\n See \'toolkit help \' for detailed help.\n") + XCTAssertEqual(output, "OVERVIEW: A tool to generate automatized documentation\n\nUSAGE: toolkit \n\nOPTIONS:\n -h, --help Show help information.\n\nSUBCOMMANDS:\n generate-documentation Generate the documentation in JSON and Markdown format\n set-executable Set file mode \"executable\" to Script Commands\n version Print the current Toolkit version\n\n See \'toolkit help \' for detailed help.\n") } /// Returns path to the built products directory. var productsDirectory: URL { #if os(macOS) - for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { - return bundle.bundleURL.deletingLastPathComponent() - } - fatalError("couldn't find the products directory") + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + return bundle.bundleURL.deletingLastPathComponent() + } + fatalError("couldn't find the products directory") #else - return Bundle.main.bundleURL + return Bundle.main.bundleURL #endif } - static var allTests = [ + @MainActor static var allTests = [ ("testExample", testExample), ] } diff --git a/Tools/Toolkit/Tests/ToolkitLibraryTests/XCTestManifests.swift b/Tools/Toolkit/Tests/ToolkitLibraryTests/XCTestManifests.swift index b9e2d0fcf..ca07179da 100644 --- a/Tools/Toolkit/Tests/ToolkitLibraryTests/XCTestManifests.swift +++ b/Tools/Toolkit/Tests/ToolkitLibraryTests/XCTestManifests.swift @@ -1,14 +1,14 @@ // -// MIT License -// Copyright (c) 2020-2021 Raycast. All rights reserved. +// MIT License +// Copyright (c) 2020-2026 Raycast. All rights reserved. // import XCTest #if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(ToolkitLibraryTests.allTests), - ] -} + public func allTests() -> [XCTestCaseEntry] { + [ + testCase(ToolkitLibraryTests.allTests), + ] + } #endif diff --git a/commands/images/icon-powershell.png b/commands/images/icon-powershell.png new file mode 100644 index 000000000..290f35ab9 Binary files /dev/null and b/commands/images/icon-powershell.png differ