composer/lib/dependabot/composer/file_parser.rb
# typed: strict
# frozen_string_literal: true

require "dependabot/composer"
require "dependabot/dependency"
require "dependabot/composer/version"
require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/shared_helpers"
require "dependabot/errors"

module Dependabot
  module Composer
    REQUIREMENT_SEPARATOR = /
      (?<=\S|^)          # Positive lookbehind for a non-whitespace character or start of string
      (?:[ \t,]*\|\|?[ \t]*) # Match optional whitespace, a pipe (|| or |), and optional whitespace
      (?=\S|$)           # Positive lookahead for a non-whitespace character or end of string
    /x

    class FileParser < Dependabot::FileParsers::Base
      require "dependabot/file_parsers/base/dependency_set"

      DEPENDENCY_GROUP_KEYS = T.let(
        [
          {
            manifest: "require",
            lockfile: "packages",
            group: "runtime"
          },
          {
            manifest: "require-dev",
            lockfile: "packages-dev",
            group: "development"
          }
        ].freeze,
        T::Array[T::Hash[Symbol, String]]
      )

      sig { override.returns(T::Array[Dependabot::Dependency]) }
      def parse
        dependency_set = T.let(DependencySet.new, DependencySet)
        dependency_set += manifest_dependencies
        dependency_set += lockfile_dependencies
        dependency_set.dependencies
      end

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

      private

      sig { returns(Ecosystem::VersionManager) }
      def package_manager
        if composer_version == Helpers::V1
          return PackageManager.new(
            detected_version: composer_version
          )
        end
        PackageManager.new(
          detected_version: composer_version,
          raw_version: env_versions[:composer]
        )
      end

      sig { returns(T.nilable(Ecosystem::VersionManager)) }
      def language
        php_version = env_versions[:php]

        return unless php_version

        Language.new(
          php_version,
          requirement: php_requirement
        )
      end

      sig { returns(T::Hash[Symbol, T.nilable(String)]) }
      def env_versions
        @env_versions ||= T.let(
          Helpers.fetch_composer_and_php_versions,
          T.nilable(T::Hash[Symbol, T.nilable(String)])
        )
      end

      # Capture PHP requirement from the composer.json
      sig { returns(T.nilable(Requirement)) }
      def php_requirement
        requirement_string = Helpers.php_constraint(parsed_composer_json)

        return nil unless requirement_string

        requirements = requirement_string
                       .strip
                       .split(REQUIREMENT_SEPARATOR)
                       .map(&:strip)
                       .reject(&:empty?)

        return nil unless requirements.any?

        Requirement.new(requirements)
      end

      sig { returns(DependencySet) }
      def manifest_dependencies # rubocop:disable Metrics/PerceivedComplexity
        dependencies = T.let(DependencySet.new, DependencySet)

        DEPENDENCY_GROUP_KEYS.each do |keys|
          manifest = keys[:manifest]
          next unless manifest.is_a?(String)

          next unless parsed_composer_json[manifest].is_a?(Hash)

          parsed_composer_json[manifest].each do |name, req|
            next unless package?(name)

            if Dependabot::Experiments.enabled?(:exclude_local_composer_packages)
              local_package_prefix = ["dev-main", "dev-master", "@dev"]

              # we avoid updating local packages, so we skip them adding to dependency list
              if local_package_prefix.include?(req)
                Dependabot.logger.info("Skipping #{name} with version #{req} as it cannot be updated.")
                next
              end
            end

            if lockfile
              group = keys[:group]
              next unless group.is_a?(String)

              version = dependency_version(name: name, type: group)

              # Ignore dependency versions which don't appear in the
              # composer.lock or are non-numeric and not a git SHA, since they
              # can't be compared later in the process.
              next unless version&.match?(/^\d/) ||
                          version&.match?(/^[0-9a-f]{40}$/)
            end

            dependencies << build_manifest_dependency(name, req, keys)
          end
        end

        dependencies
      end

      sig { params(name: String, req: String, keys: T::Hash[Symbol, String]).returns(Dependabot::Dependency) }
      def build_manifest_dependency(name, req, keys)
        group = T.must(keys[:group])

        Dependabot::Dependency.new(
          name: name,
          version: dependency_version(name: name, type: group),
          requirements: [{
            requirement: req,
            file: PackageManager::MANIFEST_FILENAME,
            source: dependency_source(
              name: name,
              type: group,
              requirement: req
            ),
            groups: [group]
          }],
          package_manager: PackageManager::NAME
        )
      end

      # rubocop:disable Metrics/PerceivedComplexity
      sig { returns(DependencySet) }
      def lockfile_dependencies
        dependencies = T.let(DependencySet.new, DependencySet)

        return dependencies unless lockfile

        DEPENDENCY_GROUP_KEYS.each do |keys|
          key = keys.fetch(:lockfile)
          next unless parsed_lockfile&.[](key).is_a?(Array)

          parsed_lockfile&.[](key)&.each do |details|
            name = details["name"]
            next unless name.is_a?(String) && package?(name)

            version = details["version"]&.to_s&.sub(/^v?/, "")
            next unless version.is_a?(String)
            next unless version.match?(/^\d/) ||
                        version.match?(/^[0-9a-f]{40}$/)

            dependencies << build_lockfile_dependency(name, version, keys)
          end
        end

        dependencies
      end
      # rubocop:enable Metrics/PerceivedComplexity

      sig { params(name: String, version: String, keys: T::Hash[Symbol, String]).returns(Dependabot::Dependency) }
      def build_lockfile_dependency(name, version, keys)
        Dependabot::Dependency.new(
          name: name,
          version: version,
          requirements: [],
          package_manager: PackageManager::NAME,
          subdependency_metadata: [{
            production: keys.fetch(:group) != "development"
          }]
        )
      end

      sig { params(name: String, type: String).returns(T.nilable(String)) }
      def dependency_version(name:, type:)
        return unless lockfile

        package = lockfile_details(name: name, type: type)
        return unless package

        version = package.fetch("version")&.to_s&.sub(/^v?/, "")
        return version unless version&.start_with?("dev-")

        package.dig("source", "reference")
      end

      sig do
        params(
          name: String,
          type: String,
          requirement: String
        ).returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
      end
      def dependency_source(name:, type:, requirement:)
        return unless lockfile

        package_details = lockfile_details(name: name, type: type)
        return unless package_details

        if package_details["source"].nil? &&
           package_details.dig("dist", "type") == "path"
          return { type: "path" }
        end

        git_dependency_details(package_details, requirement)
      end

      sig do
        params(
          package_details: T::Hash[String, T.untyped],
          requirement: String
        ).returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
      end
      def git_dependency_details(package_details, requirement)
        return unless package_details.dig("source", "type") == "git"

        branch =
          if requirement.start_with?("dev-")
            requirement
              .sub(/^dev-/, "")
              .sub(/\s+as\s.*/, "")
              .split("#").first
          elsif package_details.fetch("version")&.to_s&.start_with?("dev-")
            package_details.fetch("version")&.to_s&.sub(/^dev-/, "")
          end

        details = { type: "git", url: package_details.dig("source", "url") }
        return details unless branch

        details.merge(branch: branch, ref: nil)
      end

      sig { params(name: String, type: String).returns(T.nilable(T::Hash[String, T.untyped])) }
      def lockfile_details(name:, type:)
        key = lockfile_key(type)
        parsed_lockfile&.fetch(key, [])&.find { |d| d["name"] == name }
      end

      sig { params(type: String).returns(String) }
      def lockfile_key(type)
        case type
        when "runtime" then "packages"
        when "development" then "packages-dev"
        else raise "unknown type #{type}"
        end
      end

      sig { params(name: String).returns(T::Boolean) }
      def package?(name)
        name.split("/").count == 2
      end

      sig { override.void }
      def check_required_files
        raise "No #{PackageManager::MANIFEST_FILENAME}!" unless get_original_file(PackageManager::MANIFEST_FILENAME)
      end

      sig { returns(T.nilable(T::Hash[String, T.untyped])) }
      def parsed_lockfile # rubocop:disable Metrics/PerceivedComplexity
        return unless lockfile

        content = lockfile&.content

        raise Dependabot::DependencyFileNotParseable, lockfile&.path || "" if content.nil? || content.strip.empty?

        @parsed_lockfile ||= T.let(JSON.parse(content), T.nilable(T::Hash[String, T.untyped]))
      rescue JSON::ParserError
        raise Dependabot::DependencyFileNotParseable, lockfile&.path || ""
      end

      sig { returns(T::Hash[String, T.untyped]) }
      def parsed_composer_json
        content = composer_json&.content

        if content.nil? || content.strip.empty?
          raise Dependabot::DependencyFileNotParseable,
                composer_json&.path || ""
        end

        @parsed_composer_json ||= T.let(JSON.parse(content), T.nilable(T::Hash[String, T.untyped]))
      rescue JSON::ParserError
        raise Dependabot::DependencyFileNotParseable, composer_json&.path || ""
      end

      sig { returns(T.nilable(Dependabot::DependencyFile)) }
      def composer_json
        @composer_json ||= T.let(
          get_original_file(PackageManager::MANIFEST_FILENAME),
          T.nilable(Dependabot::DependencyFile)
        )
      end

      sig { returns(T.nilable(Dependabot::DependencyFile)) }
      def lockfile
        @lockfile ||= T.let(
          get_original_file(PackageManager::LOCKFILE_FILENAME),
          T.nilable(Dependabot::DependencyFile)
        )
      end

      sig { returns(String) }
      def composer_version
        @composer_version ||= T.let(
          Helpers.composer_version(parsed_composer_json, parsed_lockfile),
          T.nilable(String)
        )
      end
    end
  end
end

Dependabot::FileParsers.register("composer", Dependabot::Composer::FileParser)
