Module: ManifestParser

Defined in:
gems/spm_version_updates/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 'gems/spm_version_updates/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 'gems/spm_version_updates/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 'gems/spm_version_updates/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 'gems/spm_version_updates/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 'gems/spm_version_updates/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