Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 76 additions & 14 deletions swift/lib/dependabot/swift/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
# frozen_string_literal: true

require "dependabot/dependency"
require "dependabot/experiments"
require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/swift/file_parser/dependency_parser"
require "dependabot/swift/file_parser/manifest_parser"
require "dependabot/swift/file_parser/xcode_spm_resolver"
require "dependabot/swift/package_manager"
require "dependabot/swift/language"
Comment thread
markhallen marked this conversation as resolved.

Expand All @@ -18,6 +20,34 @@ class FileParser < Dependabot::FileParsers::Base

sig { override.returns(T::Array[Dependabot::Dependency]) }
def parse
if package_manifest_file
parse_classic_spm
elsif xcode_spm_mode?
parse_xcode_spm
else
raise "No Package.swift or Xcode Package.resolved found!"
end
end

sig { returns(Ecosystem) }
def ecosystem
@ecosystem ||= T.let(
begin
Ecosystem.new(
name: ECOSYSTEM,
language: language,
package_manager: package_manager
)
end,
T.nilable(Dependabot::Ecosystem)
)
end

private

# Classic SPM parsing: uses swift CLI via DependencyParser + ManifestParser
sig { returns(T::Array[Dependabot::Dependency]) }
def parse_classic_spm
dependency_set = DependencySet.new

dependency_parser.parse.map do |dep|
Expand All @@ -41,21 +71,21 @@ def parse
dependency_set.dependencies
end

sig { returns(Ecosystem) }
def ecosystem
@ecosystem ||= T.let(
begin
Ecosystem.new(
name: ECOSYSTEM,
language: language,
package_manager: package_manager
)
end,
T.nilable(Dependabot::Ecosystem)
)
# Xcode SPM parsing: delegates to XcodeSpmResolver which parses
# Package.resolved JSON and enriches with project.pbxproj requirements
sig { returns(T::Array[Dependabot::Dependency]) }
def parse_xcode_spm
XcodeSpmResolver.new(
xcode_resolved_files: xcode_resolved_files,
pbxproj_files: pbxproj_files
).parse
end

private
sig { returns(T::Boolean) }
def xcode_spm_mode?
Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) &&
xcode_resolved_files.any?
end

sig { returns(Dependabot::Swift::FileParser::DependencyParser) }
def dependency_parser
Expand All @@ -68,7 +98,15 @@ def dependency_parser

sig { override.void }
def check_required_files
raise "No Package.swift!" unless package_manifest_file
return if package_manifest_file

if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm)
return if xcode_resolved_files.any?

raise "No Package.swift or Xcode Package.resolved found!"
end

raise "No Package.swift!"
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
Expand All @@ -77,6 +115,30 @@ def package_manifest_file
@package_manifest_file ||= T.let(get_original_file("Package.swift"), T.nilable(Dependabot::DependencyFile))
end

# All non-support Package.resolved files from Xcode project directories
sig { returns(T::Array[Dependabot::DependencyFile]) }
def xcode_resolved_files
@xcode_resolved_files ||= T.let(
dependency_files.select do |f|
f.name.end_with?("Package.resolved") &&
f.name.include?(".xcodeproj/") &&
!f.support_file?
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

# All project.pbxproj support files
sig { returns(T::Array[Dependabot::DependencyFile]) }
def pbxproj_files
@pbxproj_files ||= T.let(
dependency_files.select do |f|
f.name.end_with?("project.pbxproj") && f.support_file?
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

sig { returns(Ecosystem::VersionManager) }
def package_manager
@package_manager ||= T.let(
Expand Down
11 changes: 2 additions & 9 deletions swift/lib/dependabot/swift/file_parser/dependency_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
require "dependabot/file_parsers/base"
require "dependabot/shared_helpers"
require "dependabot/dependency"
require "dependabot/swift/url_helpers"
require "json"
require "uri"

module Dependabot
module Swift
Expand Down Expand Up @@ -79,7 +79,7 @@ def subdependencies(data, level: 0)
def all_dependencies(data, level: 0)
identity = data["identity"]
url = SharedHelpers.scp_to_standard(data["url"])
name = normalize(url)
name = UrlHelpers.normalize_name(url)
version = data["version"]

source = { type: "git", url: url, ref: version, branch: nil }
Expand All @@ -97,13 +97,6 @@ def all_dependencies(data, level: 0)
[dep, *subdependencies(data, level: level + 1)].compact
end

sig { params(source: String).returns(String) }
def normalize(source)
uri = URI.parse(source.downcase)

"#{uri.host}#{uri.path}".delete_prefix("www.").delete_suffix(".git")
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
attr_reader :dependency_files

Expand Down
165 changes: 165 additions & 0 deletions swift/lib/dependabot/swift/file_parser/package_resolved_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# typed: strict
# frozen_string_literal: true

require "json"
require "sorbet-runtime"
require "dependabot/dependency"
require "dependabot/errors"
require "dependabot/shared_helpers"
require "dependabot/swift/file_parser"
require "dependabot/swift/url_helpers"

module Dependabot
module Swift
class FileParser < Dependabot::FileParsers::Base
class PackageResolvedParser
extend T::Sig

SUPPORTED_VERSIONS = T.let([1, 2, 3].freeze, T::Array[Integer])

# Maps schema version to the JSON keys used for each pin field
PIN_KEYS = T.let(
{
1 => { url: "repositoryURL", identity: "package", state: "state" },
2 => { url: "location", identity: "identity", state: "state" },
3 => { url: "location", identity: "identity", state: "state" }
}.freeze,
T::Hash[Integer, T::Hash[Symbol, String]]
)

sig { params(resolved_file: Dependabot::DependencyFile).void }
def initialize(resolved_file)
@resolved_file = resolved_file
end

sig { returns(T::Array[Dependabot::Dependency]) }
def parse
parsed = parse_json
schema_version = detect_schema_version(parsed)
pins = extract_pins(parsed, schema_version)

pins.filter_map { |pin| build_dependency(pin, schema_version) }
end

private

sig { returns(Dependabot::DependencyFile) }
attr_reader :resolved_file

sig { returns(T::Hash[String, T.untyped]) }
def parse_json
content = resolved_file.content
unless content
raise Dependabot::DependencyFileNotParseable.new(
resolved_file.name,
"#{resolved_file.name} has no content"
)
end

JSON.parse(content)
rescue JSON::ParserError => e
raise Dependabot::DependencyFileNotParseable.new(
resolved_file.name,
"#{resolved_file.name} is not valid JSON: #{e.message}"
)
end

sig { params(parsed: T::Hash[String, T.untyped]).returns(Integer) }
def detect_schema_version(parsed)
version = parsed["version"]

unless version.is_a?(Integer) && SUPPORTED_VERSIONS.include?(version)
raise Dependabot::DependencyFileNotParseable.new(
resolved_file.name,
"#{resolved_file.name} has unsupported schema version: #{version.inspect}. " \
"Supported versions: #{SUPPORTED_VERSIONS.join(', ')}"
)
end

version
end

sig do
params(
parsed: T::Hash[String, T.untyped],
schema_version: Integer
).returns(T::Array[T::Hash[String, T.untyped]])
end
def extract_pins(parsed, schema_version)
pins = if schema_version == 1
parsed.dig("object", "pins")
else
# v2 and v3 use the same top-level "pins" key
parsed["pins"]
end

unless pins.is_a?(Array)
raise Dependabot::DependencyFileNotParseable.new(
resolved_file.name,
"#{resolved_file.name} is missing the expected 'pins' array " \
"(schema version #{schema_version})"
)
end

pins
end

sig do
params(
pin: T::Hash[String, T.untyped],
schema_version: Integer
).returns(T.nilable(Dependabot::Dependency))
end
def build_dependency(pin, schema_version)
keys = T.must(PIN_KEYS[schema_version])
url = pin[T.must(keys[:url])]
return nil unless url.is_a?(String) && !url.empty?

state = pin[T.must(keys[:state])] || {}
identity = pin[T.must(keys[:identity])]
# v1 uses a display name for "package"; normalize to lowercase like v2/v3 "identity".
# v2/v3 identity is always lowercase per spec, so only v1 needs downcasing.
identity = identity&.downcase if schema_version == 1

build_dependency_object(
identity: identity,
url: url,
version: state["version"],
revision: state["revision"],
branch: state["branch"]
)
end

sig do
params(
identity: T.nilable(String),
url: String,
version: T.nilable(String),
revision: T.nilable(String),
branch: T.nilable(String)
).returns(T.nilable(Dependabot::Dependency))
end
def build_dependency_object(identity:, url:, version:, revision:, branch:)
normalized_url = SharedHelpers.scp_to_standard(url)
name = UrlHelpers.normalize_name(normalized_url)
ref = version || revision

source = { type: "git", url: normalized_url, ref: ref, branch: branch }

Dependency.new(
name: name,
version: version,
package_manager: "swift",
requirements: [{
requirement: version ? "= #{version}" : nil,
groups: ["dependencies"],
file: resolved_file.name,
source: source
}],
metadata: { identity: identity }
)
end
end
end
end
end
Loading
Loading