├── .swiftformat ├── Tribute.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ ├── TributeTests.xcscheme │ │ └── Tribute.xcscheme └── project.pbxproj ├── Package.swift ├── .gitignore ├── Sources ├── main.swift ├── Info.plist ├── Globs.swift ├── Template.swift └── Tribute.swift ├── Tests ├── Info.plist ├── MetadataTests.swift ├── TemplateTests.swift └── GlobsTests.swift ├── .github └── workflows │ ├── linux_build.yml │ └── build.yml ├── LICENSE.md ├── CHANGELOG.md └── README.md /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 5.1 2 | --enable isEmpty 3 | --ifdef no-indent 4 | --self init-only 5 | --wraparguments before-first 6 | --wrapcollections before-first 7 | --maxwidth 120 8 | 9 | --exclude Tests/XCTestManifests.swift -------------------------------------------------------------------------------- /Tribute.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tribute.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Tribute", 6 | platforms: [.macOS(.v10_13)], 7 | products: [ 8 | .executable(name: "tribute", targets: ["Tribute"]), 9 | ], 10 | targets: [ 11 | .target(name: "Tribute", path: "Sources"), 12 | ] 13 | ) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | Build/ 7 | html/ 8 | .build 9 | .swiftpm 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | *.xccheckout 20 | *.moved-aside 21 | DerivedData 22 | *.hmap 23 | *.ipa 24 | *.dot 25 | -------------------------------------------------------------------------------- /Sources/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Tribute 4 | // 5 | // Created by Nick Lockwood on 30/11/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ExitCode: Int32 { 11 | case ok = 0 // EX_OK 12 | case error = 70 // EX_SOFTWARE 13 | } 14 | 15 | do { 16 | let tribute = Tribute() 17 | print(try tribute.run(in: FileManager.default.currentDirectoryPath)) 18 | exit(ExitCode.ok.rawValue) 19 | } catch { 20 | print("error: \(error)") 21 | exit(ExitCode.error.rawValue) 22 | } 23 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/linux_build.yml: -------------------------------------------------------------------------------- 1 | name: Build for Linux 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: 'Ref to build (branch, tag or SHA)' 8 | required: false 9 | default: 'master' 10 | 11 | jobs: 12 | build: 13 | name: Build Tribute for Linux 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | with: 18 | ref: ${{ github.event.inputs.ref }} 19 | - name: Build it 20 | run: | 21 | swift build --configuration release 22 | SWIFTFORMAT_BIN_PATH=`swift build --configuration release --show-bin-path` 23 | mv $SWIFTFORMAT_BIN_PATH/tribute "${HOME}/tribute_linux" 24 | - name: 'Upload Artifact' 25 | uses: actions/upload-artifact@v2 26 | with: 27 | name: tribute_linux 28 | path: ~/tribute_linux 29 | retention-days: 5 30 | -------------------------------------------------------------------------------- /Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSHumanReadableCopyright 24 | Copyright © 2016 Nick Lockwood. All rights reserved. 25 | NSPrincipalClass 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nick Lockwood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | jobs: 7 | macos: 8 | runs-on: macos-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | - name: Build and Test 13 | run: | 14 | xcodebuild -scheme "Tribute" -sdk macosx clean build test -enableCodeCoverage YES -derivedDataPath Build/ 15 | cd Build/Build/ProfileData 16 | cd $(ls -d */|head -n 1) 17 | directory=${PWD##*/} 18 | pathCoverage=Build/Build/ProfileData/${directory}/Coverage.profdata 19 | cd ../../../../ 20 | xcrun llvm-cov export -format="lcov" -instr-profile $pathCoverage Build/Build/Products/Debug/tribute > info.lcov 21 | bash <(curl https://codecov.io/bash) -t f6548cb5-a884-4db9-a759-6ab786662461 22 | env: 23 | DEVELOPER_DIR: /Applications/Xcode_11.7.app/Contents/Developer 24 | linux: 25 | runs-on: ubuntu-latest 26 | container: 27 | image: swift:5.3 28 | options: --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --security-opt apparmor=unconfined 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v1 32 | - name: Build 33 | run: swift build 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.4.0](https://github.com/nicklockwood/Tribute/releases/tag/0.4.0) (2022-06-01) 4 | 5 | - Added version info for libraries (when using SPM) 6 | - Removed extraneous whitespace from JSON and XML export 7 | - Added version info for SPM packages 8 | - Added Plist export option 9 | - Added Linux support 10 | 11 | ## [0.3.1](https://github.com/nicklockwood/Tribute/releases/tag/0.3.1) (2022-04-04) 12 | 13 | - Added support for SPM Package.resolved version 2 format (thanks @gewill!) 14 | - Improved globs file matching logic 15 | 16 | ## [0.3.0](https://github.com/nicklockwood/Tribute/releases/tag/0.3.0) (2021-02-20) 17 | 18 | - Added `spmcache` parameter to support custom SPM cache locations (thanks @dottore!) 19 | 20 | ## [0.2.2](https://github.com/nicklockwood/Tribute/releases/tag/0.2.2) (2021-02-11) 21 | 22 | - Improved directory scanning performance by 4x (thanks @ejensen!) 23 | 24 | ## [0.2.1](https://github.com/nicklockwood/Tribute/releases/tag/0.2.1) (2021-02-05) 25 | 26 | - Set 10.13 as the minimum macOS requirement in Package.swift 27 | - Fixed error in test target 28 | 29 | ## [0.2](https://github.com/nicklockwood/Tribute/releases/tag/0.2) (2020-12-04) 30 | 31 | - Added support for Swift Package Manager dependencies (which aren't stored inside the project) 32 | 33 | ## [0.1](https://github.com/nicklockwood/Tribute/releases/tag/0.1) (2020-12-02) 34 | 35 | - First release 36 | -------------------------------------------------------------------------------- /Tests/MetadataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataTests.swift 3 | // TributeTests 4 | // 5 | // Created by Nick Lockwood on 31/05/2022. 6 | // 7 | 8 | import XCTest 9 | 10 | private let projectDirectory = URL(fileURLWithPath: #file) 11 | .deletingLastPathComponent().deletingLastPathComponent() 12 | 13 | private let projectURL = projectDirectory 14 | .appendingPathComponent("Tribute.xcodeproj") 15 | .appendingPathComponent("project.pbxproj") 16 | 17 | private let changelogURL = projectDirectory 18 | .appendingPathComponent("CHANGELOG.md") 19 | 20 | private let tributeFileURL = projectDirectory 21 | .appendingPathComponent("Sources") 22 | .appendingPathComponent("Tribute.swift") 23 | 24 | private let tributeVersion: String = { 25 | let string = try! String(contentsOf: projectURL) 26 | let start = string.range(of: "MARKETING_VERSION = ")!.upperBound 27 | let end = string.range(of: ";", range: start ..< string.endIndex)!.lowerBound 28 | return String(string[start ..< end]) 29 | }() 30 | 31 | class MetadataTests: XCTestCase { 32 | // MARK: Releases 33 | 34 | func testLatestVersionInChangelog() { 35 | let changelog = try! String(contentsOf: changelogURL, encoding: .utf8) 36 | XCTAssertTrue( 37 | changelog.contains("[\(tributeVersion)]"), 38 | "CHANGELOG.md does not mention latest release" 39 | ) 40 | XCTAssertTrue( 41 | changelog.contains( 42 | "(https://github.com/nicklockwood/Tribute/" + 43 | "releases/tag/\(tributeVersion))" 44 | ), 45 | "CHANGELOG.md does not include correct link for latest release" 46 | ) 47 | } 48 | 49 | func testVersionConstantUpdated() throws { 50 | let source = try String(contentsOf: tributeFileURL) 51 | XCTAssertNotNil(source.range(of: """ 52 | case .version: 53 | return "\(tributeVersion)" 54 | """)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tribute.xcodeproj/xcshareddata/xcschemes/TributeTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Tribute.xcodeproj/xcshareddata/xcschemes/Tribute.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 70 | 76 | 77 | 78 | 79 | 85 | 87 | 93 | 94 | 95 | 96 | 98 | 99 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /Tests/TemplateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TributeTests.swift 3 | // TributeTests 4 | // 5 | // Created by Nick Lockwood on 01/12/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | class TemplateTests: XCTestCase { 11 | // MARK: Fields 12 | 13 | func testName() throws { 14 | let template = Template(rawValue: "foo$namebaz") 15 | let library = Library( 16 | name: "bar", 17 | licensePath: "", 18 | licenseType: .mit, 19 | licenseText: "" 20 | ) 21 | XCTAssertEqual(try template.render([library], as: .text), "foobarbaz") 22 | } 23 | 24 | func testType() throws { 25 | let template = Template(rawValue: "foo$typebaz") 26 | let library = Library( 27 | name: "bar", 28 | licensePath: "", 29 | licenseType: .mit, 30 | licenseText: "" 31 | ) 32 | XCTAssertEqual(try template.render([library], as: .text), "fooMITbaz") 33 | } 34 | 35 | func testText() throws { 36 | let template = Template(rawValue: "foo$textbaz") 37 | let library = Library( 38 | name: "quux", 39 | licensePath: "", 40 | licenseType: .mit, 41 | licenseText: "bar" 42 | ) 43 | XCTAssertEqual(try template.render([library], as: .text), "foobarbaz") 44 | } 45 | 46 | func testHeaderAndFooter() throws { 47 | let template = Template(rawValue: """ 48 | foo 49 | $start 50 | $name 51 | $end 52 | bar 53 | """) 54 | let library = Library( 55 | name: "quux", 56 | licensePath: "", 57 | licenseType: .mit, 58 | licenseText: "bar" 59 | ) 60 | XCTAssertEqual(try template.render([library, library], as: .text), """ 61 | foo 62 | quux 63 | quux 64 | bar 65 | """) 66 | } 67 | 68 | func testInlineHeaderAndFooter() throws { 69 | let template = Template(rawValue: """ 70 | foo$start$name$endbar 71 | """) 72 | let library = Library( 73 | name: "quux", 74 | licensePath: "", 75 | licenseType: .mit, 76 | licenseText: "bar" 77 | ) 78 | XCTAssertEqual(try template.render([library, library], as: .text), """ 79 | fooquuxquuxbar 80 | """) 81 | } 82 | 83 | func testSeparator() throws { 84 | let template = Template(rawValue: """ 85 | foo 86 | $start 87 | $name$separator, 88 | $end 89 | bar 90 | """) 91 | let library = Library( 92 | name: "quux", 93 | licensePath: "", 94 | licenseType: .mit, 95 | licenseText: "bar" 96 | ) 97 | XCTAssertEqual(try template.render([library, library], as: .text), """ 98 | foo 99 | quux, 100 | quux 101 | bar 102 | """) 103 | } 104 | 105 | // MARK: Format 106 | 107 | func testJSON() throws { 108 | let template = Template(rawValue: """ 109 | { 110 | "name": "$name", 111 | "version": "$version", 112 | "text": "$text" 113 | } 114 | """) 115 | let library = Library( 116 | name: "Foobar", 117 | version: "1.0.1", 118 | licensePath: "", 119 | licenseType: .mit, 120 | licenseText: """ 121 | line 1 122 | line 2 123 | """ 124 | ) 125 | XCTAssertEqual(try template.render([library], as: .json), """ 126 | { 127 | "name": "Foobar", 128 | "version": "1.0.1", 129 | "text": "line 1\\nline 2" 130 | } 131 | """) 132 | } 133 | 134 | func testXML() throws { 135 | let template = Template(rawValue: """ 136 | 137 | $name 138 | $text 139 | 140 | """) 141 | let library = Library( 142 | name: "foo & bar - \"the best!\"", 143 | licensePath: "", 144 | licenseType: .mit, 145 | licenseText: """ 146 | line 1 147 | line 2 148 | """ 149 | ) 150 | XCTAssertEqual(try template.render([library], as: .xml), """ 151 | 152 | foo & bar - "the best!" 153 | line 1\nline 2 154 | 155 | """) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/nicklockwood/Tribute/actions/workflows/build.yml/badge.svg)](https://github.com/nicklockwood/Tribute/actions/workflows/build.yml) 2 | [![Swift 5.1](https://img.shields.io/badge/swift-5.1-red.svg?style=flat)](https://developer.apple.com/swift) 3 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT) 4 | [![Twitter](https://img.shields.io/badge/twitter-@nicklockwood-blue.svg)](http://twitter.com/nicklockwood) 5 | 6 | Tribute 7 | ======== 8 | 9 | Many open source libraries or frameworks using popular licenses such as MIT or BSD require attribution as part of their licensing conditions. This means that apps using those frameworks need to include the license somewhere (typically in the settings screen). 10 | 11 | Remembering to include all of those licenses and keeping them up-to-date is a frustratingly manual process. It's easy to forget, which can potentially have serious ramifications if the library vendor is litigious. 12 | 13 | Tribute is a command-line tool to simplify the process of generating, checking and maintaining open source licenses in your projects. 14 | 15 | 16 | Installation 17 | ------------ 18 | 19 | You can install the tool on macOS or Linux by building it yourself from source, downloading the prebuilt binaries from the [releases page](https://github.com/nicklockwood/Tribute/releases), or by using [Mint](https://github.com/yonaskolb/Mint). 20 | 21 | ```bash 22 | $ mint install nicklockwood/tribute 23 | ``` 24 | 25 | Usage 26 | ------ 27 | 28 | Once you have installed the `tribute` command-line tool you can run it as follows: 29 | 30 | ```bash 31 | $ cd path/to/your/project 32 | $ tribute list 33 | ``` 34 | 35 | If run from inside your project, this command should list all the open source libraries that you are using. You may find that some libraries are included that you don't think should be. You can ignore these either by using `--skip library-name` and/or `--exclude subfolder` as follows: 36 | 37 | ```bash 38 | $ tribute list --exclude Tests --skip UnusedKit 39 | ``` 40 | 41 | If any libraries are missing, make sure they have a valid `LICENSE` file. Only libraries that include a standard open source license file will be detected by the Tribute tool. 42 | 43 | 44 | Advanced 45 | --------- 46 | 47 | In addition to listing the licenses in a project, Tribute can also generate a file for display in your app or web site. To generate a licenses file, use the following command (note that the file name: 48 | 49 | ```bash 50 | $ tribute export path/to/acknowledgements-file.json 51 | ``` 52 | 53 | Tribute offers a variety of options for configuring the format and structure of this file. For more details run the following command: 54 | 55 | ```bash 56 | $ tribute help export 57 | ``` 58 | 59 | Once you have generated a licenses file and integrated it into your app or website, you might want to configure a script to update it every time you build. To set this up in Xcode, do the following: 60 | 61 | 1. Click on your project in the file list, choose your target under `TARGETS`, click the `Build Phases` tab 62 | 2. Add a `New Run Script Phase` by clicking the little plus icon in the top left and paste in the following script: 63 | 64 | ```bash 65 | if which tribute >/dev/null; then 66 | tribute export path/to/acknowledgements-file.json 67 | else 68 | echo "warning: Tribute not installed, download from https://github.com/nicklockwood/Tribute" 69 | fi 70 | ``` 71 | 72 | If you have a CI (Continuous Integration) setup, you probably don't want to generate this file on the server, but you might want to validate that it has been run as part of your automated test suite. To do that, you can use the `check` command as follows: 73 | 74 | ```bash 75 | $ tribute check path/to/acknowledgements-file.json 76 | ``` 77 | 78 | This command won't change any files, but will produce an error if any non-excluded libraries are missing from the licenses file. 79 | 80 | 81 | Known issues 82 | ------------- 83 | 84 | * Dependency scanning is quite slow for large projects, especially for projects that include Swift Package Manager dependencies. This will be improved in future. 85 | 86 | * Dependency scanning only works if the dependencies have been checked out. If you are using git submodules, or a package manager such as CocoaPods, Carthage or Swift Package Manager, make sure your dependencies have all been resolved before running Tribute (if you are able to successfully build your app you can assume that the dependencies have been resolved). 87 | 88 | * Tribute can only detect dependencies that include a LICENSE file (with or without file extensions). If you have copied a dependency without the LICENSE file, or if the dependency doesn't include such a file, it won't be detected. If you encounter such cases please open an issue and I'll try to find a solution. 89 | 90 | * Version detection only works for libraries included via Swift Package manager. 91 | -------------------------------------------------------------------------------- /Sources/Globs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Globs.swift 3 | // Tribute 4 | // 5 | // Created by Nick Lockwood on 31/12/2018. 6 | // 7 | 8 | import Foundation 9 | 10 | func expandPath(_ path: String, in directory: String) -> URL { 11 | if path.hasPrefix("/") { 12 | return URL(fileURLWithPath: path) 13 | } 14 | if path.hasPrefix("~") { 15 | return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) 16 | } 17 | return URL(fileURLWithPath: directory).appendingPathComponent(path) 18 | } 19 | 20 | func pathContainsGlobSyntax(_ path: String) -> Bool { 21 | "*?[{".contains(where: { path.contains($0) }) 22 | } 23 | 24 | /// Glob type represents either an exact path or wildcard 25 | enum Glob: CustomStringConvertible { 26 | case path(String) 27 | case regex(String, NSRegularExpression) 28 | 29 | func matches(_ path: String) -> Bool { 30 | switch self { 31 | case let .path(_path): 32 | return _path == path 33 | case let .regex(prefix, regex): 34 | guard path.hasPrefix(prefix) else { 35 | return false 36 | } 37 | let count = prefix.utf16.count 38 | let range = NSRange(location: count, length: path.utf16.count - count) 39 | return regex.firstMatch(in: path, options: [], range: range) != nil 40 | } 41 | } 42 | 43 | var description: String { 44 | switch self { 45 | case let .path(path): 46 | return path 47 | case let .regex(prefix, regex): 48 | var result = regex.pattern.dropFirst().dropLast() 49 | .replacingOccurrences(of: "([^/]+)?", with: "*") 50 | .replacingOccurrences(of: "(.+/)?", with: "**/") 51 | .replacingOccurrences(of: ".+", with: "**") 52 | .replacingOccurrences(of: "[^/]", with: "?") 53 | .replacingOccurrences(of: "\\", with: "") 54 | while let range = result.range(of: "\\([^)]+\\)", options: .regularExpression) { 55 | let options = result[range].dropFirst().dropLast().components(separatedBy: "|") 56 | result.replaceSubrange(range, with: "{\(options.joined(separator: ","))}") 57 | } 58 | return prefix + result 59 | } 60 | } 61 | } 62 | 63 | /// Expand one or more comma-delimited file paths using glob syntax 64 | func expandGlobs(_ path: String, in directory: String) -> [Glob] { 65 | guard pathContainsGlobSyntax(path) else { 66 | return [.path(expandPath(path, in: directory).path)] 67 | } 68 | var path = path 69 | var tokens = [String: String]() 70 | while let range = path.range(of: "\\{[^}]+\\}", options: .regularExpression) { 71 | let options = path[range].dropFirst().dropLast() 72 | .replacingOccurrences(of: "[.+(){\\\\|]", with: "\\\\$0", options: .regularExpression) 73 | .components(separatedBy: ",") 74 | let token = "<<<\(tokens.count)>>>" 75 | tokens[token] = "(\(options.joined(separator: "|")))" 76 | path.replaceSubrange(range, with: token) 77 | } 78 | path = expandPath(path, in: directory).path 79 | if FileManager.default.fileExists(atPath: path) { 80 | // TODO: should we also handle cases where path includes tokens? 81 | return [.path(path)] 82 | } 83 | var prefix = "", regex = "" 84 | let parts = path.components(separatedBy: "/") 85 | for (i, part) in parts.enumerated() { 86 | if pathContainsGlobSyntax(part) || part.contains("<<<") { 87 | regex = parts[i...].joined(separator: "/") 88 | break 89 | } 90 | prefix += "\(part)/" 91 | } 92 | regex = "^\(regex)$" 93 | .replacingOccurrences(of: "[.+(){\\\\|]", with: "\\\\$0", options: .regularExpression) 94 | .replacingOccurrences(of: "?", with: "[^/]") 95 | .replacingOccurrences(of: "**/", with: "(.+/)?") 96 | .replacingOccurrences(of: "**", with: ".+") 97 | .replacingOccurrences(of: "*", with: "([^/]+)?") 98 | for (token, replacement) in tokens { 99 | regex = regex.replacingOccurrences(of: token, with: replacement) 100 | } 101 | return [.regex(prefix, try! NSRegularExpression(pattern: regex, options: []))] 102 | } 103 | 104 | func matchGlobs(_ globs: [Glob], in directory: String) throws -> [URL] { 105 | var urls = [URL]() 106 | let keys: [URLResourceKey] = [.isDirectoryKey] 107 | let manager = FileManager.default 108 | func enumerate(_ directory: URL, with glob: Glob) { 109 | guard let files = try? manager.contentsOfDirectory( 110 | at: directory, includingPropertiesForKeys: keys, options: [] 111 | ) else { 112 | return 113 | } 114 | for url in files { 115 | let path = url.path 116 | var isDirectory: ObjCBool = false 117 | if glob.matches(path) { 118 | urls.append(url) 119 | } else if manager.fileExists(atPath: path, isDirectory: &isDirectory), 120 | isDirectory.boolValue 121 | { 122 | enumerate(url, with: glob) 123 | } 124 | } 125 | } 126 | for glob in globs { 127 | switch glob { 128 | case let .path(path): 129 | if manager.fileExists(atPath: path) { 130 | urls.append(URL(fileURLWithPath: path)) 131 | } else { 132 | throw TributeError("File not found at \(glob)") 133 | } 134 | case let .regex(path, _): 135 | let count = urls.count 136 | if directory.hasPrefix(path) { 137 | enumerate(URL(fileURLWithPath: directory).standardized, with: glob) 138 | } else if path.hasPrefix(directory) { 139 | enumerate(URL(fileURLWithPath: path).standardized, with: glob) 140 | } 141 | if count == urls.count { 142 | throw TributeError("Glob did not match any files at \(glob)") 143 | } 144 | } 145 | } 146 | return urls 147 | } 148 | -------------------------------------------------------------------------------- /Sources/Template.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Template.swift 3 | // Tribute 4 | // 5 | // Created by Nick Lockwood on 30/11/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Format: String, CaseIterable { 11 | case text 12 | case xml 13 | case json 14 | case plist 15 | 16 | static func infer(from template: Template) -> Format { 17 | let text = template.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) 18 | if let first = text.first { 19 | if first == "<" { 20 | return text.contains(" Format { 30 | switch url.pathExtension.lowercased() { 31 | case "xml": 32 | return .xml 33 | case "json": 34 | return .json 35 | case "plist": 36 | return .plist 37 | default: 38 | return .text 39 | } 40 | } 41 | } 42 | 43 | struct Template: RawRepresentable { 44 | let rawValue: String 45 | 46 | static func `default`(for format: Format) -> Template { 47 | switch format { 48 | case .text: 49 | return Template(rawValue: "$name ($version)\n\n$text\n\n") 50 | case .xml: 51 | return Template(rawValue: """ 52 | 53 | $start 54 | $name 55 | $version 56 | $type 57 | $text 58 | $separator 59 | $end 60 | 61 | """) 62 | case .plist: 63 | return Template(rawValue: """ 64 | 65 | 67 | 68 | 69 | $start 70 | name 71 | $name 72 | version 73 | $version 74 | type 75 | $type 76 | text 77 | $text 78 | $separator 79 | $end 80 | 81 | 82 | """) 83 | case .json: 84 | return Template(rawValue: """ 85 | [ 86 | $start{ 87 | "name": "$name", 88 | "version": "$version", 89 | "type": "$type", 90 | "text": "$text" 91 | }$separator, 92 | $end 93 | ] 94 | """) 95 | } 96 | } 97 | 98 | init(rawValue: String) { 99 | self.rawValue = rawValue 100 | } 101 | 102 | func render(_ libraries: [Library], as format: Format) throws -> String { 103 | let startRange = rawValue.range(of: "\n$start") ?? rawValue.range(of: "$start") ?? 104 | (rawValue.startIndex ..< rawValue.startIndex) 105 | let endRange = rawValue.range(of: "\n$end") ?? rawValue.range(of: "$end") ?? 106 | (rawValue.endIndex ..< rawValue.endIndex) 107 | let separatorRange = rawValue.range(of: "$separator") ?? (endRange.lowerBound ..< endRange.lowerBound) 108 | if startRange.upperBound > endRange.lowerBound { 109 | throw TributeError("$start must appear before $end") 110 | } 111 | if startRange.upperBound > separatorRange.lowerBound { 112 | throw TributeError("$start must appear before $separator") 113 | } 114 | if separatorRange.upperBound > endRange.lowerBound { 115 | throw TributeError("$separator must appear before $end") 116 | } 117 | 118 | let header = String(rawValue[.. String in 124 | let licenseType = library.licenseType?.rawValue ?? "Unknown" 125 | let version = library.version ?? "Unknown" 126 | return section 127 | .replacingOccurrences( 128 | of: "$name", with: escape(library.name, as: format) 129 | ) 130 | .replacingOccurrences( 131 | of: " ($version)", with: library.version.map { 132 | " (\(escape($0, as: format)))" 133 | } ?? "" 134 | ) 135 | .replacingOccurrences( 136 | of: "($version)", with: library.version.map { 137 | "(\(escape($0, as: format)))" 138 | } ?? "" 139 | ) 140 | .replacingOccurrences( 141 | of: "$version", with: escape(version, as: format) 142 | ) 143 | .replacingOccurrences( 144 | of: "$type", with: escape(licenseType, as: format) 145 | ) 146 | .replacingOccurrences( 147 | of: "$text", with: escape(library.licenseText, as: format) 148 | ) 149 | }.joined(separator: separator) 150 | 151 | return header + body + footer 152 | } 153 | 154 | func escape(_ text: String, as format: Format) -> String { 155 | switch format { 156 | case .text: 157 | return text 158 | case .json: 159 | let jsonEncoder = JSONEncoder() 160 | if #available(macOS 10.15, *) { 161 | jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 162 | } else { 163 | jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] 164 | } 165 | let data = (try? jsonEncoder.encode(text)) ?? Data() 166 | return "\(String(data: data, encoding: .utf8)?.dropFirst().dropLast() ?? "")" 167 | case .xml, .plist: 168 | let plistEncoder = PropertyListEncoder() 169 | plistEncoder.outputFormat = .xml 170 | let data = (try? plistEncoder.encode([text])) ?? Data() 171 | let text = String(data: data, encoding: .utf8) ?? "" 172 | let start = text.range(of: "")?.upperBound ?? text.startIndex 173 | let end = text.range(of: "")?.lowerBound ?? text.endIndex 174 | return String(text[start ..< end]).replacingOccurrences(of: "\"", with: """) 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Tests/GlobsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobsTests.swift 3 | // TributeTests 4 | // 5 | // Created by Nick Lockwood on 01/12/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | class GlobsTests: XCTestCase { 11 | // MARK: Glob matching 12 | 13 | func testExpandWildcardPathWithExactName() { 14 | let path = "GlobsTests.swift" 15 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 16 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 17 | } 18 | 19 | func testExpandPathWithWildcardInMiddle() { 20 | let path = "Globs*.swift" 21 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 22 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 23 | } 24 | 25 | func testExpandPathWithSingleCharacterWildcardInMiddle() { 26 | let path = "GlobsTest?.swift" 27 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 28 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 29 | } 30 | 31 | func testExpandPathWithWildcardAtEnd() { 32 | let path = "Glo*" 33 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 34 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 35 | } 36 | 37 | func testExpandPathWithDoubleWildcardAtEnd() { 38 | let path = "Glob**" 39 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 40 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 41 | } 42 | 43 | func testExpandPathWithCharacterClass() { 44 | let path = "Glob[sZ]*.swift" 45 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 46 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 47 | } 48 | 49 | func testExpandPathWithCharacterClassRange() { 50 | let path = "T[e-r]*.swift" 51 | let directory = URL(fileURLWithPath: #file) 52 | .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources") 53 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 2) 54 | } 55 | 56 | func testExpandPathWithEitherOr() { 57 | let path = "T{emplate,ribute}.swift" 58 | let directory = URL(fileURLWithPath: #file) 59 | .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources") 60 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 2) 61 | } 62 | 63 | func testExpandPathWithWildcardAtStart() { 64 | let path = "*Tests.swift" 65 | let directory = URL(fileURLWithPath: #file).deletingLastPathComponent() 66 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 3) 67 | } 68 | 69 | func testExpandPathWithSubdirectoryAndWildcard() { 70 | let path = "Tests/*Tests.swift" 71 | let directory = URL(fileURLWithPath: #file) 72 | .deletingLastPathComponent().deletingLastPathComponent() 73 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 3) 74 | } 75 | 76 | func testSingleWildcardDoesNotMatchDirectorySlash() { 77 | let path = "*Tests.swift" 78 | let directory = URL(fileURLWithPath: #file) 79 | .deletingLastPathComponent().deletingLastPathComponent() 80 | XCTAssertThrowsError(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path)) 81 | } 82 | 83 | func testDoubleWildcardMatchesDirectorySlash() { 84 | let path = "**Tests.swift" 85 | let directory = URL(fileURLWithPath: #file) 86 | .deletingLastPathComponent().deletingLastPathComponent() 87 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 3) 88 | } 89 | 90 | func testDoubleWildcardMatchesNoSubdirectories() { 91 | let path = "Tests/**/GlobsTests.swift" 92 | let directory = URL(fileURLWithPath: #file) 93 | .deletingLastPathComponent().deletingLastPathComponent() 94 | XCTAssertEqual(try matchGlobs(expandGlobs(path, in: directory.path), in: directory.path).count, 1) 95 | } 96 | 97 | // MARK: glob regex 98 | 99 | func testWildcardRegex() { 100 | let path = "/Rule*.swift" 101 | let directory = URL(fileURLWithPath: #file) 102 | guard case let .regex(_, regex) = expandGlobs(path, in: directory.path)[0] else { 103 | return 104 | } 105 | XCTAssertEqual(regex.pattern, "^Rule([^/]+)?\\.swift$") 106 | } 107 | 108 | func testDoubleWildcardRegex() { 109 | let path = "/**Rule.swift" 110 | let directory = URL(fileURLWithPath: #file) 111 | guard case let .regex(_, regex) = expandGlobs(path, in: directory.path)[0] else { 112 | return 113 | } 114 | XCTAssertEqual(regex.pattern, "^.+Rule\\.swift$") 115 | } 116 | 117 | func testDoubleWildcardSlashRegex() { 118 | let path = "/**/Rule.swift" 119 | let directory = URL(fileURLWithPath: #file) 120 | guard case let .regex(_, regex) = expandGlobs(path, in: directory.path)[0] else { 121 | return 122 | } 123 | XCTAssertEqual(regex.pattern, "^(.+/)?Rule\\.swift$") 124 | } 125 | 126 | func testEitherOrRegex() { 127 | let path = "/SwiftFormat.{h,swift}" 128 | let directory = URL(fileURLWithPath: #file) 129 | guard case let .regex(_, regex) = expandGlobs(path, in: directory.path)[0] else { 130 | return 131 | } 132 | XCTAssertEqual(regex.pattern, "^SwiftFormat\\.(h|swift)$") 133 | } 134 | 135 | func testEitherOrContainingDotRegex() { 136 | let path = "/SwiftFormat{.h,.swift}" 137 | let directory = URL(fileURLWithPath: #file) 138 | guard case let .regex(_, regex) = expandGlobs(path, in: directory.path)[0] else { 139 | return 140 | } 141 | XCTAssertEqual(regex.pattern, "^SwiftFormat(\\.h|\\.swift)$") 142 | } 143 | 144 | // MARK: glob description 145 | 146 | func testGlobPathDescription() { 147 | let path = "/foo/bar.swift" 148 | let directory = URL(fileURLWithPath: #file) 149 | let globs = expandGlobs(path, in: directory.path) 150 | XCTAssertEqual(globs[0].description, path) 151 | } 152 | 153 | func testGlobWildcardDescription() { 154 | let path = "/foo/*.swift" 155 | let directory = URL(fileURLWithPath: #file) 156 | let globs = expandGlobs(path, in: directory.path) 157 | XCTAssertEqual(globs[0].description, path) 158 | } 159 | 160 | func testGlobDoubleWildcardDescription() { 161 | let path = "/foo/**bar.swift" 162 | let directory = URL(fileURLWithPath: #file) 163 | let globs = expandGlobs(path, in: directory.path) 164 | XCTAssertEqual(globs[0].description, path) 165 | } 166 | 167 | func testGlobDoubleWildcardSlashDescription() { 168 | let path = "/foo/**/bar.swift" 169 | let directory = URL(fileURLWithPath: #file) 170 | let globs = expandGlobs(path, in: directory.path) 171 | XCTAssertEqual(globs[0].description, path) 172 | } 173 | 174 | func testGlobSingleCharacterWildcardDescription() { 175 | let path = "/foo/ba?.swift" 176 | let directory = URL(fileURLWithPath: #file) 177 | let globs = expandGlobs(path, in: directory.path) 178 | XCTAssertEqual(globs[0].description, path) 179 | } 180 | 181 | func testGlobEitherOrDescription() { 182 | let path = "/foo/{bar,baz}.swift" 183 | let directory = URL(fileURLWithPath: #file) 184 | let globs = expandGlobs(path, in: directory.path) 185 | XCTAssertEqual(globs[0].description, path) 186 | } 187 | 188 | func testGlobEitherOrWithDotsDescription() { 189 | let path = "/foo{.swift,.txt}" 190 | let directory = URL(fileURLWithPath: #file) 191 | let globs = expandGlobs(path, in: directory.path) 192 | XCTAssertEqual(globs[0].description, path) 193 | } 194 | 195 | func testGlobCharacterClassDescription() { 196 | let path = "/Options[DS]*.swift" 197 | let directory = URL(fileURLWithPath: #file) 198 | let globs = expandGlobs(path, in: directory.path) 199 | XCTAssertEqual(globs[0].description, path) 200 | } 201 | 202 | func testGlobCharacterRangeDescription() { 203 | let path = "/Options[D-S]*.swift" 204 | let directory = URL(fileURLWithPath: #file) 205 | let globs = expandGlobs(path, in: directory.path) 206 | XCTAssertEqual(globs[0].description, path) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /Tribute.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 016C8F0F257583CE00692CEE /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F0E257583CE00692CEE /* main.swift */; }; 11 | 016C8F292575AB8600692CEE /* Globs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F282575AB8600692CEE /* Globs.swift */; }; 12 | 016C8F2C2575AB9A00692CEE /* Tribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F2B2575AB9A00692CEE /* Tribute.swift */; }; 13 | 016C8F302575B65600692CEE /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F2F2575B65600692CEE /* Template.swift */; }; 14 | 016C8F392576431D00692CEE /* TemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F382576431D00692CEE /* TemplateTests.swift */; }; 15 | 016C8F41257645B700692CEE /* GlobsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F40257645B700692CEE /* GlobsTests.swift */; }; 16 | 016C8F46257645DF00692CEE /* Globs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F282575AB8600692CEE /* Globs.swift */; }; 17 | 016C8F4D257649CA00692CEE /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F2F2575B65600692CEE /* Template.swift */; }; 18 | 016C8F50257649E000692CEE /* Tribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C8F2B2575AB9A00692CEE /* Tribute.swift */; }; 19 | 01C6F9A828467CEB000B57B1 /* MetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C6F9A628467C2C000B57B1 /* MetadataTests.swift */; }; 20 | /* End PBXBuildFile section */ 21 | 22 | /* Begin PBXCopyFilesBuildPhase section */ 23 | 016C8F09257583CE00692CEE /* CopyFiles */ = { 24 | isa = PBXCopyFilesBuildPhase; 25 | buildActionMask = 2147483647; 26 | dstPath = /usr/share/man/man1/; 27 | dstSubfolderSpec = 0; 28 | files = ( 29 | ); 30 | runOnlyForDeploymentPostprocessing = 1; 31 | }; 32 | /* End PBXCopyFilesBuildPhase section */ 33 | 34 | /* Begin PBXFileReference section */ 35 | 016C8F0B257583CE00692CEE /* tribute */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = tribute; sourceTree = BUILT_PRODUCTS_DIR; }; 36 | 016C8F0E257583CE00692CEE /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 37 | 016C8F282575AB8600692CEE /* Globs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Globs.swift; sourceTree = ""; }; 38 | 016C8F2B2575AB9A00692CEE /* Tribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tribute.swift; sourceTree = ""; }; 39 | 016C8F2F2575B65600692CEE /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = ""; }; 40 | 016C8F362576431C00692CEE /* TributeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TributeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 41 | 016C8F382576431D00692CEE /* TemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateTests.swift; sourceTree = ""; }; 42 | 016C8F3A2576431D00692CEE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43 | 016C8F40257645B700692CEE /* GlobsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobsTests.swift; sourceTree = ""; }; 44 | 0180747F27FB647A0038589F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 45 | 01C6F9A628467C2C000B57B1 /* MetadataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetadataTests.swift; sourceTree = ""; }; 46 | /* End PBXFileReference section */ 47 | 48 | /* Begin PBXFrameworksBuildPhase section */ 49 | 016C8F08257583CE00692CEE /* Frameworks */ = { 50 | isa = PBXFrameworksBuildPhase; 51 | buildActionMask = 2147483647; 52 | files = ( 53 | ); 54 | runOnlyForDeploymentPostprocessing = 0; 55 | }; 56 | 016C8F332576431C00692CEE /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | ); 61 | runOnlyForDeploymentPostprocessing = 0; 62 | }; 63 | /* End PBXFrameworksBuildPhase section */ 64 | 65 | /* Begin PBXGroup section */ 66 | 016C8F02257583CE00692CEE = { 67 | isa = PBXGroup; 68 | children = ( 69 | 016C8F0D257583CE00692CEE /* Sources */, 70 | 016C8F372576431D00692CEE /* Tests */, 71 | 016C8F0C257583CE00692CEE /* Products */, 72 | ); 73 | indentWidth = 4; 74 | sourceTree = ""; 75 | tabWidth = 4; 76 | }; 77 | 016C8F0C257583CE00692CEE /* Products */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | 016C8F0B257583CE00692CEE /* tribute */, 81 | 016C8F362576431C00692CEE /* TributeTests.xctest */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | 016C8F0D257583CE00692CEE /* Sources */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 016C8F282575AB8600692CEE /* Globs.swift */, 90 | 016C8F2F2575B65600692CEE /* Template.swift */, 91 | 016C8F2B2575AB9A00692CEE /* Tribute.swift */, 92 | 016C8F0E257583CE00692CEE /* main.swift */, 93 | 0180747F27FB647A0038589F /* Info.plist */, 94 | ); 95 | path = Sources; 96 | sourceTree = ""; 97 | }; 98 | 016C8F372576431D00692CEE /* Tests */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | 016C8F40257645B700692CEE /* GlobsTests.swift */, 102 | 016C8F382576431D00692CEE /* TemplateTests.swift */, 103 | 01C6F9A628467C2C000B57B1 /* MetadataTests.swift */, 104 | 016C8F3A2576431D00692CEE /* Info.plist */, 105 | ); 106 | path = Tests; 107 | sourceTree = ""; 108 | }; 109 | /* End PBXGroup section */ 110 | 111 | /* Begin PBXNativeTarget section */ 112 | 016C8F0A257583CE00692CEE /* Tribute */ = { 113 | isa = PBXNativeTarget; 114 | buildConfigurationList = 016C8F12257583CE00692CEE /* Build configuration list for PBXNativeTarget "Tribute" */; 115 | buildPhases = ( 116 | 016C8F07257583CE00692CEE /* Sources */, 117 | 016C8F08257583CE00692CEE /* Frameworks */, 118 | 016C8F09257583CE00692CEE /* CopyFiles */, 119 | ); 120 | buildRules = ( 121 | ); 122 | dependencies = ( 123 | ); 124 | name = Tribute; 125 | productName = Tribute; 126 | productReference = 016C8F0B257583CE00692CEE /* tribute */; 127 | productType = "com.apple.product-type.tool"; 128 | }; 129 | 016C8F352576431C00692CEE /* TributeTests */ = { 130 | isa = PBXNativeTarget; 131 | buildConfigurationList = 016C8F3B2576431D00692CEE /* Build configuration list for PBXNativeTarget "TributeTests" */; 132 | buildPhases = ( 133 | 016C8F322576431C00692CEE /* Sources */, 134 | 016C8F332576431C00692CEE /* Frameworks */, 135 | 016C8F342576431C00692CEE /* Resources */, 136 | 0178CD952576BE3900CCC41B /* Format Code */, 137 | ); 138 | buildRules = ( 139 | ); 140 | dependencies = ( 141 | ); 142 | name = TributeTests; 143 | productName = TributeTests; 144 | productReference = 016C8F362576431C00692CEE /* TributeTests.xctest */; 145 | productType = "com.apple.product-type.bundle.unit-test"; 146 | }; 147 | /* End PBXNativeTarget section */ 148 | 149 | /* Begin PBXProject section */ 150 | 016C8F03257583CE00692CEE /* Project object */ = { 151 | isa = PBXProject; 152 | attributes = { 153 | LastSwiftUpdateCheck = 1220; 154 | LastUpgradeCheck = 1220; 155 | TargetAttributes = { 156 | 016C8F0A257583CE00692CEE = { 157 | CreatedOnToolsVersion = 12.2; 158 | }; 159 | 016C8F352576431C00692CEE = { 160 | CreatedOnToolsVersion = 12.2; 161 | }; 162 | }; 163 | }; 164 | buildConfigurationList = 016C8F06257583CE00692CEE /* Build configuration list for PBXProject "Tribute" */; 165 | compatibilityVersion = "Xcode 9.3"; 166 | developmentRegion = en; 167 | hasScannedForEncodings = 0; 168 | knownRegions = ( 169 | en, 170 | Base, 171 | ); 172 | mainGroup = 016C8F02257583CE00692CEE; 173 | productRefGroup = 016C8F0C257583CE00692CEE /* Products */; 174 | projectDirPath = ""; 175 | projectRoot = ""; 176 | targets = ( 177 | 016C8F0A257583CE00692CEE /* Tribute */, 178 | 016C8F352576431C00692CEE /* TributeTests */, 179 | ); 180 | }; 181 | /* End PBXProject section */ 182 | 183 | /* Begin PBXResourcesBuildPhase section */ 184 | 016C8F342576431C00692CEE /* Resources */ = { 185 | isa = PBXResourcesBuildPhase; 186 | buildActionMask = 2147483647; 187 | files = ( 188 | ); 189 | runOnlyForDeploymentPostprocessing = 0; 190 | }; 191 | /* End PBXResourcesBuildPhase section */ 192 | 193 | /* Begin PBXShellScriptBuildPhase section */ 194 | 0178CD952576BE3900CCC41B /* Format Code */ = { 195 | isa = PBXShellScriptBuildPhase; 196 | buildActionMask = 2147483647; 197 | files = ( 198 | ); 199 | inputFileListPaths = ( 200 | ); 201 | inputPaths = ( 202 | ); 203 | name = "Format Code"; 204 | outputFileListPaths = ( 205 | ); 206 | outputPaths = ( 207 | ); 208 | runOnlyForDeploymentPostprocessing = 0; 209 | shellPath = /bin/sh; 210 | shellScript = "if which swiftformat >/dev/null; then\n swiftformat .\nelse\n echo \"warning: SwiftFormat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; 211 | }; 212 | /* End PBXShellScriptBuildPhase section */ 213 | 214 | /* Begin PBXSourcesBuildPhase section */ 215 | 016C8F07257583CE00692CEE /* Sources */ = { 216 | isa = PBXSourcesBuildPhase; 217 | buildActionMask = 2147483647; 218 | files = ( 219 | 016C8F0F257583CE00692CEE /* main.swift in Sources */, 220 | 016C8F302575B65600692CEE /* Template.swift in Sources */, 221 | 016C8F2C2575AB9A00692CEE /* Tribute.swift in Sources */, 222 | 016C8F292575AB8600692CEE /* Globs.swift in Sources */, 223 | ); 224 | runOnlyForDeploymentPostprocessing = 0; 225 | }; 226 | 016C8F322576431C00692CEE /* Sources */ = { 227 | isa = PBXSourcesBuildPhase; 228 | buildActionMask = 2147483647; 229 | files = ( 230 | 016C8F50257649E000692CEE /* Tribute.swift in Sources */, 231 | 016C8F392576431D00692CEE /* TemplateTests.swift in Sources */, 232 | 01C6F9A828467CEB000B57B1 /* MetadataTests.swift in Sources */, 233 | 016C8F4D257649CA00692CEE /* Template.swift in Sources */, 234 | 016C8F41257645B700692CEE /* GlobsTests.swift in Sources */, 235 | 016C8F46257645DF00692CEE /* Globs.swift in Sources */, 236 | ); 237 | runOnlyForDeploymentPostprocessing = 0; 238 | }; 239 | /* End PBXSourcesBuildPhase section */ 240 | 241 | /* Begin XCBuildConfiguration section */ 242 | 016C8F10257583CE00692CEE /* Debug */ = { 243 | isa = XCBuildConfiguration; 244 | buildSettings = { 245 | ALWAYS_SEARCH_USER_PATHS = NO; 246 | CLANG_ANALYZER_NONNULL = YES; 247 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 248 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 249 | CLANG_CXX_LIBRARY = "libc++"; 250 | CLANG_ENABLE_MODULES = YES; 251 | CLANG_ENABLE_OBJC_ARC = YES; 252 | CLANG_ENABLE_OBJC_WEAK = YES; 253 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 254 | CLANG_WARN_BOOL_CONVERSION = YES; 255 | CLANG_WARN_COMMA = YES; 256 | CLANG_WARN_CONSTANT_CONVERSION = YES; 257 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 258 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 259 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 260 | CLANG_WARN_EMPTY_BODY = YES; 261 | CLANG_WARN_ENUM_CONVERSION = YES; 262 | CLANG_WARN_INFINITE_RECURSION = YES; 263 | CLANG_WARN_INT_CONVERSION = YES; 264 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 265 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 266 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 267 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 268 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 269 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 270 | CLANG_WARN_STRICT_PROTOTYPES = YES; 271 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 272 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 273 | CLANG_WARN_UNREACHABLE_CODE = YES; 274 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 275 | COPY_PHASE_STRIP = NO; 276 | DEBUG_INFORMATION_FORMAT = dwarf; 277 | ENABLE_STRICT_OBJC_MSGSEND = YES; 278 | ENABLE_TESTABILITY = YES; 279 | GCC_C_LANGUAGE_STANDARD = gnu11; 280 | GCC_DYNAMIC_NO_PIC = NO; 281 | GCC_NO_COMMON_BLOCKS = YES; 282 | GCC_OPTIMIZATION_LEVEL = 0; 283 | GCC_PREPROCESSOR_DEFINITIONS = ( 284 | "DEBUG=1", 285 | "$(inherited)", 286 | ); 287 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 288 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 289 | GCC_WARN_UNDECLARED_SELECTOR = YES; 290 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 291 | GCC_WARN_UNUSED_FUNCTION = YES; 292 | GCC_WARN_UNUSED_VARIABLE = YES; 293 | MACOSX_DEPLOYMENT_TARGET = 10.15; 294 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 295 | MTL_FAST_MATH = YES; 296 | ONLY_ACTIVE_ARCH = YES; 297 | SDKROOT = macosx; 298 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 299 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 300 | }; 301 | name = Debug; 302 | }; 303 | 016C8F11257583CE00692CEE /* Release */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ALWAYS_SEARCH_USER_PATHS = NO; 307 | CLANG_ANALYZER_NONNULL = YES; 308 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 309 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 310 | CLANG_CXX_LIBRARY = "libc++"; 311 | CLANG_ENABLE_MODULES = YES; 312 | CLANG_ENABLE_OBJC_ARC = YES; 313 | CLANG_ENABLE_OBJC_WEAK = YES; 314 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 315 | CLANG_WARN_BOOL_CONVERSION = YES; 316 | CLANG_WARN_COMMA = YES; 317 | CLANG_WARN_CONSTANT_CONVERSION = YES; 318 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 319 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 320 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 321 | CLANG_WARN_EMPTY_BODY = YES; 322 | CLANG_WARN_ENUM_CONVERSION = YES; 323 | CLANG_WARN_INFINITE_RECURSION = YES; 324 | CLANG_WARN_INT_CONVERSION = YES; 325 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 326 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 327 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 328 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 329 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 330 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 331 | CLANG_WARN_STRICT_PROTOTYPES = YES; 332 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 333 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 334 | CLANG_WARN_UNREACHABLE_CODE = YES; 335 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 336 | COPY_PHASE_STRIP = NO; 337 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 338 | ENABLE_NS_ASSERTIONS = NO; 339 | ENABLE_STRICT_OBJC_MSGSEND = YES; 340 | GCC_C_LANGUAGE_STANDARD = gnu11; 341 | GCC_NO_COMMON_BLOCKS = YES; 342 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 343 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 344 | GCC_WARN_UNDECLARED_SELECTOR = YES; 345 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 346 | GCC_WARN_UNUSED_FUNCTION = YES; 347 | GCC_WARN_UNUSED_VARIABLE = YES; 348 | MACOSX_DEPLOYMENT_TARGET = 10.15; 349 | MTL_ENABLE_DEBUG_INFO = NO; 350 | MTL_FAST_MATH = YES; 351 | SDKROOT = macosx; 352 | SWIFT_COMPILATION_MODE = wholemodule; 353 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 354 | }; 355 | name = Release; 356 | }; 357 | 016C8F13257583CE00692CEE /* Debug */ = { 358 | isa = XCBuildConfiguration; 359 | buildSettings = { 360 | CODE_SIGN_IDENTITY = "-"; 361 | CODE_SIGN_STYLE = Manual; 362 | DEVELOPMENT_TEAM = 8VQKF583ED; 363 | ENABLE_HARDENED_RUNTIME = NO; 364 | INFOPLIST_FILE = "$(SRCROOT)/Sources/Info.plist"; 365 | MARKETING_VERSION = 0.4.0; 366 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.Tribute; 367 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 368 | PRODUCT_NAME = tribute; 369 | PROVISIONING_PROFILE_SPECIFIER = ""; 370 | SWIFT_VERSION = 5.0; 371 | }; 372 | name = Debug; 373 | }; 374 | 016C8F14257583CE00692CEE /* Release */ = { 375 | isa = XCBuildConfiguration; 376 | buildSettings = { 377 | CODE_SIGN_IDENTITY = "-"; 378 | CODE_SIGN_STYLE = Manual; 379 | DEVELOPMENT_TEAM = 8VQKF583ED; 380 | ENABLE_HARDENED_RUNTIME = NO; 381 | INFOPLIST_FILE = "$(SRCROOT)/Sources/Info.plist"; 382 | MARKETING_VERSION = 0.4.0; 383 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.Tribute; 384 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 385 | PRODUCT_NAME = tribute; 386 | PROVISIONING_PROFILE_SPECIFIER = ""; 387 | SWIFT_VERSION = 5.0; 388 | }; 389 | name = Release; 390 | }; 391 | 016C8F3C2576431D00692CEE /* Debug */ = { 392 | isa = XCBuildConfiguration; 393 | buildSettings = { 394 | CODE_SIGN_STYLE = Automatic; 395 | COMBINE_HIDPI_IMAGES = YES; 396 | INFOPLIST_FILE = Tests/Info.plist; 397 | LD_RUNPATH_SEARCH_PATHS = ( 398 | "$(inherited)", 399 | "@executable_path/../Frameworks", 400 | "@loader_path/../Frameworks", 401 | ); 402 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.TributeTests; 403 | PRODUCT_NAME = "$(TARGET_NAME)"; 404 | SWIFT_VERSION = 5.0; 405 | }; 406 | name = Debug; 407 | }; 408 | 016C8F3D2576431D00692CEE /* Release */ = { 409 | isa = XCBuildConfiguration; 410 | buildSettings = { 411 | CODE_SIGN_STYLE = Automatic; 412 | COMBINE_HIDPI_IMAGES = YES; 413 | INFOPLIST_FILE = Tests/Info.plist; 414 | LD_RUNPATH_SEARCH_PATHS = ( 415 | "$(inherited)", 416 | "@executable_path/../Frameworks", 417 | "@loader_path/../Frameworks", 418 | ); 419 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.TributeTests; 420 | PRODUCT_NAME = "$(TARGET_NAME)"; 421 | SWIFT_VERSION = 5.0; 422 | }; 423 | name = Release; 424 | }; 425 | /* End XCBuildConfiguration section */ 426 | 427 | /* Begin XCConfigurationList section */ 428 | 016C8F06257583CE00692CEE /* Build configuration list for PBXProject "Tribute" */ = { 429 | isa = XCConfigurationList; 430 | buildConfigurations = ( 431 | 016C8F10257583CE00692CEE /* Debug */, 432 | 016C8F11257583CE00692CEE /* Release */, 433 | ); 434 | defaultConfigurationIsVisible = 0; 435 | defaultConfigurationName = Release; 436 | }; 437 | 016C8F12257583CE00692CEE /* Build configuration list for PBXNativeTarget "Tribute" */ = { 438 | isa = XCConfigurationList; 439 | buildConfigurations = ( 440 | 016C8F13257583CE00692CEE /* Debug */, 441 | 016C8F14257583CE00692CEE /* Release */, 442 | ); 443 | defaultConfigurationIsVisible = 0; 444 | defaultConfigurationName = Release; 445 | }; 446 | 016C8F3B2576431D00692CEE /* Build configuration list for PBXNativeTarget "TributeTests" */ = { 447 | isa = XCConfigurationList; 448 | buildConfigurations = ( 449 | 016C8F3C2576431D00692CEE /* Debug */, 450 | 016C8F3D2576431D00692CEE /* Release */, 451 | ); 452 | defaultConfigurationIsVisible = 0; 453 | defaultConfigurationName = Release; 454 | }; 455 | /* End XCConfigurationList section */ 456 | }; 457 | rootObject = 016C8F03257583CE00692CEE /* Project object */; 458 | } 459 | -------------------------------------------------------------------------------- /Sources/Tribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tribute.swift 3 | // Tribute 4 | // 5 | // Created by Nick Lockwood on 30/11/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TributeError: Error, CustomStringConvertible { 11 | let description: String 12 | 13 | init(_ message: String) { 14 | self.description = message 15 | } 16 | } 17 | 18 | enum Argument: String, CaseIterable { 19 | case anonymous = "" 20 | case allow 21 | case skip 22 | case exclude 23 | case template 24 | case format 25 | case spmcache 26 | } 27 | 28 | enum Command: String, CaseIterable { 29 | case export 30 | case list 31 | case check 32 | case help 33 | case version 34 | 35 | var help: String { 36 | switch self { 37 | case .help: return "Display general or command-specific help" 38 | case .list: return "Display list of libraries and licenses found in project" 39 | case .export: return "Export license information for project" 40 | case .check: return "Check that exported license info is correct" 41 | case .version: return "Display the current version of Tribute" 42 | } 43 | } 44 | } 45 | 46 | enum LicenseType: String, CaseIterable { 47 | case bsd = "BSD" 48 | case mit = "MIT" 49 | case isc = "ISC" 50 | case zlib = "Zlib" 51 | case apache = "Apache" 52 | 53 | private var matchStrings: [String] { 54 | switch self { 55 | case .bsd: 56 | return [ 57 | "BSD License", 58 | "Redistribution and use in source and binary forms, with or without modification", 59 | "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR", 60 | ] 61 | case .mit: 62 | return [ 63 | "MIT License", 64 | "Permission is hereby granted, free of charge, to any person", 65 | "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", 66 | ] 67 | case .isc: 68 | return [ 69 | "Permission to use, copy, modify, and/or distribute this software for any", 70 | ] 71 | case .zlib: 72 | return [ 73 | "Altered source versions must be plainly marked as such, and must not be", 74 | ] 75 | case .apache: 76 | return [ 77 | "Apache License", 78 | ] 79 | } 80 | } 81 | 82 | init?(licenseText: String) { 83 | let preprocessedText = Self.preprocess(licenseText) 84 | guard let type = Self.allCases.first(where: { 85 | $0.matches(preprocessedText: preprocessedText) 86 | }) else { 87 | return nil 88 | } 89 | self = type 90 | } 91 | 92 | func matches(_ licenseText: String) -> Bool { 93 | matches(preprocessedText: Self.preprocess(licenseText)) 94 | } 95 | 96 | private func matches(preprocessedText: String) -> Bool { 97 | matchStrings.contains { 98 | preprocessedText.range(of: $0, options: .caseInsensitive) != nil 99 | } 100 | } 101 | 102 | private static func preprocess(_ licenseText: String) -> String { 103 | licenseText.lowercased() 104 | .replacingOccurrences(of: "\n", with: " ") 105 | .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) 106 | } 107 | } 108 | 109 | struct Library { 110 | var name: String 111 | var version: String? 112 | var licensePath: String 113 | var licenseType: LicenseType? 114 | var licenseText: String 115 | 116 | func with(_ fn: (inout Library) -> Void) -> Library { 117 | var copy = self 118 | fn(©) 119 | return copy 120 | } 121 | } 122 | 123 | private extension String { 124 | func addingTrailingSpace(toWidth width: Int) -> String { 125 | self + String(repeating: " ", count: width - count) 126 | } 127 | } 128 | 129 | class Tribute { 130 | // Find best match for a given string in a list of options 131 | func bestMatches(for query: String, in options: [String]) -> [String] { 132 | let lowercaseQuery = query.lowercased() 133 | // Sort matches by Levenshtein edit distance 134 | return options 135 | .compactMap { option -> (String, Int)? in 136 | let lowercaseOption = option.lowercased() 137 | let distance = editDistance(lowercaseOption, lowercaseQuery) 138 | guard distance <= lowercaseQuery.count / 2 || 139 | !lowercaseOption.commonPrefix(with: lowercaseQuery).isEmpty 140 | else { 141 | return nil 142 | } 143 | return (option, distance) 144 | } 145 | .sorted { $0.1 < $1.1 } 146 | .map { $0.0 } 147 | } 148 | 149 | /// The Levenshtein edit-distance between two strings 150 | func editDistance(_ lhs: String, _ rhs: String) -> Int { 151 | var dist = [[Int]]() 152 | for i in 0 ... lhs.count { 153 | dist.append([i]) 154 | } 155 | for j in 1 ... rhs.count { 156 | dist[0].append(j) 157 | } 158 | for i in 1 ... lhs.count { 159 | let lhs = lhs[lhs.index(lhs.startIndex, offsetBy: i - 1)] 160 | for j in 1 ... rhs.count { 161 | if lhs == rhs[rhs.index(rhs.startIndex, offsetBy: j - 1)] { 162 | dist[i].append(dist[i - 1][j - 1]) 163 | } else { 164 | dist[i].append(min(dist[i - 1][j] + 1, dist[i][j - 1] + 1, dist[i - 1][j - 1] + 1)) 165 | } 166 | } 167 | } 168 | return dist[lhs.count][rhs.count] 169 | } 170 | 171 | // Parse a flat array of command-line arguments into a dictionary of flags and values 172 | func preprocessArguments(_ args: [String]) throws -> [Argument: [String]] { 173 | let arguments = Argument.allCases 174 | let argumentNames = arguments.map { $0.rawValue } 175 | var namedArgs: [Argument: [String]] = [:] 176 | var name: Argument? 177 | for arg in args { 178 | if arg.hasPrefix("--") { 179 | // Long argument names 180 | let key = String(arg.unicodeScalars.dropFirst(2)) 181 | guard let argument = Argument(rawValue: key) else { 182 | guard let match = bestMatches(for: key, in: argumentNames).first else { 183 | throw TributeError("Unknown option --\(key).") 184 | } 185 | throw TributeError("Unknown option --\(key). Did you mean --\(match)?") 186 | } 187 | name = argument 188 | namedArgs[argument] = namedArgs[argument] ?? [] 189 | continue 190 | } else if arg.hasPrefix("-") { 191 | // Short argument names 192 | let flag = String(arg.unicodeScalars.dropFirst()) 193 | guard let match = arguments.first(where: { $0.rawValue.hasPrefix(flag) }) else { 194 | throw TributeError("Unknown flag -\(flag).") 195 | } 196 | name = match 197 | namedArgs[match] = namedArgs[match] ?? [] 198 | continue 199 | } 200 | var arg = arg 201 | let hasTrailingComma = arg.hasSuffix(",") && arg != "," 202 | if hasTrailingComma { 203 | arg = String(arg.dropLast()) 204 | } 205 | let existing = namedArgs[name ?? .anonymous] ?? [] 206 | namedArgs[name ?? .anonymous] = existing + [arg] 207 | } 208 | return namedArgs 209 | } 210 | 211 | func fetchLibraries( 212 | in directory: URL, 213 | excluding: [Glob], 214 | spmCache: URL?, 215 | includingPackages: Bool = true 216 | ) throws -> [Library] { 217 | let standardizedDirectory = directory.standardized 218 | let directoryPath = standardizedDirectory.path 219 | 220 | let manager = FileManager.default 221 | guard let enumerator = manager.enumerator( 222 | at: standardizedDirectory, 223 | includingPropertiesForKeys: nil, 224 | options: .skipsHiddenFiles 225 | ) else { 226 | throw TributeError("Unable to process directory at \(directoryPath).") 227 | } 228 | 229 | // Fetch libraries 230 | var libraries = [Library]() 231 | for case let licenceFile as URL in enumerator { 232 | if excluding.contains(where: { $0.matches(licenceFile.path) }) { 233 | continue 234 | } 235 | let licensePath = licenceFile.path.dropFirst(directoryPath.count) 236 | if includingPackages { 237 | if licenceFile.lastPathComponent == "Package.resolved" { 238 | libraries += try fetchLibraries(forResolvedPackageAt: licenceFile, spmCache: spmCache) 239 | continue 240 | } 241 | if licenceFile.lastPathComponent == "Package.swift", 242 | !manager.fileExists( 243 | atPath: licenceFile.deletingPathExtension() 244 | .appendingPathExtension("resolved").path 245 | ) 246 | { 247 | guard let string = try? String(contentsOf: licenceFile) else { 248 | throw TributeError("Unable to read Package.swift at \(licensePath).") 249 | } 250 | if string.range(of: ".package(") != nil { 251 | throw TributeError( 252 | "Found unresolved Package.swift at \(licensePath). Run 'swift package resolve' to resolve dependencies." 253 | ) 254 | } 255 | } 256 | } 257 | let name = licenceFile.deletingLastPathComponent().lastPathComponent 258 | if libraries.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { 259 | continue 260 | } 261 | let ext = licenceFile.pathExtension 262 | let fileName = licenceFile.deletingPathExtension().lastPathComponent.lowercased() 263 | guard ["license", "licence"].contains(fileName), 264 | ["", "text", "txt", "md"].contains(ext) 265 | else { 266 | continue 267 | } 268 | var isDirectory: ObjCBool = false 269 | _ = manager.fileExists(atPath: licenceFile.path, isDirectory: &isDirectory) 270 | if isDirectory.boolValue { 271 | continue 272 | } 273 | do { 274 | let licenseText = try String(contentsOf: licenceFile) 275 | .trimmingCharacters(in: .whitespacesAndNewlines) 276 | let library = Library( 277 | name: name, 278 | version: nil, 279 | licensePath: String(licensePath), 280 | licenseType: LicenseType(licenseText: licenseText), 281 | licenseText: licenseText 282 | ) 283 | libraries.append(library) 284 | } catch { 285 | throw TributeError("Unable to read license file at \(licensePath).") 286 | } 287 | } 288 | 289 | return libraries.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } 290 | } 291 | 292 | func fetchLibraries(forResolvedPackageAt url: URL, spmCache: URL?) throws -> [Library] { 293 | struct Resolved: Decodable { 294 | var version: Double 295 | } 296 | 297 | struct State: Decodable { 298 | let revision: String? 299 | let version: String? 300 | } 301 | 302 | struct ResolvedV1: Decodable { 303 | struct Pin: Decodable { 304 | let package: String 305 | let repositoryURL: URL 306 | let state: State? 307 | } 308 | 309 | struct Object: Decodable { 310 | let pins: [Pin] 311 | } 312 | 313 | let object: Object 314 | } 315 | 316 | struct ResolvedV2: Decodable { 317 | struct Pin: Decodable { 318 | let identity: String 319 | let location: URL 320 | let state: State? 321 | } 322 | 323 | let pins: [Pin] 324 | 325 | init(_ v1: ResolvedV1) { 326 | self.pins = v1.object.pins.map { 327 | Pin( 328 | identity: $0.package, 329 | location: $0.repositoryURL, 330 | state: $0.state 331 | ) 332 | } 333 | } 334 | 335 | func pin(for name: String) -> Pin? { 336 | pins.first(where: { 337 | name.caseInsensitiveCompare($0.identity) == .orderedSame || 338 | name.caseInsensitiveCompare( 339 | $0.location 340 | .deletingPathExtension() 341 | .lastPathComponent 342 | ) == .orderedSame 343 | }) 344 | } 345 | } 346 | 347 | let resolved: ResolvedV2 348 | do { 349 | let data = try Data(contentsOf: url) 350 | let decoder = JSONDecoder() 351 | let version = try decoder.decode(Resolved.self, from: data).version 352 | switch version { 353 | case 1: 354 | resolved = try ResolvedV2(decoder.decode(ResolvedV1.self, from: data)) 355 | case 2, 3: 356 | resolved = try decoder.decode(ResolvedV2.self, from: data) 357 | default: 358 | throw TributeError("Unsupported Swift Package.resolved version: \(version).") 359 | } 360 | } catch { 361 | throw TributeError("Unable to read Swift Package file at \(url.path).") 362 | } 363 | let directory: URL 364 | if let spmCache = spmCache { 365 | directory = spmCache 366 | } else if let derivedDataDirectory = FileManager.default 367 | .urls(for: .libraryDirectory, in: .userDomainMask).first? 368 | .appendingPathComponent("Developer/Xcode/DerivedData") 369 | { 370 | directory = derivedDataDirectory 371 | } else { 372 | throw TributeError("Unable to locate ~/Library/Developer/Xcode/DerivedData directory.") 373 | } 374 | let libraries = try fetchLibraries( 375 | in: directory, 376 | excluding: [], 377 | spmCache: nil, 378 | includingPackages: false 379 | ) 380 | return libraries.compactMap { 381 | guard let pin = resolved.pin(for: $0.name) else { 382 | return nil 383 | } 384 | return $0.with { $0.version = pin.state?.version } 385 | } 386 | } 387 | 388 | func getHelp(with arg: String?) throws -> String { 389 | guard let arg = arg else { 390 | let width = Command.allCases.map { $0.rawValue.count }.max(by: <) ?? 0 391 | return """ 392 | Available commands: 393 | 394 | \(Command.allCases.map { 395 | " \($0.rawValue.addingTrailingSpace(toWidth: width)) \($0.help)" 396 | }.joined(separator: "\n")) 397 | 398 | (Type 'tribute help [command]' for more information) 399 | """ 400 | } 401 | guard let command = Command(rawValue: arg) else { 402 | let commands = Command.allCases.map { $0.rawValue } 403 | if let closest = bestMatches(for: arg, in: commands).first { 404 | throw TributeError("Unrecognized command '\(arg)'. Did you mean '\(closest)'?") 405 | } 406 | throw TributeError("Unrecognized command '\(arg)'.") 407 | } 408 | let detailedHelp: String 409 | switch command { 410 | case .help: 411 | detailedHelp = """ 412 | [command] The command to display help for. 413 | """ 414 | case .export: 415 | detailedHelp = """ 416 | [filepath] Path to the file that the licenses should be exported to. If omitted 417 | then the licenses will be written to stdout. 418 | 419 | --exclude One or more directories to be excluded from the library search. 420 | Paths should be relative to the current directory, and may include 421 | wildcard/glob syntax. 422 | 423 | --skip One or more libraries to be skipped. Use this for libraries that do 424 | not require attribution, or which are used in the build process but 425 | are not actually shipped to the end-user. 426 | 427 | --allow A list of libraries that should be included even if their licenses 428 | are not supported/recognized. 429 | 430 | --template A template string or path to a template file to use for generating 431 | the licenses file. The template should contain one or more of the 432 | following placeholder strings: 433 | 434 | $name The name of the library 435 | $version The installed version of the library or "Unknown" 436 | ($version) Version in parentheses - will be omitted if unknown 437 | $type The license type (e.g. MIT, Apache, BSD) or "Unknown" 438 | $text The text of the license itself 439 | $start The start of the license template (after the header) 440 | $end The end of the license template (before the footer) 441 | $separator A delimiter to be included between each license 442 | 443 | --format How the output should be formatted (JSON, XML, plist or text). If 444 | omitted this will be inferred from the template or output filepath. 445 | 446 | --spmcache Path to the Swift Package Manager cache (where SPM stores downloaded 447 | libraries). If omitted the standard derived data path will be used. 448 | """ 449 | case .check: 450 | detailedHelp = """ 451 | [filepath] The path to the licenses file that will be compared against the 452 | libraries found in the project (required). An error will be returned 453 | if any libraries are missing from the file, or if the format doesn't 454 | match the other parameters. 455 | 456 | --exclude One or more directories to be excluded from the library search. 457 | Paths should be relative to the current directory, and may include 458 | wildcard/glob syntax. 459 | 460 | --skip One or more libraries to be skipped. Use this for libraries that do 461 | not require attribution, or which are used in the build process but 462 | are not actually shipped to the end-user. 463 | """ 464 | case .list, .version: 465 | return command.help 466 | } 467 | 468 | return command.help + ".\n\n" + detailedHelp + "\n" 469 | } 470 | 471 | func listLibraries(in directory: String, with args: [String]) throws -> String { 472 | let arguments = try preprocessArguments(args) 473 | let globs = (arguments[.exclude] ?? []).flatMap { expandGlobs($0, in: directory) } 474 | let spmCache = arguments[.spmcache]?.first 475 | 476 | // Directories 477 | let path = "." 478 | let directoryURL = expandPath(path, in: directory) 479 | let cacheURL = spmCache.map { expandPath($0, in: directory) } 480 | let libraries = try fetchLibraries(in: directoryURL, excluding: globs, spmCache: cacheURL) 481 | 482 | // Output 483 | let nameWidth = libraries.map { 484 | $0.name.count + ($0.version.map { $0.count + 3 } ?? 0) 485 | }.max() ?? 0 486 | let licenceWidth = libraries.map { 487 | ($0.licenseType?.rawValue ?? "Unknown").count 488 | }.max() ?? 0 489 | return libraries.map { 490 | var name = $0.name + ($0.version.map { " (\($0))" } ?? "") 491 | name += String(repeating: " ", count: nameWidth - name.count) 492 | var type = ($0.licenseType?.rawValue ?? "Unknown") 493 | type += String(repeating: " ", count: licenceWidth - type.count) 494 | return "\(name) \(type) \($0.licensePath)" 495 | }.joined(separator: "\n") 496 | } 497 | 498 | func check(in directory: String, with args: [String]) throws -> String { 499 | let arguments = try preprocessArguments(args) 500 | let skip = (arguments[.skip] ?? []).map { $0.lowercased() } 501 | let globs = (arguments[.exclude] ?? []).flatMap { expandGlobs($0, in: directory) } 502 | let spmCache = arguments[.spmcache]?.first 503 | 504 | // Directory 505 | let path = "." 506 | let directoryURL = expandPath(path, in: directory) 507 | let cacheURL = spmCache.map { expandPath($0, in: directory) } 508 | var libraries = try fetchLibraries(in: directoryURL, excluding: globs, spmCache: cacheURL) 509 | let libraryNames = libraries.map { $0.name.lowercased() } 510 | 511 | if let name = skip.first(where: { !libraryNames.contains($0) }) { 512 | if let closest = bestMatches(for: name.lowercased(), in: libraryNames).first { 513 | throw TributeError("Unknown library '\(name)'. Did you mean '\(closest)'?") 514 | } 515 | throw TributeError("Unknown library '\(name)'.") 516 | } 517 | 518 | // Filtering 519 | libraries = libraries.filter { !skip.contains($0.name.lowercased()) } 520 | 521 | // File path 522 | let anon = arguments[.anonymous] ?? [] 523 | guard let inputURL = (anon.count > 2 ? anon[2] : nil).map({ 524 | expandPath($0, in: directory) 525 | }) else { 526 | throw TributeError("Missing path to licenses file.") 527 | } 528 | 529 | // Check 530 | guard var licensesText = try? String(contentsOf: inputURL) else { 531 | throw TributeError("Unable to read licenses file at \(inputURL.path).") 532 | } 533 | licensesText = licensesText 534 | .replacingOccurrences(of: "\n", with: " ") 535 | .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) 536 | if let library = libraries.first(where: { !licensesText.contains($0.name) }) { 537 | throw TributeError("License for '\(library.name)' is missing from licenses file.") 538 | } 539 | return "Licenses file is up-to-date." 540 | } 541 | 542 | func export(in directory: String, with args: [String]) throws -> String { 543 | let arguments = try preprocessArguments(args) 544 | let allow = (arguments[.allow] ?? []).map { $0.lowercased() } 545 | let skip = (arguments[.skip] ?? []).map { $0.lowercased() } 546 | let globs = (arguments[.exclude] ?? []).flatMap { expandGlobs($0, in: directory) } 547 | let rawFormat = arguments[.format]?.first 548 | let cache = arguments[.spmcache]?.first 549 | 550 | // File 551 | let anon = arguments[.anonymous] ?? [] 552 | let outputURL = (anon.count > 2 ? anon[2] : nil).map { expandPath($0, in: directory) } 553 | 554 | // Template 555 | let template: Template 556 | if let pathOrTemplate = arguments[.template]?.first { 557 | if pathOrTemplate.contains("$name") { 558 | template = Template(rawValue: pathOrTemplate) 559 | } else { 560 | let templateFile = expandPath(pathOrTemplate, in: directory) 561 | let templateText = try String(contentsOf: templateFile) 562 | template = Template(rawValue: templateText) 563 | } 564 | } else { 565 | template = .default( 566 | for: rawFormat.flatMap(Format.init) ?? 567 | outputURL.flatMap { .infer(from: $0) } ?? .text 568 | ) 569 | } 570 | 571 | // Format 572 | let format: Format 573 | if let rawFormat = rawFormat { 574 | guard let _format = Format(rawValue: rawFormat) else { 575 | let formats = Format.allCases.map { $0.rawValue } 576 | if let closest = bestMatches(for: rawFormat, in: formats).first { 577 | throw TributeError("Unsupported output format '\(rawFormat)'. Did you mean '\(closest)'?") 578 | } 579 | throw TributeError("Unsupported output format '\(rawFormat)'.") 580 | } 581 | format = _format 582 | } else { 583 | format = .infer(from: template) 584 | } 585 | 586 | // Directory 587 | let path = "." 588 | let directoryURL = expandPath(path, in: directory) 589 | let cacheDirectory: URL? 590 | if let cache = cache { 591 | cacheDirectory = expandPath(cache, in: directory) 592 | } else { 593 | cacheDirectory = nil 594 | } 595 | var libraries = try fetchLibraries(in: directoryURL, excluding: globs, spmCache: cacheDirectory) 596 | let libraryNames = libraries.map { $0.name.lowercased() } 597 | 598 | if let name = (allow + skip).first(where: { !libraryNames.contains($0) }) { 599 | if let closest = bestMatches(for: name.lowercased(), in: libraryNames).first { 600 | throw TributeError("Unknown library '\(name)'. Did you mean '\(closest)'?") 601 | } 602 | throw TributeError("Unknown library '\(name)'.") 603 | } 604 | 605 | // Filtering 606 | libraries = try libraries.filter { library in 607 | if skip.contains(library.name.lowercased()) { 608 | return false 609 | } 610 | let name = library.name 611 | guard allow.contains(name.lowercased()) || library.licenseType != nil else { 612 | let escapedName = (name.contains(" ") ? "\"\(name)\"" : name).lowercased() 613 | throw TributeError( 614 | "Unrecognized license at \(library.licensePath). " 615 | + "Use '--allow \(escapedName)' or '--skip \(escapedName)' to bypass." 616 | ) 617 | } 618 | return true 619 | } 620 | 621 | // Output 622 | let result = try template.render(libraries, as: format) 623 | if let outputURL = outputURL { 624 | do { 625 | try result.write(to: outputURL, atomically: true, encoding: .utf8) 626 | return "License data successfully written to \(outputURL.path)." 627 | } catch { 628 | throw TributeError("Unable to write output to \(outputURL.path). \(error).") 629 | } 630 | } else { 631 | return result 632 | } 633 | } 634 | 635 | func run(in directory: String, with args: [String] = CommandLine.arguments) throws -> String { 636 | let arg = args.count > 1 ? args[1] : Command.help.rawValue 637 | guard let command = Command(rawValue: arg) else { 638 | let commands = Command.allCases.map { $0.rawValue } 639 | if let closest = bestMatches(for: arg, in: commands).first { 640 | throw TributeError("Unrecognized command '\(arg)'. Did you mean '\(closest)'?") 641 | } 642 | throw TributeError("Unrecognized command '\(arg)'.") 643 | } 644 | switch command { 645 | case .help: 646 | return try getHelp(with: args.count > 2 ? args[2] : nil) 647 | case .list: 648 | return try listLibraries(in: directory, with: args) 649 | case .export: 650 | return try export(in: directory, with: args) 651 | case .check: 652 | return try check(in: directory, with: args) 653 | case .version: 654 | return "0.4.0" 655 | } 656 | } 657 | } 658 | --------------------------------------------------------------------------------