├── .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 | [](https://github.com/nicklockwood/Tribute/actions/workflows/build.yml)
2 | [](https://developer.apple.com/swift)
3 | [](https://opensource.org/licenses/MIT)
4 | [](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 |
--------------------------------------------------------------------------------