spm_version_updates 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 922e9383223719cafb033ce65ca8b710013bc1654217973443b1b1b99eead693
4
+ data.tar.gz: b6a5fa439183d0cfcb8192b050565020b8f314e18f573d88b8e4ce40fb619bfb
5
+ SHA512:
6
+ metadata.gz: cc90410091871b58726de87d0f8e992e3abf08c75799dc1901c1afd893ca5736f39c02076b136523a3ba437a5796e51cf196abcd728e513b4e1e27dd9332945f
7
+ data.tar.gz: a7329c84f09b9615cf25872e4e3181694b306e717dc978b36b8edbd8334543339f68d4bd2402ae84c15e425cad2005e6ace00475c41598c36ca81309f1f3f95a
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2023-2024 Harold Martin <harold.martin@gmail.com>
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # spm_version_updates
2
+
3
+ Core library for detecting available updates to Swift Package Manager
4
+ dependencies. It powers both the
5
+ [`danger-spm_version_updates`](https://rubygems.org/gems/danger-spm_version_updates)
6
+ Danger plugin and the
7
+ [Swift Package Version Updates GitHub Action](https://github.com/hbmartin/github-action-spm_version_updates),
8
+ and can be used directly from any Ruby program.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ gem install spm_version_updates
14
+ ```
15
+
16
+ Xcode-project mode additionally requires the `xcodeproj` gem; manifest mode
17
+ has no extra dependencies.
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require "spm_version_updates"
23
+
24
+ checker = SpmChecker.new
25
+
26
+ # Manifest mode: check one or more Package.swift files (a Package.resolved
27
+ # next to each manifest is used automatically when present).
28
+ warnings = checker.check_manifests(["path/to/Package.swift"])
29
+
30
+ # Xcode mode: check the packages referenced by an Xcode project.
31
+ # warnings = checker.check_for_updates("path/to/App.xcodeproj")
32
+
33
+ warnings.each { |warning| puts warning }
34
+
35
+ # Structured details (repository URL, current/available version, severity,
36
+ # suggested update command, ...) for each warning:
37
+ checker.warning_details.each { |detail| p detail }
38
+ ```
39
+
40
+ Behavior is configurable through accessors on `SpmChecker` — for example
41
+ `check_when_exact`, `check_branches`, `report_above_maximum`,
42
+ `report_pre_releases`, `ignore_repos`, and allow-host restrictions. See the
43
+ class documentation for the full list.
44
+
45
+ ## License
46
+
47
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_host_normalizer"
4
+ require_relative "git_operations"
5
+
6
+ # Normalizes user-provided allow-host entries into hostnames.
7
+ class AllowHostNormalizer
8
+ MALFORMED_SCHEME_PATTERN = %r{\A[a-z][a-z0-9+\-.]*//}i
9
+
10
+ def self.normalize(entry)
11
+ new(entry).normalize
12
+ end
13
+
14
+ def self.configured_entries(entries)
15
+ Array(entries).filter_map { |entry|
16
+ value = entry.to_s.strip
17
+ value unless value.empty?
18
+ }
19
+ end
20
+
21
+ def initialize(entry)
22
+ @raw = entry.to_s.strip
23
+ end
24
+
25
+ def normalize
26
+ return nil if raw.empty?
27
+ return parsed if parsed && !malformed_scheme?
28
+ return fallback if fallback.match?(GitHostNormalizer::HOST_PATTERN)
29
+
30
+ warn_unparseable
31
+ nil
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :raw
37
+
38
+ def parsed
39
+ @parsed ||= GitOperations.host(raw)
40
+ end
41
+
42
+ def fallback
43
+ @fallback ||= raw.sub(/:\d+\z/, "").downcase
44
+ end
45
+
46
+ def malformed_scheme?
47
+ raw.match?(MALFORMED_SCHEME_PATTERN)
48
+ end
49
+
50
+ def warn_unparseable
51
+ warn("allow-hosts entry #{raw.inspect} could not be parsed as a host and will not match any repository URL")
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Redacts credentials embedded in URL userinfo before logging or emitting data.
4
+ module CredentialRedactor
5
+ class << self
6
+ def redact(value)
7
+ value&.to_s&.gsub(%r{([a-z][a-z0-9+\-.]*://)([^/\s@]+)@}i, '\1[REDACTED]@')
8
+ end
9
+
10
+ def redact_hash_value(hash, key)
11
+ hash.dup.tap { |copy|
12
+ copy[key] = redact(copy[key]) if copy.key?(key)
13
+ }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "update_severity"
4
+
5
+ # Parses fail-on inputs and evaluates whether reported updates should fail.
6
+ module FailOnThreshold
7
+ ANY = "any"
8
+
9
+ def self.from_inputs(explicit_fail_on, legacy_fail_on)
10
+ input_name, value = [
11
+ ["fail-on", explicit_fail_on],
12
+ ["fail-on-updates", legacy_fail_on],
13
+ ["fail-on-updates", "false"],
14
+ ].find { |_name, candidate| candidate }
15
+ normalize(value, input_name)
16
+ end
17
+
18
+ def self.failure_message(threshold, reporter)
19
+ return nil unless threshold
20
+
21
+ count = failure_count(threshold, reporter)
22
+ count.positive? ? build_message(threshold, count) : nil
23
+ end
24
+
25
+ def self.failure_count(threshold, reporter)
26
+ return reporter.records.size if threshold == ANY
27
+
28
+ UpdateSeverity.count_at_or_above(reporter.severity_counts, threshold)
29
+ end
30
+
31
+ def self.normalize(value, input_name)
32
+ normalized = value.downcase
33
+ return nil if ["false", "none"].include?(normalized)
34
+ return ANY if normalized == "true"
35
+ return normalized if UpdateSeverity.threshold?(normalized)
36
+
37
+ raise(ArgumentError, "#{input_name} must be false, true, major, minor, or patch")
38
+ end
39
+
40
+ def self.build_message(threshold, count)
41
+ plural = count == 1 ? "" : "s"
42
+ threshold_note = threshold == ANY ? "" : " #{threshold}+"
43
+ "Found #{count}#{threshold_note} SPM dependency update#{plural}"
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "uri"
5
+
6
+ # Extracts and normalizes hostnames from common git remote URL forms.
7
+ module GitHostNormalizer
8
+ HOST_PATTERN = /\A[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\z/i
9
+ BRACKETED_IPV6_PATTERN = /\A\[(?<address>[^\]]+)\](?::\d+)?\z/
10
+
11
+ class << self
12
+ def host(repo_url)
13
+ url = repo_url.to_s.strip
14
+ return nil if url.empty?
15
+
16
+ parsed_host(url) || scp_like_host(url) || bare_host(url)
17
+ end
18
+
19
+ def parsed_host(url)
20
+ normalize_host(URI.parse(url).host)
21
+ rescue URI::InvalidURIError
22
+ nil
23
+ end
24
+
25
+ def scp_like_host(url)
26
+ match = url.match(%r{\A(?:[^@\s/]+@)?(?<host>[^:\s/]+):(?!/)[^:\s]+\z})
27
+ match && normalize_host(match[:host])
28
+ end
29
+
30
+ def bare_host(url)
31
+ return nil if url.start_with?("/", "./", "../")
32
+ return nil if url.include?("://")
33
+
34
+ normalize_host(url.split("/", 2).first)
35
+ end
36
+
37
+ def normalize_host(host)
38
+ normalized = normalized_ipv6_host(host)
39
+ return normalized if normalized
40
+
41
+ normalized = host.to_s.sub(/:\d+\z/, "").downcase
42
+ return normalized if normalized.match?(HOST_PATTERN)
43
+
44
+ nil
45
+ end
46
+
47
+ def normalized_ipv6_host(host)
48
+ raw = host.to_s.strip.downcase
49
+ bracketed = raw.match(BRACKETED_IPV6_PATTERN)
50
+ return normalize_ipv6_address(bracketed[:address]) if bracketed
51
+ return normalize_ipv6_address(raw) if raw.count(":") >= 2
52
+
53
+ nil
54
+ end
55
+
56
+ def normalize_ipv6_address(address)
57
+ parsed = IPAddr.new(address)
58
+ parsed.ipv6? ? parsed.to_s : nil
59
+ rescue IPAddr::Error
60
+ nil
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "credential_redactor"
5
+ require_relative "git_host_normalizer"
6
+ require_relative "semver"
7
+
8
+ # Git operations for SPM version checking (migrated from git.rb)
9
+ module GitOperations
10
+ ALLOWED_PROTOCOLS = "https:ssh:git"
11
+ LS_REMOTE_RETRY_DELAYS = [0.25, 0.5].freeze
12
+ NON_INTERACTIVE_ENV = {
13
+ "GIT_ALLOW_PROTOCOL" => ALLOWED_PROTOCOLS,
14
+ "GIT_TERMINAL_PROMPT" => "0"
15
+ }.freeze
16
+ TAG_REF_PATTERNS = ["[0-9]*.[0-9]*", "v[0-9]*.[0-9]*"].freeze
17
+
18
+ # Raised when git cannot complete a remote reference lookup.
19
+ class LsRemoteError < StandardError; end
20
+
21
+ # Removes protocol and trailing .git from a repo URL
22
+ # @param [String] repo_url The URL of the repository
23
+ # @return [String]
24
+ def self.trim_repo_url(repo_url)
25
+ url = repo_url.to_s.strip
26
+ return "" if url.empty?
27
+
28
+ url.split("://").last.gsub(/\.git$/, "")
29
+ end
30
+
31
+ # Extract a readable name for the repo given the url, generally org/repo
32
+ # @return [String]
33
+ def self.repo_name(repo_url)
34
+ match = repo_url.match(%r{([\w-]+/[\w-]+)(.git)?$})
35
+ if match
36
+ match[1] || match[0]
37
+ else
38
+ repo_url
39
+ end
40
+ end
41
+
42
+ # Extracts the hostname from common git remote URL forms.
43
+ # @return [String, nil]
44
+ def self.host(repo_url)
45
+ GitHostNormalizer.host(repo_url)
46
+ end
47
+
48
+ # Call git to list tags
49
+ # @param [String] repo_url The URL of the dependency's repository
50
+ # @return [Array<SpmVersionUpdates::Semver>]
51
+ def self.version_tags(repo_url)
52
+ output = ls_remote(repo_url, options: ["--tags", "--refs"], patterns: TAG_REF_PATTERNS)
53
+
54
+ versions = output
55
+ .split("\n")
56
+ .filter_map { |line| tag_name(line) }
57
+ .filter_map { |line|
58
+ begin
59
+ SpmVersionUpdates::Semver.new(line)
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+ }
64
+ versions.sort!.reverse!
65
+ versions
66
+ end
67
+
68
+ # Call git to find the last commit on a branch
69
+ # @param [String] repo_url The URL of the dependency's repository
70
+ # @param [String] branch_name The name of the branch on which to find the last commit
71
+ # @return [String, nil]
72
+ def self.branch_last_commit(repo_url, branch_name)
73
+ branch_ref = "refs/heads/#{branch_name}"
74
+ output = ls_remote(repo_url, options: ["--branches"], patterns: [branch_ref])
75
+
76
+ line = output
77
+ .split("\n")
78
+ .find { |remote_ref| remote_ref.split("\t")[1] == branch_ref }
79
+ line&.split("\t")&.first
80
+ end
81
+
82
+ # Run `git ls-remote` with an argument vector (no shell), so repository URLs
83
+ # are never word-split or interpreted by a shell. Raises after bounded retries
84
+ # instead of masking network/auth failures as "no updates available".
85
+ # @return [String]
86
+ def self.ls_remote(repo_url, options:, patterns: [])
87
+ attempts = 0
88
+ stdout = nil
89
+ stderr = nil
90
+
91
+ loop {
92
+ attempts += 1
93
+ stdout, stderr, status = capture_ls_remote(repo_url, options, patterns)
94
+ return stdout if status.success?
95
+
96
+ break if attempts >= ls_remote_attempts
97
+
98
+ sleep(LS_REMOTE_RETRY_DELAYS.fetch(attempts - 1))
99
+ }
100
+
101
+ raise_ls_remote_error(failure_message(repo_url, stderr, attempts))
102
+ rescue Errno::ENOENT
103
+ raise_ls_remote_error("git command not found. Please ensure git is installed and available in your PATH.")
104
+ rescue SystemCallError => error
105
+ raise_ls_remote_error("git ls-remote failed to start: #{error.message}")
106
+ end
107
+
108
+ def self.ls_remote_attempts
109
+ LS_REMOTE_RETRY_DELAYS.size + 1
110
+ end
111
+
112
+ def self.capture_ls_remote(repo_url, options, patterns)
113
+ Open3.capture3(
114
+ NON_INTERACTIVE_ENV,
115
+ "git",
116
+ "ls-remote",
117
+ *options,
118
+ "--",
119
+ repo_url,
120
+ *patterns
121
+ )
122
+ end
123
+
124
+ def self.failure_message(repo_url, stderr, attempts)
125
+ details = stderr.to_s.strip
126
+ details = "no stderr" if details.empty?
127
+ "git ls-remote failed for #{redact_credentials(repo_url)} after #{attempts} attempts: #{redact_credentials(details)}"
128
+ end
129
+
130
+ def self.raise_ls_remote_error(message)
131
+ warn(message)
132
+ raise(LsRemoteError, message)
133
+ end
134
+
135
+ def self.tag_name(line)
136
+ line[%r{\A[^\t]+\trefs/tags/(.+)\z}, 1]
137
+ end
138
+
139
+ def self.redact_credentials(value)
140
+ CredentialRedactor.redact(value)
141
+ end
142
+
143
+ private_class_method :ls_remote,
144
+ :ls_remote_attempts,
145
+ :capture_ls_remote,
146
+ :failure_message,
147
+ :raise_ls_remote_error,
148
+ :tag_name,
149
+ :redact_credentials
150
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_operations"
4
+ require_relative "package_resolved"
5
+
6
+ # Parses Swift Package Manager manifests (`Package.swift`) and their adjacent
7
+ # `Package.resolved` files.
8
+ #
9
+ # This supports the "SwiftPM-native" repo layout, where dependencies are
10
+ # declared directly in one or more `Package.swift` manifests rather than as
11
+ # `XCRemoteSwiftPackageReference` objects inside an `.xcodeproj`.
12
+ #
13
+ # Manifests are parsed with a lightweight, dependency-free scanner so the action
14
+ # runs on any runner (e.g. `ubuntu-latest`) without requiring Swift or a
15
+ # macOS/Xcode toolchain to be installed.
16
+ #
17
+ # The requirement hashes returned by {get_packages} intentionally mirror the
18
+ # shape produced by `Xcodeproj` for `XCRemoteSwiftPackageReference#requirement`
19
+ # (`"kind"`, `"minimumVersion"`, `"maximumVersion"`, `"version"`, `"branch"`,
20
+ # `"revision"`) so the same comparison logic can be reused for both modes.
21
+ module ManifestParser
22
+ PACKAGE_CALL = ".package("
23
+
24
+ # Find the direct SPM dependencies declared in a `Package.swift` manifest.
25
+ #
26
+ # Local packages (declared with `path:`) and packages without a recognizable
27
+ # version requirement are skipped.
28
+ #
29
+ # Keyed by the normalized repository URL (used to match against
30
+ # `Package.resolved` pins and `ignore-repos`), while the original,
31
+ # scheme-bearing `repository_url` is retained for git operations.
32
+ #
33
+ # @param [String] manifest_path The path to a `Package.swift` file
34
+ # @raise [ManifestPathMustBeSet] if the manifest_path is blank
35
+ # @raise [CouldNotFindManifest] if the file does not exist
36
+ # @return [Hash<String, Hash>] normalized URL => { "repository_url", "requirement" }
37
+ def self.get_packages(manifest_path)
38
+ raise(ManifestPathMustBeSet) if manifest_path.nil? || manifest_path.empty?
39
+ raise(CouldNotFindManifest, manifest_path) unless File.exist?(manifest_path)
40
+
41
+ content = strip_comments(File.read(manifest_path))
42
+ package_calls(content).each_with_object({}) { |call, packages|
43
+ url = call[/\burl\s*:\s*"([^"]+)"/, 1]
44
+ next if url.nil? # local package (path:) or otherwise unrecognized
45
+
46
+ requirement = requirement_for(call)
47
+ next if requirement.nil?
48
+
49
+ packages[GitOperations.trim_repo_url(url)] = { "repository_url" => url, "requirement" => requirement }
50
+ }
51
+ end
52
+
53
+ # Extract the resolved versions from a `Package.resolved` file.
54
+ #
55
+ # @param [String] resolved_path The path to a `Package.resolved` file
56
+ # @return [Hash<String, String>] normalized repository URL => version or revision
57
+ def self.get_resolved_versions(resolved_path)
58
+ PackageResolved.versions_from(resolved_path)
59
+ end
60
+
61
+ # Infer the `Package.resolved` path that sits next to a manifest.
62
+ #
63
+ # @param [String] manifest_path The path to a `Package.swift` file
64
+ # @return [String]
65
+ def self.default_resolved_path(manifest_path)
66
+ File.join(File.dirname(manifest_path), "Package.resolved")
67
+ end
68
+
69
+ # Extract the argument body of each `.package( ... )` call, honoring nested
70
+ # parentheses (e.g. `.upToNextMajor(from: "1.0.0")`) and string literals.
71
+ #
72
+ # @param [String] content The (comment-stripped) manifest source
73
+ # @return [Array<String>]
74
+ def self.package_calls(content)
75
+ calls = []
76
+ search_start = 0
77
+ while (marker_index = content.index(PACKAGE_CALL, search_start))
78
+ open_index = marker_index + PACKAGE_CALL.length - 1
79
+ close_index = matching_paren(content, open_index)
80
+ break if close_index.nil?
81
+
82
+ calls << content[(open_index + 1)...close_index]
83
+ search_start = close_index + 1
84
+ end
85
+ calls
86
+ end
87
+
88
+ # Map the body of a `.package(...)` call to an Xcodeproj-style requirement.
89
+ #
90
+ # Ordering matters: ranges and the explicit `.upToNextMajor`/`.upToNextMinor`
91
+ # forms are matched before the bare `from:` shorthand because they also
92
+ # contain the substring `from:`.
93
+ #
94
+ # @param [String] call The body of a `.package(...)` call
95
+ # @return [Hash, nil]
96
+ def self.requirement_for(call)
97
+ if (range = call.match(/"([^"]+)"\s*(\.\.[.<])\s*"([^"]+)"/))
98
+ version_range_requirement(range[1], range[2], range[3])
99
+ elsif (version = call[/\.upToNextMinor\s*\(\s*from\s*:\s*"([^"]+)"/, 1])
100
+ { "kind" => "upToNextMinorVersion", "minimumVersion" => version }
101
+ elsif (version = call[/\.upToNextMajor\s*\(\s*from\s*:\s*"([^"]+)"/, 1] || call[/\bfrom\s*:\s*"([^"]+)"/, 1])
102
+ { "kind" => "upToNextMajorVersion", "minimumVersion" => version }
103
+ elsif (version = call[/\bexact\s*:\s*"([^"]+)"/, 1] || call[/\.exact\s*\(\s*"([^"]+)"/, 1])
104
+ { "kind" => "exactVersion", "version" => version }
105
+ elsif (branch = call[/\bbranch\s*:\s*"([^"]+)"/, 1] || call[/\.branch\s*\(\s*"([^"]+)"/, 1])
106
+ { "kind" => "branch", "branch" => branch }
107
+ elsif (revision = call[/\brevision\s*:\s*"([^"]+)"/, 1] || call[/\.revision\s*\(\s*"([^"]+)"/, 1])
108
+ { "kind" => "revision", "revision" => revision }
109
+ end
110
+ end
111
+
112
+ # Build a versionRange requirement from a Swift range literal.
113
+ #
114
+ # Xcode's `versionRange` (like Swift's `..<`) uses an exclusive maximum. SwiftPM
115
+ # normalizes a closed range `a...b` to the half-open range `a ..< (b + 1 patch)`,
116
+ # so we do the same here for `...` to keep the inclusive upper bound — otherwise
117
+ # version `b` would be incorrectly excluded from update checks.
118
+ #
119
+ # @return [Hash]
120
+ def self.version_range_requirement(minimum, range_operator, maximum)
121
+ maximum = increment_patch_version(maximum) if range_operator == "..."
122
+ { "kind" => "versionRange", "minimumVersion" => minimum, "maximumVersion" => maximum }
123
+ end
124
+
125
+ # Increment the patch component of an `x.y.z` version, dropping any
126
+ # pre-release/build suffix (matching how SwiftPM derives the exclusive upper
127
+ # bound of a closed range as `Version(major, minor, patch + 1)`). Returns the
128
+ # input unchanged if it is not a three-part version.
129
+ #
130
+ # @return [String]
131
+ def self.increment_patch_version(version)
132
+ major, minor, patch = version.match(/\A(\d+)\.(\d+)\.(\d+)/)&.captures
133
+ return version if patch.nil?
134
+
135
+ "#{major}.#{minor}.#{patch.to_i + 1}"
136
+ end
137
+
138
+ # Find the index of the `)` that closes the `(` at +open_index+, ignoring
139
+ # parentheses and the like that appear inside string literals.
140
+ #
141
+ # @return [Integer, nil]
142
+ def self.matching_paren(content, open_index)
143
+ depth = 0
144
+ index = open_index
145
+ length = content.length
146
+ in_string = false
147
+ while index < length
148
+ char = content[index]
149
+ if in_string
150
+ if char == "\\"
151
+ index += 2
152
+ next
153
+ end
154
+ in_string = false if char == '"'
155
+ elsif char == '"'
156
+ in_string = true
157
+ elsif char == "("
158
+ depth += 1
159
+ elsif char == ")"
160
+ depth -= 1
161
+ return index if depth.zero?
162
+ end
163
+ index += 1
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Remove `//` line comments and `/* */` block comments while leaving string
169
+ # literals (e.g. URLs containing `//`) untouched.
170
+ #
171
+ # @param [String] content The raw manifest source
172
+ # @return [String]
173
+ def self.strip_comments(content)
174
+ output = +""
175
+ index = 0
176
+ length = content.length
177
+ while index < length
178
+ char = content[index]
179
+ nxt = content[index + 1]
180
+ if char == '"'
181
+ index = copy_string_literal(content, index, output)
182
+ elsif char == "/" && nxt == "/"
183
+ index += 1 while index < length && content[index] != "\n"
184
+ elsif char == "/" && nxt == "*"
185
+ index = skip_block_comment(content, index)
186
+ else
187
+ output << char
188
+ index += 1
189
+ end
190
+ end
191
+ output
192
+ end
193
+
194
+ # Copy a double-quoted string literal verbatim into +output+, respecting
195
+ # backslash escapes, and return the index just past the closing quote.
196
+ #
197
+ # @return [Integer]
198
+ def self.copy_string_literal(content, index, output)
199
+ length = content.length
200
+ output << content[index] # opening quote
201
+ index += 1
202
+ while index < length
203
+ char = content[index]
204
+ output << char
205
+ if char == "\\"
206
+ output << content[index + 1] if index + 1 < length
207
+ index += 2
208
+ next
209
+ end
210
+ index += 1
211
+ break if char == '"'
212
+ end
213
+ index
214
+ end
215
+
216
+ # Return the index just past the closing `*/` of a block comment. Swift block
217
+ # comments nest, so depth is tracked: `/* a /* b */ c */` is a single comment.
218
+ #
219
+ # @return [Integer]
220
+ def self.skip_block_comment(content, index)
221
+ length = content.length
222
+ depth = 1
223
+ index += 2 # skip the opening "/*"
224
+ while index < length && depth.positive?
225
+ if content[index] == "/" && content[index + 1] == "*"
226
+ depth += 1
227
+ index += 2
228
+ elsif content[index] == "*" && content[index + 1] == "/"
229
+ depth -= 1
230
+ index += 2
231
+ else
232
+ index += 1
233
+ end
234
+ end
235
+ index
236
+ end
237
+
238
+ private_class_method :package_calls,
239
+ :requirement_for,
240
+ :version_range_requirement,
241
+ :increment_patch_version,
242
+ :matching_paren,
243
+ :strip_comments,
244
+ :copy_string_literal,
245
+ :skip_block_comment
246
+
247
+ # Raised when manifest mode is invoked without a manifest path.
248
+ class ManifestPathMustBeSet < StandardError
249
+ end
250
+
251
+ # Raised when a configured Package.swift manifest is missing.
252
+ class CouldNotFindManifest < StandardError
253
+ end
254
+
255
+ # Raised when manifest mode cannot find an expected Package.resolved file.
256
+ class CouldNotFindResolvedFile < StandardError
257
+ end
258
+ end