├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ └── release.yml ├── .gitignore ├── Changelog.md ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── CLI │ ├── XCResourceCommand.swift │ ├── XCSnippet │ │ ├── InstallSnippetsCommand.swift │ │ ├── ListSnippetsCommand.swift │ │ ├── OpenSnippetsCommand.swift │ │ ├── RemoveSnippetsCommand.swift │ │ └── XCSnippetCommand.swift │ ├── XCTemplate │ │ ├── InstallTemplatesCommand.swift │ │ ├── ListTemplatesCommand.swift │ │ ├── OpenTemplatesCommand.swift │ │ ├── RemoveTemplatesCommand.swift │ │ └── XCTemplateCommand.swift │ └── main.swift └── XCResource │ ├── Shared │ ├── Extension │ │ ├── FileManager+Temporary.swift │ │ └── URL+Convenience.swift │ └── Shell.swift │ ├── XCSnippet │ ├── File │ │ ├── SnippetFileToSnippetMapper.swift │ │ ├── XCSnippetCoder.swift │ │ ├── XCSnippetFile.swift │ │ ├── XCSnippetFileManager.swift │ │ ├── XCSnippetFileParser.swift │ │ └── XCSnippetFileSummaryTagger.swift │ ├── XCSnippet.swift │ ├── XCSnippetCLI.swift │ ├── XCSnippetDownloadingStrategy.swift │ ├── XCSnippetFolderURLProviding.swift │ ├── XCSnippetLibrary.swift │ ├── XCSnippetList.swift │ ├── XCSnippetNamespace.swift │ ├── XCSnippetNamespaceMapper.swift │ ├── XCSnippetSource.swift │ └── XCSnippetsDownloader.swift │ └── XCTemplate │ ├── File │ ├── XCTemplateFile.swift │ ├── XCTemplateFileManager.swift │ ├── XCTemplateFolderFile.swift │ └── XCTemplateFolderMapper.swift │ ├── URLInputParser.swift │ ├── XCTemplate.swift │ ├── XCTemplateCLI.swift │ ├── XCTemplateFolder.swift │ ├── XCTemplateFolderDownloadingStrategy.swift │ ├── XCTemplateFolderDownloadingStrategyFactory.swift │ ├── XCTemplateLibrary.swift │ ├── XCTemplateNamespace.swift │ ├── XCTemplateNamespaceFolderURLProviding.swift │ ├── XCTemplateSource.swift │ └── XCTemplatesDownloader.swift └── Tests ├── LinuxMain.swift └── XCResourceTests ├── URLInputParserTests.swift ├── Utils ├── DynamicXCSnippetFolder.swift ├── DynamicXCTemplateFolder.swift ├── FileManager+Temporary.swift ├── GitRepository.swift └── TestXCTemplateFolderURLProvider.swift ├── XCSnippetDownloaderTests.swift ├── XCSnippetFileManagerTests.swift ├── XCSnippetFileSummaryTaggerTests.swift ├── XCTemplateDownloaderTests.swift ├── XCTemplateFileManagerTests.swift └── XCTestManifests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macOS-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Build and Test 14 | run: swift test 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | formula: 9 | name: Update Homebrew formula 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Update the Homebrew formula with latest release 13 | uses: NSHipster/update-homebrew-formula-action@main 14 | with: 15 | repository: faberNovel/xcresource-cli 16 | tap: faberNovel/homebrew-formulae 17 | formula: Formula/xcresource.rb 18 | env: 19 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN_CI }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | release: 10 | name: Create a release on GitHub 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Get the version 18 | id: get_version 19 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 20 | - name: Get Changelog Entries 21 | id: changelog 22 | uses: mindsers/changelog-reader-action@v2 23 | with: 24 | version: ${{ steps.get_version.outputs.VERSION }} 25 | path: ./Changelog.md 26 | - name: Create Release 27 | uses: actions/create-release@v1 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN_CI }} 30 | with: 31 | tag_name: ${{ github.ref }} 32 | release_name: ${{ github.ref }} 33 | body: ${{ steps.changelog.outputs.changes }} 34 | draft: false 35 | prerelease: contains(github.ref, '-') 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | DerivedData 3 | /.previous-build 4 | xcuserdata 5 | .DS_Store 6 | *~ 7 | \#* 8 | .\#* 9 | .*.sw[nop] 10 | *.xcscmblueprint 11 | /default.profraw 12 | *.xcodeproj 13 | Utilities/Docker/*.tar.gz 14 | .swiftpm 15 | /build 16 | *.pyc -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | ## [0.1.4] 7 | 8 | ### Fixed 9 | 10 | - Fixed git URL encoding 11 | 12 | ## [0.1.3] 13 | 14 | ### Fixed 15 | 16 | - Fixed relative URL parameters #7 17 | - Fixed empty code snippets folder #11 18 | 19 | ### Updated 20 | 21 | - Updated `xcresource snippet remove`: it can not delete custom snippets 22 | - Updated CLI messages 23 | 24 | ## [0.1.2] 25 | 26 | ### Updated 27 | 28 | - Updated namespace format: two new lines now precedes the `Namespace:` indicator. 29 | 30 | ## [0.1.1] 31 | 32 | ### Fixed 33 | 34 | - Fixed MakeFile 35 | 36 | ## [0.1.0] 37 | 38 | ### Added 39 | 40 | - Added support for Xcode snippets #8 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | 3 | prefix ?= /usr/local 4 | bindir ?= $(prefix)/bin 5 | srcdir = Sources 6 | 7 | REPODIR = $(shell pwd) 8 | BUILDDIR = $(REPODIR)/.build 9 | SOURCES = $(wildcard $(srcdir)/**/*.swift) 10 | 11 | .DEFAULT_GOAL = all 12 | 13 | .PHONY: all 14 | all: xcresource 15 | 16 | xcresource: $(SOURCES) 17 | @swift build \ 18 | -c release \ 19 | --disable-sandbox \ 20 | --build-path "$(BUILDDIR)" 21 | 22 | .PHONY: install 23 | install: xcresource 24 | @install -d "$(bindir)" 25 | @install "$(BUILDDIR)/release/xcresource" "$(bindir)" 26 | 27 | .PHONY: uninstall 28 | uninstall: 29 | @rm -rf "$(bindir)/xcresource" 30 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "831ed5e860a70e745bc1337830af4786b2576881", 10 | "version": "0.4.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "XCResource", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | ], 11 | products: [ 12 | .executable(name: "xcresource", targets: ["CLI"]), 13 | .library(name: "XCResource", targets: ["XCResource"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.4.1") 17 | ], 18 | targets: [ 19 | .target(name: "XCResource", dependencies: []), 20 | .target( 21 | name: "CLI", 22 | dependencies: [ 23 | "XCResource", 24 | .product(name: "ArgumentParser", package: "swift-argument-parser") 25 | ] 26 | ), 27 | .testTarget( 28 | name: "XCResourceTests", 29 | dependencies: ["XCResource"] 30 | ), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xcresource 2 | 3 | A Xcode resource manager. Use it to download Xcode templates or snippets from git repositories. 4 | 5 | ## Requirements 6 | 7 | - Swift 5.2 8 | - **Xcode 11.4** or later 9 | - macOS 10.5 10 | 11 | ## Installation 12 | 13 | ### Homebrew 14 | 15 | Run the following command to install using Homebrew: 16 | 17 | ``` 18 | $ brew install fabernovel/formulae/xcresource 19 | ``` 20 | (this will also install the `cmake` dependency) 21 | 22 | To uninstall it: 23 | ``` 24 | $ brew uninstall xcresource 25 | ``` 26 | 27 | ### Manually 28 | 29 | Run the following commands to build and install manually: 30 | ``` 31 | $ git clone https://github.com/faberNovel/xcresource-cli 32 | $ cd xcresource-cli 33 | $ make install 34 | ``` 35 | 36 | ## Usage 37 | 38 | ``` 39 | OVERVIEW: A Swift command-line tool to manage Xcode resources. 40 | 41 | USAGE: xcresource 42 | 43 | OPTIONS: 44 | -h, --help Show help information. 45 | 46 | SUBCOMMANDS: 47 | template A command to manage Xcode templates. 48 | snippet A command to manage Xcode snippets. 49 | ``` 50 | 51 | ### xcresource template 52 | 53 | ``` 54 | OVERVIEW: A Swift command-line tool to manage Xcode templates. 55 | 56 | USAGE: xcresource template 57 | 58 | OPTIONS: 59 | -h, --help Show help information. 60 | 61 | SUBCOMMANDS: 62 | install Install Xcode templates from a git repository. 63 | remove Remove Xcode templates. 64 | list List Xcode templates. 65 | open Open Xcode templates folder. 66 | ``` 67 | 68 | Running `xcresource template install` installs the [Fabernovel templates](https://github.com/faberNovel/CodeSnippet_iOS/blob/master/CodeSnippet.md) under the `FABERNOVEL` namespace. 69 | 70 | ### xctemplate snippet 71 | 72 | ``` 73 | OVERVIEW: A Swift command-line tool to manage Xcode snippets. 74 | 75 | USAGE: xcresource snippet 76 | 77 | OPTIONS: 78 | -h, --help Show help information. 79 | 80 | SUBCOMMANDS: 81 | install Install Xcode snippets from a git repository. 82 | remove Remove Xcode snippet. 83 | list List Xcode snippet. 84 | open Open Xcode snippets folder. 85 | ``` 86 | 87 | Running `xcresource snippet install` installs the [Fabernovel snippets](https://github.com/faberNovel/CodeSnippet_iOS/blob/master/XcodeSnippet.md) under the FABERNOVEL namespace. 88 | 89 | ## Contributing 90 | 91 | ### To test 92 | 93 | ``` 94 | make install 95 | ``` 96 | -------------------------------------------------------------------------------- /Sources/CLI/XCResourceCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | 5 | struct XCResourceCommand: ParsableCommand { 6 | 7 | static let configuration = CommandConfiguration( 8 | commandName: "xcresource", 9 | abstract: "A command-line tool to manage Xcode resources.", 10 | subcommands: [ 11 | XCTemplateCommand.self, 12 | XCSnippetCommand.self 13 | ] 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CLI/XCSnippet/InstallSnippetsCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct InstallSnippetsCommand: ParsableCommand { 7 | 8 | enum Error: Swift.Error { 9 | case invalidURL 10 | } 11 | 12 | @Option( 13 | name: .shortAndLong, 14 | help: "The snippet Git repository url. can be a local directory path: ./src/my_template_repo." 15 | ) 16 | var url: String = "https://github.com/faberNovel/CodeSnippet_iOS.git" 17 | 18 | @Option( 19 | name: .shortAndLong, 20 | help: "Namespaces are not visible in Xcode. A namespace acts as an installation folder. The snippets will be installed inside it. If the namespace already exists, it is replaced." 21 | ) 22 | var namespace: String = "FABERNOVEL" 23 | 24 | @Option( 25 | name: .shortAndLong, 26 | help: "The templates subdirectory path inside the repository." 27 | ) 28 | var snippetsPath: String = "XCSnippet" 29 | 30 | @Option( 31 | name: .shortAndLong, 32 | help: "The targeted repo pointer (branch or tag)." 33 | ) 34 | var pointer: String = "master" 35 | 36 | public static let configuration = CommandConfiguration( 37 | commandName: "install", 38 | abstract: "Install Xcode snippets from a git repository." 39 | ) 40 | 41 | // MARK: - ParsableCommand 42 | 43 | func run() throws { 44 | let cli = XCSnippetCLI() 45 | let list = try cli.installSnippets( 46 | url: url, 47 | pointer: pointer, 48 | namespace: namespace, 49 | snippetsPath: snippetsPath 50 | ) 51 | print("🎉 \(list.snippets.count) snippets successfully installed") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/CLI/XCSnippet/ListSnippetsCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct ListSnippetsCommand: ParsableCommand { 7 | 8 | @Option( 9 | name: .shortAndLong, 10 | help: "The snippet namespace to list. All namespaces are listed if not specified." 11 | ) 12 | var namespace: String? 13 | 14 | public static let configuration = CommandConfiguration( 15 | commandName: "list", 16 | abstract: "List Xcode snippet." 17 | ) 18 | 19 | // MARK: - ParsableCommand 20 | 21 | func run() throws { 22 | let cli = XCSnippetCLI() 23 | try cli.snippetList(namespace: namespace).forEach { namespace, list in 24 | print("#", namespace.name) 25 | list.snippets.forEach { snippet in 26 | print("-", snippet.name) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/CLI/XCSnippet/OpenSnippetsCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct OpenSnippetsCommand: ParsableCommand { 7 | 8 | public static let configuration = CommandConfiguration( 9 | commandName: "open", 10 | abstract: "Open Xcode snippets folder." 11 | ) 12 | 13 | // MARK: - ParsableCommand 14 | 15 | func run() throws { 16 | try XCSnippetCLI().openSnippetFolder() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CLI/XCSnippet/RemoveSnippetsCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct RemoveSnippetsCommand: ParsableCommand { 7 | 8 | @Option( 9 | name: .shortAndLong, 10 | help: "The snippet namespace to delete. All the snippets are deleted if not specified." 11 | ) 12 | var namespace: String? 13 | 14 | public static let configuration = CommandConfiguration( 15 | commandName: "remove", 16 | abstract: "Remove Xcode snippet." 17 | ) 18 | 19 | // MARK: - ParsableCommand 20 | 21 | func run() throws { 22 | try XCSnippetCLI().removeSnippets(namespace: namespace) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/CLI/XCSnippet/XCSnippetCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | 5 | struct XCSnippetCommand: ParsableCommand { 6 | 7 | static let configuration = CommandConfiguration( 8 | commandName: "snippet", 9 | abstract: "A command to manage Xcode snippets.", 10 | subcommands: [ 11 | InstallSnippetsCommand.self, 12 | RemoveSnippetsCommand.self, 13 | ListSnippetsCommand.self, 14 | OpenSnippetsCommand.self 15 | ] 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CLI/XCTemplate/InstallTemplatesCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallTemplatesCommand.swift 3 | // 4 | // 5 | // Created by Gaétan Zanella on 30/04/2020. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | import XCResource 11 | 12 | struct InstallTemplatesCommand: ParsableCommand { 13 | 14 | enum Error: Swift.Error { 15 | case invalidURL 16 | } 17 | 18 | @Option( 19 | name: .shortAndLong, 20 | help: "The templates Git repository url. can be a local directory path: ./src/my_template_repo." 21 | ) 22 | var url: String = "https://github.com/faberNovel/CodeSnippet_iOS.git" 23 | 24 | @Option( 25 | name: .shortAndLong, 26 | help: "Namespaces are not visible in Xcode. A namespace acts as an installation folder. The templates will be installed inside it. If the namespace already exists, it is replaced." 27 | ) 28 | var namespace: String = "FABERNOVEL" 29 | 30 | @Option( 31 | name: .shortAndLong, 32 | help: "The templates subdirectory path inside the repository." 33 | ) 34 | var templatesPath: String = "XCTemplate" 35 | 36 | @Option( 37 | name: .shortAndLong, 38 | help: "The targeted repo pointer (branch or tag)." 39 | ) 40 | var pointer: String = "master" 41 | 42 | public static let configuration = CommandConfiguration( 43 | commandName: "install", 44 | abstract: "Install Xcode templates from a git repository." 45 | ) 46 | 47 | // MARK: - ParsableCommand 48 | 49 | func run() throws { 50 | let folder = try XCTemplateCLI().downloadTemplates( 51 | url: url, 52 | pointer: pointer, 53 | namespace: namespace, 54 | templatesPath: templatesPath 55 | ) 56 | print("🎉 \(folder.templateCount()) templates successfully installed") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CLI/XCTemplate/ListTemplatesCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct ListTemplatesCommand: ParsableCommand { 7 | 8 | @Option( 9 | name: .shortAndLong, 10 | help: "The template namespace to list. All namespaces are listed if not specified." 11 | ) 12 | var namespace: String? 13 | 14 | public static let configuration = CommandConfiguration( 15 | commandName: "list", 16 | abstract: "List Xcode templates." 17 | ) 18 | 19 | // MARK: - ParsableCommand 20 | 21 | func run() throws { 22 | let folder = try XCTemplateCLI().templateFolder(namespace: namespace) 23 | if folder.isEmpty() { 24 | print("No templates installed") 25 | } else { 26 | folder.describe() 27 | } 28 | } 29 | } 30 | 31 | private extension XCTemplateFolder { 32 | 33 | func describe(depth: Int = 0) { 34 | if depth > 0 { 35 | let prefix = (0..", template.name) 40 | } 41 | folders.forEach { folder in 42 | folder.describe(depth: depth + 1) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CLI/XCTemplate/OpenTemplatesCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct OpenTemplatesCommand: ParsableCommand { 7 | 8 | public static let configuration = CommandConfiguration( 9 | commandName: "open", 10 | abstract: "Open Xcode templates folder." 11 | ) 12 | 13 | // MARK: - ParsableCommand 14 | 15 | func run() throws { 16 | try XCTemplateCLI().openRootTemplateFolder() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CLI/XCTemplate/RemoveTemplatesCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | import XCResource 5 | 6 | struct RemoveTemplatesCommand: ParsableCommand { 7 | 8 | @Option( 9 | name: .shortAndLong, 10 | help: "The template namespace to delete." 11 | ) 12 | var namespace: String = "FABERNOVEL" 13 | 14 | public static let configuration = CommandConfiguration( 15 | commandName: "remove", 16 | abstract: "Remove Xcode templates." 17 | ) 18 | 19 | // MARK: - ParsableCommand 20 | 21 | func run() throws { 22 | try XCTemplateCLI().removeTemplates(namespace: namespace) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/CLI/XCTemplate/XCTemplateCommand.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import ArgumentParser 4 | 5 | struct XCTemplateCommand: ParsableCommand { 6 | 7 | static let configuration = CommandConfiguration( 8 | commandName: "template", 9 | abstract: "A command to manage Xcode templates.", 10 | subcommands: [ 11 | InstallTemplatesCommand.self, 12 | RemoveTemplatesCommand.self, 13 | ListTemplatesCommand.self, 14 | OpenTemplatesCommand.self 15 | ] 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CLI/main.swift: -------------------------------------------------------------------------------- 1 | 2 | XCResourceCommand.main() 3 | -------------------------------------------------------------------------------- /Sources/XCResource/Shared/Extension/FileManager+Temporary.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | extension FileManager { 5 | 6 | func createTemporarySubdirectory() throws -> URL { 7 | let url = temporaryDirectory.appendingPathComponent(UUID().uuidString) 8 | try createDirectory( 9 | at: url, 10 | withIntermediateDirectories: true, 11 | attributes: nil 12 | ) 13 | return url 14 | } 15 | 16 | func contentsOfDirectory(at url: URL) throws -> [URL] { 17 | try contentsOfDirectory(atPath: url.path).map { 18 | url.appendingPathComponent($0) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/XCResource/Shared/Extension/URL+Convenience.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | extension URL { 5 | 6 | var isTemplate: Bool { 7 | pathExtension == "xctemplate" 8 | } 9 | 10 | var isDirectory: Bool { 11 | (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false 12 | } 13 | 14 | var isSnippet: Bool { 15 | pathExtension == "codesnippet" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/XCResource/Shared/Shell.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct GitReference { 5 | 6 | let name: String 7 | 8 | public init(_ name: String) { 9 | self.name = name 10 | } 11 | } 12 | 13 | enum ShellCommand { 14 | case open(path: String) 15 | case gitDownload(url: URL, reference: GitReference, destination: URL) 16 | } 17 | 18 | class Shell { 19 | 20 | var currentDirectoryPath = "~" 21 | 22 | private struct Error: Swift.Error { 23 | let output: String 24 | } 25 | 26 | func execute(_ command: ShellCommand) throws { 27 | try execute(command.shell()) 28 | } 29 | 30 | func changeCurrentDirectoryPath(_ path: String) { 31 | currentDirectoryPath = path 32 | } 33 | 34 | @discardableResult 35 | func execute(_ command: String) throws -> String { 36 | let task = Process() 37 | task.launchPath = "/bin/bash" 38 | task.currentDirectoryPath = currentDirectoryPath 39 | task.arguments = ["-c", command] 40 | let pipe = Pipe() 41 | task.standardOutput = pipe 42 | task.standardError = pipe 43 | task.launch() 44 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 45 | let output = String(data: data, encoding: .utf8) ?? "" 46 | task.waitUntilExit() 47 | if task.terminationStatus != 0 { 48 | throw Error(output: output) 49 | } 50 | return output 51 | } 52 | } 53 | 54 | private extension ShellCommand { 55 | 56 | func shell() -> String { 57 | switch self { 58 | case let .gitDownload(url, reference, destination): 59 | return "git clone -b '\(reference.name)' --single-branch --depth 1 \(url.toString()) \(destination.toString())" 60 | case let .open(path): 61 | return "open \(path)" 62 | } 63 | } 64 | } 65 | 66 | private extension URL { 67 | 68 | func toString() -> String { 69 | if isFileURL { 70 | return path // we remove the file: prefix 71 | } else { 72 | return absoluteString 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/SnippetFileToSnippetMapper.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct SnippetFileToSnippetMapper { 5 | 6 | func map(_ file: XCSnippetFile) -> XCSnippet { 7 | XCSnippet(identifier: file.identifier, name: file.name) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/XCSnippetCoder.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCSnippetCoder { 5 | 6 | enum CodingError: Error { 7 | case invalidData 8 | } 9 | 10 | func decodeSnippet(from data: Data) throws -> [String: Any] { 11 | guard let file = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { 12 | throw CodingError.invalidData 13 | } 14 | return file 15 | } 16 | 17 | func encodeSnippet(_ snippet: [String: Any]) throws -> Data { 18 | try PropertyListSerialization.data(fromPropertyList: snippet, format: .xml, options: .zero) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/XCSnippetFile.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct XCSnippetFile { 5 | 6 | struct Tag: Hashable { 7 | 8 | let identifier: String 9 | 10 | static let unspecified = Tag(identifier: UUID().uuidString) 11 | static func custom(_ id: String) -> Tag { 12 | Tag(identifier: id) 13 | } 14 | } 15 | 16 | let identifier: String 17 | let name: String 18 | let tag: Tag 19 | } 20 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/XCSnippetFileManager.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCSnippetFileManager { 5 | 6 | private let fileManager: FileManager 7 | private let coder = XCSnippetCoder() 8 | 9 | // MARK: - Life Cycle 10 | 11 | init(fileManager: FileManager) { 12 | self.fileManager = fileManager 13 | } 14 | 15 | // MARK: - Public 16 | 17 | func snippets(at url: URL) throws -> [XCSnippetFile] { 18 | try enumerateSnippets(at: url).map { $1 } 19 | } 20 | 21 | func snippets(at url: URL, with tag: XCSnippetFile.Tag) throws -> [XCSnippetFile] { 22 | try snippets(at: url).filter { $0.tag == tag } 23 | } 24 | 25 | func snippetTags(at url: URL) throws -> Set { 26 | Set(try enumerateSnippets(at: url).map { $1.tag }) 27 | } 28 | 29 | func removeSnippets(with tag: XCSnippetFile.Tag, at url: URL) throws { 30 | try enumerateSnippets(at: url) 31 | .filter { $1.tag == tag } 32 | .forEach { url, _ in 33 | try fileManager.removeItem(at: url) 34 | } 35 | } 36 | 37 | func tagSnippets(at url: URL, tag: XCSnippetFile.Tag) throws { 38 | try enumerateSnippets(at: url).forEach { url, _ in 39 | let data = try Data(contentsOf: url) 40 | let parser = try XCSnippetFileParser(data: data) 41 | try parser.tag(tag) 42 | try coder.encodeSnippet(parser.snippetContent).write(to: url) 43 | } 44 | } 45 | 46 | func copySnippets(at origin: URL, 47 | to destination: URL) throws { 48 | try enumerateSnippets(at: origin).forEach { url, _ in 49 | try fileManager.copyItem( 50 | at: url, 51 | to: destination.appendingPathComponent(url.lastPathComponent) 52 | ) 53 | } 54 | } 55 | 56 | // MARK: - Private 57 | 58 | private func enumerateSnippets(at url: URL) throws -> [(URL, XCSnippetFile)] { 59 | try fileManager.contentsOfDirectory(at: url) 60 | .compactMap { url -> (URL, XCSnippetFile)? in 61 | guard url.isSnippet else { return nil } 62 | let data = try Data(contentsOf: url) 63 | let parser = try XCSnippetFileParser(data: data) 64 | return (url, XCSnippetFile( 65 | identifier: url.deletingPathExtension().lastPathComponent, 66 | name: try parser.name(), 67 | tag: try parser.tag() 68 | )) 69 | } 70 | .sorted { 71 | $0.1.identifier < $1.1.identifier 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/XCSnippetFileParser.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCSnippetFileParser { 5 | 6 | private enum Constants { 7 | static let tagKey = "IDECodeSnippetSummary" 8 | static let nameKey = "IDECodeSnippetTitle" 9 | } 10 | 11 | private let tagger = XCSnippetFileSummaryTagger() 12 | 13 | enum ParsingError: Error { 14 | case invalidData 15 | } 16 | 17 | private(set) var snippetContent: [String: Any] 18 | 19 | init(data: Data) throws { 20 | snippetContent = try XCSnippetCoder().decodeSnippet(from: data) 21 | } 22 | 23 | init(snippetContent: [String: Any]) { 24 | self.snippetContent = snippetContent 25 | } 26 | 27 | func tag(_ tag: XCSnippetFile.Tag) throws { 28 | let content = (snippetContent[Constants.tagKey] as? String) ?? "" 29 | snippetContent[Constants.tagKey] = tagger.tag(content, tag: tag.identifier) 30 | } 31 | 32 | func tag() throws -> XCSnippetFile.Tag { 33 | guard let content = snippetContent[Constants.tagKey] as? String else { 34 | return .unspecified 35 | } 36 | return tagger.tag(in: content).flatMap { XCSnippetFile.Tag(identifier: $0) } ?? .unspecified 37 | } 38 | 39 | func name() throws -> String { 40 | guard let name = snippetContent[Constants.nameKey] as? String else { 41 | throw ParsingError.invalidData 42 | } 43 | return name 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/File/XCSnippetFileSummaryTagger.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCSnippetFileSummaryTagger { 5 | 6 | private let marker = "Namespace: " 7 | 8 | func clearTags(_ content: String) -> String { 9 | let scanner = Scanner(string: content) 10 | scanner.charactersToBeSkipped = [] 11 | var sanitized = "" 12 | scanner.skipNamespaces(marker: marker) 13 | while let remaining = scanner.scanUpToString(marker) { 14 | sanitized += remaining 15 | scanner.skipNamespaces(marker: marker) 16 | } 17 | if let end = scanner.scanToEnd() { 18 | sanitized += end 19 | } 20 | sanitized = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) 21 | return sanitized 22 | } 23 | 24 | func tag(_ content: String, tag: String) -> String { 25 | var result = clearTags(content) 26 | if !result.isEmpty && !result.hasSuffix("\n\n") { 27 | result += result.hasSuffix("\n") ? "\n" : "\n\n" 28 | } 29 | result += "\(marker)\(tag)" 30 | return result 31 | } 32 | 33 | func tag(in content: String) -> String? { 34 | let scanner = Scanner(string: content) 35 | if let tag = scanner.scanNamespace(marker: marker) { 36 | return tag 37 | } else if scanner.scanUpToString(marker) != nil { 38 | return scanner.scanNamespace(marker: marker) 39 | } else { 40 | return nil 41 | } 42 | } 43 | } 44 | 45 | private extension Scanner { 46 | 47 | func skipNamespaces(marker: String) { 48 | while scanNamespace(marker: marker) != nil { 49 | _ = scanCharacters(from: .whitespacesAndNewlines) 50 | } 51 | } 52 | 53 | func scanNamespace(marker: String) -> String? { 54 | guard scanString(marker) != nil else { return nil } 55 | if let namespace = scanUpToCharacters(from: .whitespacesAndNewlines) { 56 | return namespace 57 | } else { 58 | return scanToEnd() 59 | } 60 | } 61 | 62 | func scanToEnd() -> String? { 63 | guard !isAtEnd else { return nil } 64 | let current = currentIndex 65 | while scanCharacter() != nil {} 66 | return String(string[current.. XCSnippetList { 18 | let url = try URLInputParser().absoluteURL(fromInput: url) 19 | let namespace = XCSnippetNamespace(namespace) 20 | try library.installSnippets( 21 | for: namespace, 22 | from: .git( 23 | url: url, 24 | reference: GitReference(pointer), 25 | folderPath: snippetsPath 26 | ) 27 | ) 28 | return try library.snippetList(for: namespace) 29 | } 30 | 31 | public func removeSnippets(namespace: String?) throws { 32 | if let namespace = namespace { 33 | let xcnamespace = XCSnippetNamespace(namespace) 34 | try library.removeSnippets(for: xcnamespace) 35 | } else { 36 | let namespaces = try library.snippetNamespaces().filter { $0 != .xcodeDefault } 37 | try namespaces.forEach { namespace in 38 | try library.removeSnippets(for:namespace) 39 | } 40 | } 41 | } 42 | 43 | public func snippetList(namespace: String?) throws -> [XCSnippetNamespace: XCSnippetList] { 44 | if let namespace = namespace { 45 | let xcnamespace = XCSnippetNamespace(namespace) 46 | return [xcnamespace: try library.snippetList(for: xcnamespace)] 47 | } else { 48 | let namespaces = try library.snippetNamespaces() 49 | return Dictionary(uniqueKeysWithValues: try namespaces.map { 50 | ($0, try library.snippetList(for: $0)) 51 | }) 52 | } 53 | } 54 | 55 | public func openSnippetFolder() throws { 56 | try Shell().execute(.open(path: library.snippetFolderURL().path)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetDownloadingStrategy.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | protocol XCSnippetDownloadingStrategy { 5 | func download(to destination: URL) throws 6 | } 7 | 8 | class XCSnippetDownloadingStrategyFactory { 9 | 10 | let fileManager: FileManager 11 | 12 | init(fileManager: FileManager) { 13 | self.fileManager = fileManager 14 | } 15 | 16 | func makeStrategy(source: XCSnippetSource) -> XCSnippetDownloadingStrategy { 17 | switch source { 18 | case let .git(url, reference, folderPath): 19 | return GitSourceSnippetDownloadingStrategy( 20 | url: url, 21 | reference: reference, 22 | folderPath: folderPath, 23 | fileManager: fileManager 24 | ) 25 | } 26 | } 27 | } 28 | 29 | struct GitSourceSnippetDownloadingStrategy: XCSnippetDownloadingStrategy { 30 | 31 | let url: URL 32 | let reference: GitReference 33 | let folderPath: String 34 | let fileManager: FileManager 35 | 36 | // MARK: - XCSnippetDownloadingStrategy 37 | 38 | func download(to destination: URL) throws { 39 | let tmp = try fileManager.createTemporarySubdirectory() 40 | defer { 41 | try? fileManager.removeItem(at: tmp) 42 | } 43 | try Shell().execute( 44 | .gitDownload(url: url, reference: reference, destination: tmp) 45 | ) 46 | let snippetsDirectoryURL = tmp.appendingPathComponent(folderPath) 47 | let snippetURLs = try fileManager.contentsOfDirectory(at: snippetsDirectoryURL) 48 | for url in snippetURLs { 49 | try fileManager.copyItem(at: url, to: destination.appendingPathComponent(url.lastPathComponent)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetFolderURLProviding.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | protocol XCSnippetFolderURLProviding { 5 | func rootSnippetFolderURL() -> URL 6 | } 7 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetLibrary.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public class XCSnippetLibrary { 5 | 6 | private let fileManager: FileManager 7 | private let snippetFileManager: XCSnippetFileManager 8 | private let downloader: XCSnippetsDownloader 9 | private let urlProvider: XCSnippetFolderURLProviding 10 | 11 | // MARK: - Life Cycle 12 | 13 | public convenience init(fileManager: FileManager = .default) { 14 | let snippetFileManager = XCSnippetFileManager(fileManager: fileManager) 15 | self.init( 16 | fileManager: fileManager, 17 | snippetFileManager: snippetFileManager, 18 | downloader: XCSnippetsDownloader( 19 | fileManager: fileManager, 20 | snippetFileManager: snippetFileManager, 21 | strategyFactory: XCSnippetDownloadingStrategyFactory(fileManager: fileManager) 22 | ), 23 | urlProvider: NativeNamespaceFolderURLProvider() 24 | ) 25 | } 26 | 27 | internal init(fileManager: FileManager, 28 | snippetFileManager: XCSnippetFileManager, 29 | downloader: XCSnippetsDownloader, 30 | urlProvider: XCSnippetFolderURLProviding) { 31 | self.fileManager = fileManager 32 | self.snippetFileManager = snippetFileManager 33 | self.downloader = downloader 34 | self.urlProvider = urlProvider 35 | } 36 | 37 | // MARK: - Public 38 | 39 | public func installSnippets(for namespace: XCSnippetNamespace, 40 | from source: XCSnippetSource) throws { 41 | let destination = urlProvider.rootSnippetFolderURL() 42 | try? fileManager.createDirectory(at: destination, withIntermediateDirectories: true, attributes: nil) 43 | try downloader.downloadSnippets( 44 | at: destination, 45 | from: source, 46 | namespace: namespace 47 | ) 48 | } 49 | 50 | public func removeSnippets(for namespace: XCSnippetNamespace) throws { 51 | try snippetFileManager.removeSnippets( 52 | with: SnippetNamespaceToSnippetFileTagMapper().map(namespace), 53 | at: urlProvider.rootSnippetFolderURL() 54 | ) 55 | } 56 | 57 | public func snippetList(for namespace: XCSnippetNamespace) throws -> XCSnippetList { 58 | let mapper = SnippetFileToSnippetMapper() 59 | return XCSnippetList(snippets: try snippetFileManager.snippets( 60 | at: urlProvider.rootSnippetFolderURL(), 61 | with: SnippetNamespaceToSnippetFileTagMapper().map(namespace) 62 | ).map { 63 | mapper.map($0) 64 | } 65 | .sorted { 66 | $0.name < $1.name 67 | }) 68 | } 69 | 70 | public func snippetNamespaces() throws -> [XCSnippetNamespace] { 71 | let mapper = SnippetFileTagToSnippetNamespaceMapper() 72 | return try snippetFileManager.snippetTags(at: urlProvider.rootSnippetFolderURL()).map { 73 | mapper.map($0) 74 | } 75 | } 76 | 77 | public func snippetFolderURL() -> URL { 78 | urlProvider.rootSnippetFolderURL() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetList.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct XCSnippetList { 5 | public let snippets: [XCSnippet] 6 | } 7 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetNamespace.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct XCSnippetNamespace: Hashable { 5 | 6 | enum Kind: Hashable { 7 | case custom(String) 8 | case xcodeDefault 9 | } 10 | 11 | let kind: Kind 12 | 13 | public init(_ id: String) { 14 | self.init(.custom(id)) 15 | } 16 | 17 | init(_ kind: Kind) { 18 | self.kind = kind 19 | } 20 | } 21 | 22 | public extension XCSnippetNamespace { 23 | 24 | static var xcodeDefault: XCSnippetNamespace { 25 | XCSnippetNamespace(.xcodeDefault) 26 | } 27 | 28 | var name: String { 29 | switch kind { 30 | case let .custom(name): 31 | return name 32 | case .xcodeDefault: 33 | return "Xcode default" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetNamespaceMapper.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct SnippetFileTagToSnippetNamespaceMapper { 5 | 6 | func map(_ tag: XCSnippetFile.Tag) -> XCSnippetNamespace { 7 | if tag == .unspecified { 8 | return .xcodeDefault 9 | } 10 | return XCSnippetNamespace(tag.identifier) 11 | } 12 | } 13 | 14 | struct SnippetNamespaceToSnippetFileTagMapper { 15 | 16 | func map(_ namespace: XCSnippetNamespace) throws -> XCSnippetFile.Tag { 17 | switch namespace.kind { 18 | case .xcodeDefault: 19 | return .unspecified 20 | case let .custom(id): 21 | return XCSnippetFile.Tag(identifier: id) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetSource.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public enum XCSnippetSource { 5 | case git(url: URL, reference: GitReference, folderPath: String) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/XCResource/XCSnippet/XCSnippetsDownloader.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCSnippetsDownloader { 5 | 6 | let fileManager: FileManager 7 | let snippetFileManager: XCSnippetFileManager 8 | let strategyFactory: XCSnippetDownloadingStrategyFactory 9 | 10 | init(fileManager: FileManager, 11 | snippetFileManager: XCSnippetFileManager, 12 | strategyFactory: XCSnippetDownloadingStrategyFactory) { 13 | self.fileManager = fileManager 14 | self.snippetFileManager = snippetFileManager 15 | self.strategyFactory = strategyFactory 16 | } 17 | 18 | // MARK: - Public 19 | 20 | func downloadSnippets(at destination: URL, 21 | from source: XCSnippetSource, 22 | namespace: XCSnippetNamespace) throws { 23 | let tmp = try fileManager.createTemporarySubdirectory() 24 | defer { 25 | try? fileManager.removeItem(at: tmp) 26 | } 27 | try strategyFactory.makeStrategy(source: source).download(to: tmp) 28 | try snippetFileManager.tagSnippets(at: tmp, tag: try namespace.toTag()) 29 | try snippetFileManager.removeSnippets(with: try namespace.toTag(), at: destination) 30 | try snippetFileManager.copySnippets(at: tmp, to: destination) 31 | } 32 | } 33 | 34 | private extension XCSnippetFile.Tag { 35 | 36 | var toNamespace: XCSnippetNamespace { 37 | SnippetFileTagToSnippetNamespaceMapper().map(self) 38 | } 39 | } 40 | 41 | private extension XCSnippetNamespace { 42 | 43 | func toTag() throws -> XCSnippetFile.Tag { 44 | try SnippetNamespaceToSnippetFileTagMapper().map(self) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/File/XCTemplateFile.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct XCTemplateFile: Equatable { 5 | let name: String 6 | let url: URL 7 | } 8 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/File/XCTemplateFileManager.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCTemplateFileManager { 5 | 6 | private let fileManager: FileManager 7 | 8 | // MARK: - Life Cycle 9 | 10 | init(fileManager: FileManager) { 11 | self.fileManager = fileManager 12 | } 13 | 14 | // MARK: - Public 15 | 16 | func removeTemplateFolder(at url: URL) throws { 17 | try fileManager.removeItem(at: url) 18 | } 19 | 20 | func templateFolder(at url: URL) throws -> XCTemplateFolderFile { 21 | try recursiveTemplateFolder(at: url) 22 | } 23 | 24 | func copy(_ folder: XCTemplateFolderFile, to destination: URL) throws { 25 | try folder.templates.forEach { template in 26 | try fileManager.copyItem( 27 | at: template.url, 28 | to: destination.appendingPathComponent(template.name) 29 | ) 30 | } 31 | try folder.folders.forEach { folder in 32 | let url = destination.appendingPathComponent(folder.name) 33 | try fileManager.createDirectory( 34 | at: url, 35 | withIntermediateDirectories: true, 36 | attributes: nil 37 | ) 38 | try copy(folder, to: url) 39 | } 40 | } 41 | 42 | private func recursiveTemplateFolder(at url: URL) throws -> XCTemplateFolderFile { 43 | let urls = try fileManager.contentsOfDirectory( 44 | at: url, 45 | includingPropertiesForKeys: [.isDirectoryKey], 46 | options: .skipsHiddenFiles 47 | ) 48 | var templates: [XCTemplateFile] = [] 49 | var folders: [XCTemplateFolderFile] = [] 50 | try urls.forEach { url in 51 | if url.isTemplate { 52 | templates.append(XCTemplateFile(name: url.lastPathComponent, url: url)) 53 | } else if url.isDirectory { 54 | folders.append(try recursiveTemplateFolder(at: url)) 55 | } 56 | } 57 | return XCTemplateFolderFile( 58 | name: url.lastPathComponent, 59 | url: url, 60 | templates: templates, 61 | folders: folders 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/File/XCTemplateFolderFile.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | struct XCTemplateFolderFile: Equatable { 5 | let name: String 6 | let url: URL 7 | let templates: [XCTemplateFile] 8 | let folders: [XCTemplateFolderFile] 9 | 10 | func templateCount() -> Int { 11 | templates.count + folders.reduce(into: 0, { $0 += $1.templateCount() }) 12 | } 13 | 14 | func isEmpty() -> Bool { 15 | templateCount() == 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/File/XCTemplateFolderMapper.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCTemplateFolderMapper { 5 | 6 | func map(_ file: XCTemplateFolderFile) -> XCTemplateFolder { 7 | XCTemplateFolder( 8 | name: file.name, 9 | folders: file.folders.map { map($0) }, 10 | templates: file.templates.map { XCTemplate(name: $0.name) } 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/URLInputParser.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class URLInputParser { 5 | 6 | enum ParsingError: Error { 7 | case invalidURL 8 | } 9 | 10 | func absoluteURL(fromInput url: String) throws -> URL { 11 | let resultURL: URL 12 | // Issue #7: Handling relative URLs 13 | let fileURL = URL(fileURLWithPath: url) 14 | if url.hasPrefix("~") { 15 | resultURL = expandTildeInPath(url) 16 | } else if url.hasPrefix("/") || fileURL.isReachable() { 17 | resultURL = fileURL 18 | } else if let url = URL(string: url) { 19 | resultURL = url 20 | } else { 21 | throw ParsingError.invalidURL 22 | } 23 | return resultURL.absoluteURL 24 | } 25 | 26 | private func expandTildeInPath(_ path: String) -> URL { 27 | URL(fileURLWithPath: NSString(string: path).expandingTildeInPath).standardized 28 | } 29 | } 30 | 31 | private extension URL { 32 | 33 | func isReachable() -> Bool { 34 | do { 35 | return try checkResourceIsReachable() 36 | } catch { 37 | return false 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplate.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct XCTemplate { 5 | 6 | public let name: String 7 | 8 | public init(name: String) { 9 | self.name = name 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateCLI.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public class XCTemplateCLI { 5 | 6 | private let templateLibrary: XCTemplateLibrary 7 | 8 | // MARK: - Life Cycle 9 | 10 | public init() { 11 | self.templateLibrary = XCTemplateLibrary() 12 | } 13 | 14 | // MARK: - Public 15 | 16 | public func downloadTemplates(url: String, 17 | pointer: String, 18 | namespace: String, 19 | templatesPath: String) throws -> XCTemplateFolder { 20 | let url = try URLInputParser().absoluteURL(fromInput: url) 21 | let templateNamespace = XCTemplateNamespace(namespace) 22 | try templateLibrary.downloadTemplates( 23 | for: templateNamespace, 24 | from: .git( 25 | url: url, 26 | reference: GitReference(pointer), 27 | folderPath: templatesPath 28 | ) 29 | ) 30 | return try templateLibrary.templateFolder(for: templateNamespace) 31 | } 32 | 33 | public func removeTemplates(namespace: String) throws { 34 | try templateLibrary.removeTemplates(for: XCTemplateNamespace(namespace)) 35 | } 36 | 37 | public func templateFolder(namespace: String?) throws -> XCTemplateFolder { 38 | let folder: XCTemplateFolder 39 | if let namespace = namespace { 40 | folder = try templateLibrary.templateFolder(for: XCTemplateNamespace(namespace)) 41 | } else { 42 | folder = try templateLibrary.rootTemplateFolder() 43 | } 44 | return folder 45 | } 46 | 47 | public func openRootTemplateFolder() throws { 48 | try Shell().execute(.open(path: templateLibrary.rootTemplateFolderURL().path)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateFolder.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct XCTemplateFolder { 5 | 6 | public let name: String 7 | public let folders: [XCTemplateFolder] 8 | public let templates: [XCTemplate] 9 | 10 | public init(name: String, 11 | folders: [XCTemplateFolder], 12 | templates: [XCTemplate]) { 13 | self.name = name 14 | self.folders = folders 15 | self.templates = templates 16 | } 17 | 18 | public func templateCount() -> Int { 19 | templates.count + folders.reduce(into: 0, { $0 += $1.templateCount() }) 20 | } 21 | 22 | public func isEmpty() -> Bool { 23 | templateCount() == 0 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateFolderDownloadingStrategy.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | protocol XCTemplateFolderDownloadingStrategy { 5 | func download(to destination: URL) throws 6 | } 7 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateFolderDownloadingStrategyFactory.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCTemplateFolderDownloadingStrategyFactory { 5 | 6 | let fileManager: FileManager 7 | let templateManager: XCTemplateFileManager 8 | 9 | init(fileManager: FileManager, templateManager: XCTemplateFileManager) { 10 | self.fileManager = fileManager 11 | self.templateManager = templateManager 12 | } 13 | 14 | func makeStrategy(source: XCTemplateSource) -> XCTemplateFolderDownloadingStrategy { 15 | switch source { 16 | case let .git(url, reference, folderPath): 17 | return GitSourceDownloadingStrategy( 18 | url: url, 19 | reference: reference, 20 | folderPath: folderPath, 21 | fileManager: fileManager, 22 | templateManager: templateManager 23 | ) 24 | } 25 | } 26 | } 27 | 28 | struct GitSourceDownloadingStrategy: XCTemplateFolderDownloadingStrategy { 29 | 30 | let url: URL 31 | let reference: GitReference 32 | let folderPath: String 33 | let fileManager: FileManager 34 | let templateManager: XCTemplateFileManager 35 | 36 | // MARK: - XCTemplateFolderDownloadingStrategy 37 | 38 | func download(to destination: URL) throws { 39 | let tmp = try fileManager.createTemporarySubdirectory() 40 | defer { 41 | try? fileManager.removeItem(at: tmp) 42 | } 43 | try Shell().execute( 44 | .gitDownload( 45 | url: url, 46 | reference: reference, 47 | destination: tmp 48 | ) 49 | ) 50 | let folder = try templateManager.templateFolder(at: tmp.appendingPathComponent(folderPath)) 51 | try? fileManager.removeItem(at: destination) 52 | try fileManager.createDirectory(at: destination, withIntermediateDirectories: true, attributes: nil) 53 | try templateManager.copy(folder, to: destination) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateLibrary.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public class XCTemplateLibrary { 5 | 6 | private let fileManager: XCTemplateFileManager 7 | private let downloader: XCTemplatesDownloader 8 | private let urlProvider: XCTemplateFolderURLProviding 9 | 10 | // MARK: - Life Cycle 11 | 12 | internal init(fileManager: XCTemplateFileManager, 13 | downloader: XCTemplatesDownloader, 14 | urlProvider: XCTemplateFolderURLProviding) { 15 | self.fileManager = fileManager 16 | self.downloader = downloader 17 | self.urlProvider = urlProvider 18 | } 19 | 20 | public convenience init(fileManager: FileManager = .default) { 21 | let templateManager = XCTemplateFileManager( 22 | fileManager: fileManager 23 | ) 24 | self.init( 25 | fileManager: templateManager, 26 | downloader: XCTemplatesDownloader( 27 | factory: XCTemplateFolderDownloadingStrategyFactory( 28 | fileManager: fileManager, 29 | templateManager: templateManager 30 | ) 31 | ), 32 | urlProvider: NativeNamespaceFolderURLProvider() 33 | ) 34 | } 35 | 36 | // MARK: - Public 37 | 38 | public func downloadTemplates(for namespace: XCTemplateNamespace, 39 | from source: XCTemplateSource) throws { 40 | try downloader.downloadTemplates( 41 | at: url(for: namespace), 42 | from: source 43 | ) 44 | } 45 | 46 | public func removeTemplates(for namespace: XCTemplateNamespace) throws { 47 | try fileManager.removeTemplateFolder(at: url(for: namespace)) 48 | } 49 | 50 | public func rootTemplateFolder() throws -> XCTemplateFolder { 51 | let folder = try fileManager.templateFolder(at: urlProvider.rootTemplateURL()) 52 | return XCTemplateFolderMapper().map(folder) 53 | } 54 | 55 | public func templateFolder(for namespace: XCTemplateNamespace) throws -> XCTemplateFolder { 56 | let folder = try fileManager.templateFolder(at: url(for: namespace)) 57 | return XCTemplateFolderMapper().map(folder) 58 | } 59 | 60 | public func rootTemplateFolderURL() -> URL { 61 | urlProvider.rootTemplateURL() 62 | } 63 | 64 | // MARK: - Private 65 | 66 | private func url(for namespace: XCTemplateNamespace) -> URL { 67 | urlProvider.url(for: namespace) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateNamespace.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public struct XCTemplateNamespace: Hashable { 5 | 6 | let id: String 7 | 8 | public init(_ id: String) { 9 | self.id = id 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateNamespaceFolderURLProviding.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | protocol XCTemplateFolderURLProviding { 5 | func rootTemplateURL() -> URL 6 | func url(for namespace: XCTemplateNamespace) -> URL 7 | } 8 | 9 | class NativeNamespaceFolderURLProvider: XCTemplateFolderURLProviding, 10 | XCSnippetFolderURLProviding { 11 | 12 | // MARK: - XCTemplateFolderURLProviding 13 | 14 | func rootTemplateURL() -> URL { 15 | URL( 16 | fileURLWithPath: "Library/Developer/Xcode/Templates", 17 | relativeTo: FileManager.default.homeDirectoryForCurrentUser 18 | ) 19 | } 20 | 21 | func url(for namespace: XCTemplateNamespace) -> URL { 22 | rootTemplateURL().appendingPathComponent(namespace.id) 23 | } 24 | 25 | // MARK: - XCSnippetFolderURLProviding 26 | 27 | func rootSnippetFolderURL() -> URL { 28 | URL( 29 | fileURLWithPath: "Library/Developer/Xcode/UserData/CodeSnippets", 30 | relativeTo: FileManager.default.homeDirectoryForCurrentUser 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplateSource.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public enum XCTemplateSource { 5 | case git(url: URL, reference: GitReference, folderPath: String) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/XCResource/XCTemplate/XCTemplatesDownloader.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | class XCTemplatesDownloader { 5 | 6 | let factory: XCTemplateFolderDownloadingStrategyFactory 7 | 8 | // MARK: - Life Cycle 9 | 10 | init(factory: XCTemplateFolderDownloadingStrategyFactory) { 11 | self.factory = factory 12 | } 13 | 14 | // MARK: - Public 15 | 16 | func downloadTemplates(at destination: URL, from source: XCTemplateSource) throws { 17 | try factory.makeStrategy(source: source).download(to: destination) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import XCResourceTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += XCTemplateInstallerTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/URLInputParserTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | import Foundation 6 | 7 | final class URLInputParserTests: XCTestCase { 8 | 9 | var parser: URLInputParser! 10 | 11 | override func setUp() { 12 | parser = URLInputParser() 13 | } 14 | 15 | func testRemoteURL() throws { 16 | let remoteURL = "git@github.com:faberNovel/CodeSnippet_iOS.git" 17 | let url = try parser.absoluteURL(fromInput: "git@github.com:faberNovel/CodeSnippet_iOS.git") 18 | XCTAssertEqual(url.path , remoteURL) 19 | } 20 | 21 | func testCurrentDirectoryURL() throws { 22 | let target = URL(fileURLWithPath: "./tst") 23 | FileManager.default.createDirectoryIfNeeded(at: target) 24 | let url = try parser.absoluteURL(fromInput: "./tst") 25 | try? FileManager.default.removeItem(at: target) 26 | XCTAssertEqual(url.path, target.path) 27 | } 28 | 29 | func testHomeRelativeURL() throws { 30 | let url = try parser.absoluteURL(fromInput: "~") 31 | XCTAssertEqual(url, FileManager.default.homeDirectoryForCurrentUser) 32 | } 33 | 34 | func testAbsoluteURL() throws { 35 | let target = "/private/tmp/tst" 36 | let url = try parser.absoluteURL(fromInput: target) 37 | XCTAssertEqual(url.path, target) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/Utils/DynamicXCSnippetFolder.swift: -------------------------------------------------------------------------------- 1 | 2 | import XCResource 3 | import Foundation 4 | 5 | class DynamicXCSnippetFolder { 6 | 7 | struct Snippet { 8 | 9 | let id: String 10 | fileprivate let content: String 11 | } 12 | 13 | let rootUrl: URL 14 | private let fileManager: FileManager 15 | 16 | init(url: URL, fileManager: FileManager) { 17 | self.rootUrl = url 18 | self.fileManager = fileManager 19 | } 20 | 21 | func clean() { 22 | try? fileManager.removeItem(at: rootUrl) 23 | } 24 | 25 | func generate() { 26 | fileManager.createDirectoryIfNeeded(at: rootUrl) 27 | } 28 | 29 | func create(_ snippet: Snippet) { 30 | fileManager.createDirectoryIfNeeded(at: rootUrl) 31 | let url = rootUrl.appendingPathComponent(snippet.id).appendingPathExtension("codesnippet") 32 | try! snippet.content.data(using: .utf8)!.write(to: url) 33 | } 34 | 35 | @discardableResult 36 | func createRandomFile() -> String { 37 | fileManager.createDirectoryIfNeeded(at: rootUrl) 38 | let name = UUID().uuidString 39 | try! "Random file".data(using: .utf8)!.write(to: rootUrl.appendingPathComponent(name)) 40 | return name 41 | } 42 | 43 | func snippet(named name: String) -> DynamicXCSnippetFolder.Snippet? { 44 | let url = rootUrl.appendingPathComponent(name).appendingPathExtension("codesnippet") 45 | guard let data = try? Data(contentsOf: url), 46 | let content = String(data: data, encoding: .utf8) 47 | else { 48 | return nil 49 | } 50 | return DynamicXCSnippetFolder.Snippet( 51 | id: url.deletingLastPathComponent().lastPathComponent, 52 | content: content 53 | ) 54 | } 55 | 56 | func create(_ snippets: [Snippet]) { 57 | snippets.forEach { create($0) } 58 | } 59 | 60 | func onlyContains(_ snippets: Snippet...) -> Bool { 61 | let names = snippets.map { $0.id } 62 | let filenames = names.map { "\($0).codesnippet" } 63 | guard fileManager.directoryOnlyContains(filenames, at: rootUrl) else { return false } 64 | for snippet in snippets { 65 | guard let local = self.snippet(named: snippet.id), local == snippet else { 66 | return false 67 | } 68 | } 69 | return true 70 | } 71 | } 72 | 73 | extension DynamicXCSnippetFolder.Snippet { 74 | 75 | static func basic(id: String) -> DynamicXCSnippetFolder.Snippet { 76 | .init( 77 | id: id, 78 | content: 79 | """ 80 | 81 | 82 | 83 | 84 | IDECodeSnippetCompletionPrefix 85 | COMPLETION-PREFIX 86 | IDECodeSnippetCompletionScopes 87 | 88 | All 89 | 90 | IDECodeSnippetContents 91 | Content 92 | IDECodeSnippetIdentifier 93 | \(id) 94 | IDECodeSnippetLanguage 95 | Xcode.SourceCodeLanguage.Generic 96 | IDECodeSnippetSummary 97 | 98 | IDECodeSnippetTitle 99 | TITLE 100 | IDECodeSnippetUserSnippet 101 | 102 | IDECodeSnippetVersion 103 | 2 104 | 105 | 106 | """ 107 | ) 108 | } 109 | 110 | static func tagged(id: String, tag: String) -> DynamicXCSnippetFolder.Snippet { 111 | .init( 112 | id: id, 113 | content: 114 | """ 115 | 116 | 117 | 118 | 119 | IDECodeSnippetCompletionPrefix 120 | COMPLETION-PREFIX 121 | IDECodeSnippetCompletionScopes 122 | 123 | All 124 | 125 | IDECodeSnippetContents 126 | Content 127 | IDECodeSnippetIdentifier 128 | \(id) 129 | IDECodeSnippetLanguage 130 | Xcode.SourceCodeLanguage.Generic 131 | IDECodeSnippetSummary 132 | Namespace: \(tag) 133 | IDECodeSnippetTitle 134 | TITLE 135 | IDECodeSnippetUserSnippet 136 | 137 | IDECodeSnippetVersion 138 | 2 139 | 140 | 141 | """ 142 | ) 143 | } 144 | } 145 | 146 | extension DynamicXCSnippetFolder.Snippet: Equatable { 147 | 148 | // MARK: - Equatable 149 | 150 | static func == (lhs: DynamicXCSnippetFolder.Snippet, rhs: DynamicXCSnippetFolder.Snippet) -> Bool { 151 | do { 152 | let lhsData = lhs.content.data(using: .utf8) ?? Data() 153 | let rhsData = rhs.content.data(using: .utf8) ?? Data() 154 | let lhsDecodedData = try PropertyListSerialization.format(lhsData) 155 | let rhsDecodedData = try PropertyListSerialization.format(rhsData) 156 | let lhsFormattedString = String(data: lhsDecodedData, encoding: .utf8) ?? "" 157 | let rhsFormattedString = String(data: rhsDecodedData, encoding: .utf8) ?? "" 158 | return lhsFormattedString == rhsFormattedString 159 | } catch { 160 | return true 161 | } 162 | } 163 | } 164 | 165 | private extension PropertyListSerialization { 166 | 167 | static func format(_ data: Data) throws -> Data { 168 | try PropertyListSerialization.data( 169 | fromPropertyList: try PropertyListSerialization.propertyList( 170 | from: data, 171 | format: nil 172 | ), 173 | format: .xml, 174 | options: .zero 175 | ) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/Utils/DynamicXCTemplateFolder.swift: -------------------------------------------------------------------------------- 1 | 2 | import XCResource 3 | import Foundation 4 | 5 | class DynamicXCTemplateFolder { 6 | 7 | let rootUrl: URL 8 | private let fileManager: FileManager 9 | 10 | init(url: URL, fileManager: FileManager) { 11 | self.rootUrl = url 12 | self.fileManager = fileManager 13 | fileManager.createDirectoryIfNeeded(at: rootUrl) 14 | } 15 | 16 | func clean() { 17 | try? fileManager.removeItem(at: rootUrl) 18 | } 19 | 20 | func createFolder(named name: String) -> DynamicXCTemplateFolder { 21 | let url = rootUrl.appendingPathComponent(name) 22 | return DynamicXCTemplateFolder(url: url, fileManager: fileManager) 23 | } 24 | 25 | func createTemplate(named name: String) { 26 | generateRootTemplate(name: name) 27 | } 28 | 29 | // MARK: - Private 30 | 31 | private func generateRootTemplate(name: String) { 32 | generateTemplate(name: name, at: rootUrl) 33 | } 34 | 35 | private func generateTemplate(name: String, at url: URL) { 36 | let target = url.appendingPathComponent(name).appendingPathExtension("xctemplate") 37 | fileManager.createFile(atPath: target.path, contents: nil, attributes: nil) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/Utils/FileManager+Temporary.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import XCTest 4 | @testable import XCResource 5 | 6 | extension FileManager { 7 | 8 | func createDirectoryIfNeeded(at url: URL) { 9 | try? createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 10 | } 11 | 12 | func assertDirectoryContains(_ testFiles: [String], at url: URL) { 13 | XCTAssertTrue(directoryOnlyContains(testFiles, at: url)) 14 | } 15 | 16 | func directoryOnlyContains(_ testFiles: [String], at url: URL) -> Bool { 17 | do { 18 | let urls = try FileManager.default.contentsOfDirectory(at: url) 19 | let fileNames = urls.map { $0.lastPathComponent } 20 | guard urls.count == testFiles.count else { return false } 21 | for filename in fileNames { 22 | guard testFiles.contains(filename) else { return false } 23 | } 24 | return true 25 | } catch { 26 | return false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/Utils/GitRepository.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | @testable import XCResource 4 | 5 | class GitRepository { 6 | 7 | let url: URL 8 | let shell: Shell 9 | 10 | init(url: URL) { 11 | self.url = url 12 | self.shell = Shell() 13 | FileManager.default.createDirectoryIfNeeded(at: url) 14 | shell.changeCurrentDirectoryPath(url.path) 15 | } 16 | 17 | func initialize() { 18 | try! shell.execute("git init") 19 | } 20 | 21 | func commit(message: String) { 22 | try! shell.execute("git commit -m \"\(message)\"") 23 | } 24 | 25 | func tag(_ tag: String) { 26 | try! shell.execute("git tag \(tag)") 27 | } 28 | 29 | func stageAll() { 30 | try! shell.execute("git add .") 31 | } 32 | 33 | func checkoutBranch(_ branch: String) { 34 | try! shell.execute("git checkout \(branch)") 35 | } 36 | 37 | func checkoutNewBranch(_ branch: String) { 38 | try! shell.execute("git checkout -b \(branch)") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/Utils/TestXCTemplateFolderURLProvider.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import Foundation 4 | 5 | class TestXCTemplateFolderURLProvider: XCTemplateFolderURLProviding { 6 | 7 | let root: URL 8 | 9 | init() { 10 | root = try! FileManager.default.createTemporarySubdirectory() 11 | } 12 | 13 | func rootTemplateURL() -> URL { 14 | root 15 | } 16 | 17 | func url(for namespace: XCTemplateNamespace) -> URL { 18 | root.appendingPathComponent(namespace.id) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCSnippetDownloaderTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | import Foundation 6 | 7 | final class XCResourceDownloaderTests: XCTestCase { 8 | 9 | private var url: URL! 10 | private var fileManager: FileManager! 11 | private var downloader: XCSnippetsDownloader! 12 | private var repository: GitRepository! 13 | 14 | override func setUp() { 15 | fileManager = .default 16 | url = FileManager.default.temporaryDirectory.appendingPathComponent("XCResourceDownloaderTests") 17 | downloader = XCSnippetsDownloader( 18 | fileManager: fileManager, 19 | snippetFileManager: XCSnippetFileManager(fileManager: fileManager), 20 | strategyFactory: XCSnippetDownloadingStrategyFactory(fileManager: fileManager) 21 | ) 22 | } 23 | 24 | override func tearDown() { 25 | try? fileManager.removeItem(at: url) 26 | } 27 | 28 | func testDownload() throws { 29 | let originURL = url.appendingPathComponent("Origin") 30 | let originFolder = DynamicXCSnippetFolder( 31 | url: originURL, 32 | fileManager: fileManager 33 | ) 34 | originFolder.clean() 35 | originFolder.generate() 36 | let repo = GitRepository(url: originURL) 37 | repo.initialize() 38 | let snippets: [DynamicXCSnippetFolder.Snippet] = [ 39 | .basic(id: "A"), 40 | .basic(id: "B"), 41 | ] 42 | originFolder.create(snippets) 43 | originFolder.createRandomFile() 44 | repo.stageAll() 45 | repo.commit(message: "Initial commit") 46 | let destinationURL = url.appendingPathComponent("Dst") 47 | let destinationFolder = DynamicXCSnippetFolder( 48 | url: destinationURL, 49 | fileManager: fileManager 50 | ) 51 | destinationFolder.generate() 52 | destinationFolder.create(.tagged(id: "C", tag: "A")) // should be replaced 53 | try downloader.downloadSnippets( 54 | at: destinationURL, 55 | from: .git(url: originURL, reference: GitReference("master"), folderPath: "/"), 56 | namespace: XCSnippetNamespace("A") 57 | ) 58 | XCTAssertTrue(destinationFolder.onlyContains(.tagged(id: "A", tag: "A"), .tagged(id: "B", tag: "A"))) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCSnippetFileManagerTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | final class XCSnippetFileManagerTests: XCTestCase { 6 | 7 | var workingDirectoryURL: URL! 8 | var fileManager: FileManager! 9 | var manager: XCSnippetFileManager! 10 | var folder: DynamicXCSnippetFolder! 11 | 12 | override func setUp() { 13 | workingDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent("XCSnippetFileManagerTests") 14 | fileManager = .default 15 | folder = DynamicXCSnippetFolder( 16 | url: workingDirectoryURL, 17 | fileManager: fileManager 18 | ) 19 | folder.clean() 20 | manager = XCSnippetFileManager(fileManager: .default) 21 | } 22 | 23 | override func tearDown() { 24 | folder.clean() 25 | } 26 | 27 | func testListSnippets() throws { 28 | folder.create(.basic(id: "A")) 29 | folder.create(.basic(id: "B")) 30 | folder.createRandomFile() 31 | let results = try manager.snippets(at: folder.rootUrl) 32 | XCTAssertEqual(results.count, 2) 33 | XCTAssertEqual(results[0].identifier, "A") 34 | XCTAssertEqual(results[1].identifier, "B") 35 | } 36 | 37 | func testListTaggedSnippets() throws { 38 | folder.create(.basic(id: "A")) 39 | folder.create(.basic(id: "B")) 40 | folder.create(.tagged(id: "T1A", tag: "T1")) 41 | folder.create(.tagged(id: "T1B", tag: "T1")) 42 | folder.create(.tagged(id: "T2A", tag: "T2")) 43 | folder.create(.tagged(id: "T2B", tag: "T2")) 44 | folder.createRandomFile() 45 | let unspecifiedResults = try manager.snippets(at: folder.rootUrl, with: .unspecified) 46 | XCTAssertEqual(unspecifiedResults.count, 2) 47 | XCTAssertEqual(unspecifiedResults[0].identifier, "A") 48 | XCTAssertEqual(unspecifiedResults[1].identifier, "B") 49 | let tagOneResults = try manager.snippets(at: folder.rootUrl, with: .custom("T1")) 50 | XCTAssertEqual(tagOneResults.count, 2) 51 | if tagOneResults.count == 2 { 52 | XCTAssertEqual(tagOneResults[0].identifier, "T1A") 53 | XCTAssertEqual(tagOneResults[1].identifier, "T1B") 54 | } 55 | let tagTwoResults = try manager.snippets(at: folder.rootUrl, with: .custom("T2")) 56 | XCTAssertEqual(tagTwoResults.count, 2) 57 | if tagTwoResults.count == 2 { 58 | XCTAssertEqual(tagTwoResults[0].identifier, "T2A") 59 | XCTAssertEqual(tagTwoResults[1].identifier, "T2B") 60 | } 61 | } 62 | 63 | func testListSnippetTags() throws { 64 | folder.create(.tagged(id: "A", tag: "A")) 65 | folder.create(.tagged(id: "B", tag: "B")) 66 | folder.create(.tagged(id: "C", tag: "C")) 67 | folder.create(.basic(id: "D")) 68 | let tags = try manager.snippetTags(at: folder.rootUrl).sorted(by: { $0.identifier < $1.identifier }) 69 | XCTAssertEqual(tags.count, 4) 70 | XCTAssertTrue(tags.contains(XCSnippetFile.Tag(identifier: "A"))) 71 | XCTAssertTrue(tags.contains(XCSnippetFile.Tag(identifier: "B"))) 72 | XCTAssertTrue(tags.contains(XCSnippetFile.Tag(identifier: "C"))) 73 | XCTAssertTrue(tags.contains(XCSnippetFile.Tag.unspecified)) 74 | } 75 | 76 | func testTagSnippets() throws { 77 | folder.create(.tagged(id: "A", tag: "A")) 78 | folder.create(.basic(id: "B")) 79 | let random = folder.createRandomFile() 80 | let tagB = XCSnippetFile.Tag(identifier: "B") 81 | try manager.tagSnippets(at: folder.rootUrl, tag: tagB) 82 | fileManager.assertDirectoryContains( 83 | ["A.codesnippet", "B.codesnippet", random], 84 | at: folder.rootUrl 85 | ) 86 | if let a = folder.snippet(named: "A.codesnippet") { 87 | XCTAssertEqual(a, .tagged(id: "A", tag: "B")) 88 | } 89 | if let b = folder.snippet(named: "B.codesnippet") { 90 | XCTAssertEqual(b, .tagged(id: "B", tag: "B")) 91 | } 92 | } 93 | 94 | func testSnippetRemoval() throws { 95 | folder.create(.tagged(id: "A", tag: "A")) 96 | folder.create(.basic(id: "B")) 97 | let random = folder.createRandomFile() 98 | fileManager.assertDirectoryContains( 99 | ["A.codesnippet", "B.codesnippet", random], 100 | at: folder.rootUrl 101 | ) 102 | try manager.removeSnippets(with: .unspecified, at: folder.rootUrl) 103 | fileManager.assertDirectoryContains( 104 | ["A.codesnippet", random], 105 | at: folder.rootUrl 106 | ) 107 | try manager.removeSnippets(with: XCSnippetFile.Tag(identifier: "A"), at: folder.rootUrl) 108 | fileManager.assertDirectoryContains( 109 | [random], 110 | at: folder.rootUrl 111 | ) 112 | } 113 | 114 | func testCopying() throws { 115 | folder.create(.basic(id: "A")) 116 | folder.create(.basic(id: "B")) 117 | let destination = DynamicXCSnippetFolder( 118 | url: folder.rootUrl.appendingPathComponent("Dst"), 119 | fileManager: fileManager 120 | ) 121 | destination.generate() 122 | try manager.copySnippets(at: folder.rootUrl, to: destination.rootUrl) 123 | fileManager.assertDirectoryContains( 124 | ["A.codesnippet", "B.codesnippet"], 125 | at: destination.rootUrl 126 | ) 127 | if let a = destination.snippet(named: "A.codesnippet") { 128 | XCTAssertEqual(a, .basic(id: "A")) 129 | } 130 | if let b = destination.snippet(named: "B.codesnippet") { 131 | XCTAssertEqual(b, .basic(id: "B")) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCSnippetFileSummaryTaggerTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | private struct TagContentSample { 6 | let initialContent: String 7 | let initialTag: String? 8 | let tag: String 9 | let expectedContent: String 10 | } 11 | 12 | final class XCSnippetFileSummaryTaggerTests: XCTestCase { 13 | 14 | func testTagging() { 15 | let tagger = XCSnippetFileSummaryTagger() 16 | let samples = [ 17 | TagContentSample( 18 | initialContent: "", 19 | initialTag: nil, 20 | tag: "A", 21 | expectedContent: "Namespace: A" 22 | ), 23 | TagContentSample( 24 | initialContent: "CONTENT", 25 | initialTag: nil, 26 | tag: "A", 27 | expectedContent: "CONTENT\n\nNamespace: A" 28 | ), 29 | TagContentSample( 30 | initialContent: "CONTENT Namespace: A", 31 | initialTag: "A", 32 | tag: "B", 33 | expectedContent: "CONTENT\n\nNamespace: B" 34 | ), 35 | TagContentSample( 36 | initialContent: "Namespace: A CONTENT\nNamespace: B\nNamespace: C", 37 | initialTag: "A", 38 | tag: "B", 39 | expectedContent: "CONTENT\n\nNamespace: B" 40 | ), 41 | TagContentSample( 42 | initialContent: "Namespace: A CONTENT\nNamespace: B\nNamespace: C END", 43 | initialTag: "A", 44 | tag: "B", 45 | expectedContent: "CONTENT\nEND\n\nNamespace: B" 46 | ), 47 | TagContentSample( 48 | initialContent: "CONTENT\n", 49 | initialTag: nil, 50 | tag: "B", 51 | expectedContent: "CONTENT\n\nNamespace: B" 52 | ), 53 | TagContentSample( 54 | initialContent: "CONTENT\n\n", 55 | initialTag: nil, 56 | tag: "B", 57 | expectedContent: "CONTENT\n\nNamespace: B" 58 | ), 59 | ] 60 | samples.enumerated().forEach { i, sample in 61 | XCTAssertEqual(tagger.tag(in: sample.initialContent), sample.initialTag) 62 | XCTAssertEqual( 63 | tagger.tag(sample.initialContent, tag: sample.tag), 64 | sample.expectedContent 65 | ) 66 | XCTAssertEqual(tagger.tag(in: sample.expectedContent), sample.tag) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCTemplateDownloaderTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | final class XCTemplateDownloaderTests: XCTestCase { 6 | 7 | private var fileManager: FileManager! 8 | private var templateManager: XCTemplateFileManager! 9 | private var workingUrl: URL! 10 | private var templatesUrl: URL! 11 | private var repository: GitRepository! 12 | 13 | override func setUp() { 14 | fileManager = .default 15 | workingUrl = try! fileManager.createTemporarySubdirectory() 16 | templatesUrl = workingUrl.appendingPathComponent("Templates") 17 | repository = GitRepository(url: workingUrl.appendingPathComponent("Repository")) 18 | repository.initialize() 19 | templateManager = XCTemplateFileManager(fileManager: fileManager) 20 | } 21 | 22 | override func tearDown() { 23 | try? fileManager.removeItem(at: workingUrl) 24 | } 25 | 26 | func testBranchGitDownload() throws { 27 | let reference = GitReference("target-branch") 28 | repository.checkoutNewBranch(reference.name) 29 | let templatesPath = "/" 30 | let folder = DynamicXCTemplateFolder( 31 | url: repository.url.appendingPathComponent(templatesPath), 32 | fileManager: fileManager 33 | ) 34 | folder.createTemplate(named: "Template1") 35 | folder.createTemplate(named: "Template2") 36 | repository.stageAll() 37 | repository.commit(message: "My templates") 38 | let strategy = GitSourceDownloadingStrategy( 39 | url: repository.url, 40 | reference: reference, 41 | folderPath: templatesPath, 42 | fileManager: fileManager, 43 | templateManager: templateManager 44 | ) 45 | try strategy.download(to: templatesUrl) 46 | try expectTemplates(at: folder.rootUrl, equals: templatesUrl) 47 | } 48 | 49 | func testTagDownload() throws { 50 | let reference = GitReference("target-tag") 51 | let templatesPath = "Templates" 52 | let folder = DynamicXCTemplateFolder( 53 | url: repository.url.appendingPathComponent(templatesPath), 54 | fileManager: fileManager 55 | ) 56 | folder.createTemplate(named: "Template1") 57 | folder.createTemplate(named: "Template2") 58 | repository.stageAll() 59 | repository.commit(message: "My templates") 60 | repository.tag(reference.name) 61 | let strategy = GitSourceDownloadingStrategy( 62 | url: repository.url, 63 | reference: reference, 64 | folderPath: templatesPath, 65 | fileManager: fileManager, 66 | templateManager: templateManager 67 | ) 68 | try strategy.download(to: templatesUrl) 69 | try expectTemplates(at: folder.rootUrl, equals: templatesUrl) 70 | } 71 | 72 | func testDeepFolderDownload() throws { 73 | let reference = GitReference("target-branch") 74 | repository.checkoutNewBranch(reference.name) 75 | let templatesPath = "Path/To/Templates" 76 | let folder = DynamicXCTemplateFolder( 77 | url: repository.url.appendingPathComponent(templatesPath), 78 | fileManager: fileManager 79 | ) 80 | folder.createTemplate(named: "Templates2") 81 | folder.createTemplate(named: "Templates1") 82 | repository.stageAll() 83 | repository.commit(message: "My templates") 84 | let strategy = GitSourceDownloadingStrategy( 85 | url: repository.url, 86 | reference: reference, 87 | folderPath: templatesPath, 88 | fileManager: fileManager, 89 | templateManager: templateManager 90 | ) 91 | try strategy.download(to: templatesUrl) 92 | try expectTemplates(at: folder.rootUrl, equals: templatesUrl) 93 | } 94 | 95 | private func expectTemplates(at origin: URL, equals destination: URL) throws { 96 | let lhs = try templateManager.templateFolder(at: origin) 97 | let rhs = try templateManager.templateFolder(at: destination) 98 | XCTAssertTrue(rhs.hasSameContent(as: lhs)) 99 | } 100 | 101 | static var allTests = [ 102 | ("testBranchGitDownload", testBranchGitDownload), 103 | ("testTagDownload", testTagDownload), 104 | ("testDeepFolderDownload", testDeepFolderDownload), 105 | ] 106 | } 107 | 108 | private extension XCTemplateFolderFile { 109 | 110 | func hasSameContent(as other: XCTemplateFolderFile) -> Bool { 111 | guard folders.count == other.folders.count else { return false } 112 | let foldersHaveSameContent = zip(other.folders, folders).allSatisfy { $0.hasSameContent(as: $1) && $0.name == $1.name } 113 | let templatesHaveSameContent = zip(other.templates, templates).allSatisfy { $0.hasSameContent(as: $1) } 114 | return templatesHaveSameContent && foldersHaveSameContent 115 | } 116 | } 117 | 118 | private extension XCTemplateFile { 119 | 120 | func hasSameContent(as other: XCTemplateFile) -> Bool { 121 | name == other.name 122 | } 123 | } 124 | 125 | private extension FileManager { 126 | 127 | func templateContentEquals(atPath lhs: String, andPath rhs: String) -> Bool { 128 | let lhsEnumerator = enumerator(atPath: lhs) 129 | let rhsEnumerator = enumerator(atPath: rhs) 130 | while let lhs = lhsEnumerator?.nextObject() as? String, let rhs = rhsEnumerator?.nextObject() as? String, lhs == rhs {} 131 | return lhsEnumerator?.nextObject() == nil && rhsEnumerator?.nextObject() == nil 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCTemplateFileManagerTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import XCResource 3 | import XCTest 4 | 5 | final class XCTemplateFileManagerTests: XCTestCase { 6 | 7 | var urlProvider: TestXCTemplateFolderURLProvider! 8 | var manager: XCTemplateFileManager! 9 | var folder: DynamicXCTemplateFolder! 10 | 11 | override func setUp() { 12 | urlProvider = TestXCTemplateFolderURLProvider() 13 | folder = DynamicXCTemplateFolder( 14 | url: urlProvider.root, 15 | fileManager: .default 16 | ) 17 | manager = XCTemplateFileManager(fileManager: .default) 18 | } 19 | 20 | override func tearDown() { 21 | folder.clean() 22 | } 23 | 24 | func testThreeBasicTemplatesExample() throws { 25 | folder.createTemplate(named: "Template1") 26 | folder.createTemplate(named: "Template2") 27 | folder.createTemplate(named: "Template3") 28 | let result = try manager.templateFolder(at: folder.rootUrl) 29 | XCTAssertEqual(result.templateCount(), 3) 30 | } 31 | 32 | func testTemplateHierarchyExample() throws { 33 | folder.createTemplate(named: "Template1") 34 | folder.createTemplate(named: "Template2") 35 | let subfolder = folder.createFolder(named: "Subfolder") 36 | subfolder.createTemplate(named: "Template3") 37 | subfolder.createTemplate(named: "Template4") 38 | let result = try manager.templateFolder(at: folder.rootUrl) 39 | XCTAssertEqual(result.templateCount(), 4) 40 | XCTAssertEqual(result.folders.count, 1) 41 | XCTAssertEqual(result.templates.count, 2) 42 | XCTAssertEqual(result.folders[0].templates.count, 2) 43 | } 44 | 45 | func testTemplateRemoval() throws { 46 | XCTAssertTrue(try manager.templateFolder(at: folder.rootUrl).folders.isEmpty) 47 | let sub = folder.createFolder(named: "TOREMOVE") 48 | XCTAssertFalse(try manager.templateFolder(at: folder.rootUrl).folders.isEmpty) 49 | try manager.removeTemplateFolder(at: sub.rootUrl) 50 | XCTAssertTrue(try manager.templateFolder(at: folder.rootUrl).folders.isEmpty) 51 | } 52 | 53 | static var allTests = [ 54 | ("testThreeBasicTemplatesExample", testThreeBasicTemplatesExample), 55 | ("testTemplateHierarchyExample", testTemplateHierarchyExample), 56 | ("testTemplateRemoval", testTemplateRemoval) 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /Tests/XCResourceTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(XCTemplateFileManagerTests.allTests), 7 | testCase(XCTemplateDownloaderTests.allTests), 8 | ] 9 | } 10 | #endif 11 | --------------------------------------------------------------------------------