Module: ManifestParser

Defined in:
lib/spm_version_updates/manifest_parser.rb

Overview

Parses Swift Package Manager manifests (‘Package.swift`) and their adjacent `Package.resolved` files.

This supports the “SwiftPM-native” repo layout, where dependencies are declared directly in one or more ‘Package.swift` manifests rather than as `XCRemoteSwiftPackageReference` objects inside an `.xcodeproj`.

Manifests are parsed with a lightweight, dependency-free scanner so the action runs on any runner (e.g. ‘ubuntu-latest`) without requiring Swift or a macOS/Xcode toolchain to be installed.

The requirement hashes returned by ManifestParser.get_packages intentionally mirror the shape produced by ‘Xcodeproj` for `XCRemoteSwiftPackageReference#requirement` (`“kind”`, `“minimumVersion”`, `“maximumVersion”`, `“version”`, `“branch”`, `“revision”`) so the same comparison logic can be reused for both modes.

Defined Under Namespace

Classes: CouldNotFindManifest, CouldNotFindResolvedFile, ManifestPathMustBeSet, PackageCallSpan

Constant Summary collapse

PACKAGE_CALL =
".package("

Class Method Summary collapse

Class Method Details

.default_resolved_path(manifest_path) ⇒ String

Infer the ‘Package.resolved` path that sits next to a manifest.

Parameters:

  • manifest_path (String)

    The path to a ‘Package.swift` file

Returns:

  • (String)


198
199
200
# File 'lib/spm_version_updates/manifest_parser.rb', line 198

def self.default_resolved_path(manifest_path)
  File.join(File.dirname(manifest_path), "Package.resolved")
end

.get_packages(manifest_path) {|Hash| ... } ⇒ Hash<String, Hash>

Find the direct SPM dependencies declared in a ‘Package.swift` manifest.

Local packages (declared with ‘path:`) and packages without a recognizable version requirement are skipped.

Keyed by the normalized repository URL (used to match against ‘Package.resolved` pins and `ignore-repos`), while the original, scheme-bearing `repository_url` is retained for git operations.

Parameters:

  • manifest_path (String)

    The path to a ‘Package.swift` file

Yields:

  • (Hash)

    optionally receives ‘{ reason:, snippet: }` for each `.package(…)` declaration that had to be skipped, so callers can surface parse warnings instead of dropping dependencies silently

Returns:

  • (Hash<String, Hash>)

    normalized URL => { “repository_url”, “requirement” }

Raises:



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/spm_version_updates/manifest_parser.rb', line 158

def self.get_packages(manifest_path, &on_skip)
  raise(ManifestPathMustBeSet) if manifest_path.nil? || manifest_path.empty?
  raise(CouldNotFindManifest, manifest_path) unless File.exist?(manifest_path)

  content = strip_comments(File.read(manifest_path))
  package_calls(content, &on_skip).each_with_object({}) { |call, packages|
    if call.include?("\\(")
      on_skip&.call({ reason: "unsupported_string_interpolation", snippet: call })
      next
    end
    if call.match?(/#+"/)
      on_skip&.call({ reason: "unsupported_raw_string", snippet: call })
      next
    end

    url = call[/\burl\s*:\s*"([^"]+)"/, 1]
    next if url.nil? # local package (path:) or otherwise unrecognized

    requirement = requirement_for(call)
    if requirement.nil?
      on_skip&.call({ reason: "unrecognized_requirement", snippet: call })
      next
    end

    packages[GitOperations.trim_repo_url(url)] = { "repository_url" => url, "requirement" => requirement }
  }
end

.get_resolved_versions(resolved_path) ⇒ Hash<String, String>

Extract the resolved versions from a ‘Package.resolved` file.

Parameters:

  • resolved_path (String)

    The path to a ‘Package.resolved` file

Returns:

  • (Hash<String, String>)

    normalized repository URL => version or revision



190
191
192
# File 'lib/spm_version_updates/manifest_parser.rb', line 190

def self.get_resolved_versions(resolved_path)
  PackageResolved.versions_from(resolved_path)
end

.package_call_spans(content) ⇒ Array<PackageCallSpan>

Extract raw source spans for ‘.package(…)` calls. Offsets are byte indexes into the original content and point to the call body, excluding outer parens.

Parameters:

  • content (String)

    raw manifest source

Returns:



207
208
209
# File 'lib/spm_version_updates/manifest_parser.rb', line 207

def self.package_call_spans(content)
  package_spans(content).map { |span| PackageCallSpan.new(**span) }
end

.requirement_for(call) ⇒ Hash?

Map the body of a ‘.package(…)` call to an Xcodeproj-style requirement.

Ordering matters: ranges and the explicit ‘.upToNextMajor`/`.upToNextMinor` forms are matched before the bare `from:` shorthand because they also contain the substring `from:`.

Parameters:

  • call (String)

    The body of a ‘.package(…)` call

Returns:

  • (Hash, nil)


266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/spm_version_updates/manifest_parser.rb', line 266

def self.requirement_for(call)
  if (range = call.match(/"([^"]+)"\s*(\.\.[.<])\s*"([^"]+)"/))
    version_range_requirement(range[1], range[2], range[3])
  elsif (version = call[/\.upToNextMinor\s*\(\s*from\s*:\s*"([^"]+)"/, 1])
    { "kind" => "upToNextMinorVersion", "minimumVersion" => version }
  elsif (version = call[/\.upToNextMajor\s*\(\s*from\s*:\s*"([^"]+)"/, 1] || call[/\bfrom\s*:\s*"([^"]+)"/, 1])
    { "kind" => "upToNextMajorVersion", "minimumVersion" => version }
  elsif (version = call[/\bexact\s*:\s*"([^"]+)"/, 1] || call[/\.exact\s*\(\s*"([^"]+)"/, 1])
    { "kind" => "exactVersion", "version" => version }
  elsif (branch = call[/\bbranch\s*:\s*"([^"]+)"/, 1] || call[/\.branch\s*\(\s*"([^"]+)"/, 1])
    { "kind" => "branch", "branch" => branch }
  elsif (revision = call[/\brevision\s*:\s*"([^"]+)"/, 1] || call[/\.revision\s*\(\s*"([^"]+)"/, 1])
    { "kind" => "revision", "revision" => revision }
  end
end