diff --git a/spec/unit/dependency_definition_spec.cr b/spec/unit/dependency_definition_spec.cr new file mode 100644 index 00000000..7e38574e --- /dev/null +++ b/spec/unit/dependency_definition_spec.cr @@ -0,0 +1,63 @@ +require "./spec_helper" +require "../../src/dependency_definition" + +private def expect_parses(value, resolver_key : String, source : String, requirement : Shards::Requirement) + Shards::DependencyDefinition.parts_from_cli(value).should eq(Shards::DependencyDefinition::Parts.new(resolver_key: resolver_key, source: source, requirement: requirement)) +end + +module Shards + describe DependencyDefinition do + it ".parts_from_cli" do + # GitHub short syntax + expect_parses("github:foo/bar", "github", "foo/bar", Any) + expect_parses("github:Foo/Bar@1.2.3", "github", "Foo/Bar", VersionReq.new("~> 1.2.3")) + + # GitHub urls + expect_parses("https://github.com/foo/bar", "github", "foo/bar", Any) + + # GitHub urls from clone popup + expect_parses("https://github.com/foo/bar.git", "github", "foo/bar", Any) + expect_parses("git@github.com:foo/bar.git", "git", "git@github.com:foo/bar.git", Any) + + # GitLab short syntax + expect_parses("gitlab:foo/bar", "gitlab", "foo/bar", Any) + + # GitLab urls + expect_parses("https://gitlab.com/foo/bar", "gitlab", "foo/bar", Any) + + # GitLab urls from clone popup + expect_parses("https://gitlab.com/foo/bar.git", "gitlab", "foo/bar", Any) + expect_parses("git@gitlab.com:foo/bar.git", "git", "git@gitlab.com:foo/bar.git", requirement: Any) + + # Bitbucket short syntax + expect_parses("bitbucket:foo/bar", "bitbucket", "foo/bar", Any) + + # bitbucket urls + expect_parses("https://bitbucket.com/foo/bar", "bitbucket", "foo/bar", Any) + + # Git convenient syntax since resolver matches scheme + expect_parses("git://git.example.org/crystal-library.git", "git", "git://git.example.org/crystal-library.git", Any) + expect_parses("git@example.org:foo/bar.git", "git", "git@example.org:foo/bar.git", Any) + + # Local paths + local_absolute = "/an/absolute/path" + local_relative = "an/relative/path" + + # Path short syntax + expect_parses("./#{local_relative}", "path", "./#{local_relative}", Any) + expect_parses("../#{local_relative}", "path", "../#{local_relative}", Any) + {% if flag?(:windows) %} + expect_parses(".\\relative\\windows", "path", "./relative/windows", Any) + expect_parses("..\\relative\\windows", "path", "../relative/windows", Any) + {% end %} + # Path file schema + expect_parses("file://#{local_relative}", "path", local_relative, Any) + expect_parses("file://#{local_absolute}", "path", local_absolute, Any) + # Path resolver syntax + expect_parses("path:#{local_absolute}", "path", local_absolute, Any) + expect_parses("path:#{local_relative}", "path", local_relative, Any) + # Other resolvers short + expect_parses("git:git://git.example.org/crystal-library.git", "git", "git://git.example.org/crystal-library.git", Any) + end + end +end diff --git a/src/dependency.cr b/src/dependency.cr index c4d51b26..25263d25 100644 --- a/src/dependency.cr +++ b/src/dependency.cr @@ -44,6 +44,7 @@ module Shards end end + # Used to generate the shard.lock file. def to_yaml(yaml : YAML::Builder) yaml.scalar name yaml.mapping do diff --git a/src/dependency_definition.cr b/src/dependency_definition.cr new file mode 100644 index 00000000..55b20250 --- /dev/null +++ b/src/dependency_definition.cr @@ -0,0 +1,103 @@ +require "./dependency" + +module Shards + class DependencyDefinition + record Parts, resolver_key : String, source : String, requirement : Requirement + + property dependency : Dependency + # resolver's key and source are normalized. We preserve the key and source to be used + # in the shard.yml file in these field. This is used to generate the shard.yml file + # in a more human-readable way. + property resolver_key : String + property source : String + + def initialize(@dependency : Dependency, @resolver_key : String, @source : String) + end + + # Used to generate the shard.yml file. + def to_yaml(yaml : YAML::Builder) + yaml.scalar dependency.name + yaml.mapping do + yaml.scalar resolver_key + yaml.scalar source + dependency.requirement.to_yaml(yaml) + end + end + + # Parse a dependency from a CLI argument + def self.from_cli(value : String) : DependencyDefinition + parts = parts_from_cli(value) + + # We need to check the actual shard name to create a dependency. + # This requires getting the actual spec file from some matching version. + resolver = Resolver.find_resolver(parts.resolver_key, "unknown", parts.source) + version = resolver.versions_for(parts.requirement).first || raise Shards::Error.new("No versions found for dependency: #{value}") + spec = resolver.spec(version) + name = spec.name || raise Shards::Error.new("No name found for dependency: #{value}") + + DependencyDefinition.new(Dependency.new(name, resolver, parts.requirement), parts.resolver_key, parts.source) + end + + # :nodoc: + # + # Parse the dependency from a CLI argument + # and return the parts needed to create the proper dependency. + # + # Split to allow better unit testing. + def self.parts_from_cli(value : String) : Parts + resolver_key = nil + source = "" + requirement = Any + + if value.starts_with?("file://") + resolver_key = "path" + source = value[7..-1] # drop "file://" + end + + # relative paths + path = Path[value].to_posix.to_s + if path.starts_with?("./") || path.starts_with?("../") + resolver_key = "path" + source = path + end + + uri = URI.parse(value) + if uri.scheme != "file" && uri.host && + (resolver_key = GitResolver::KNOWN_PROVIDERS[uri.host]?) + source = uri.path[1..-1].rchop(".git") # drop first "/"" + end + + if value.starts_with?("git://") + resolver_key = "git" + source = value + end + + if value.starts_with?("git@") + resolver_key = "git" + source = value + end + + unless resolver_key + Resolver.resolver_keys.each do |key| + key_schema = "#{key}:" + if value.starts_with?(key_schema) + resolver_key = key + source = value.sub(key_schema, "") + + # narrow down requirement + if source.includes?("@") + source, version = source.split("@") + requirement = VersionReq.new("~> #{version}") + end + + break + end + end + end + + raise Shards::Error.new("Invalid dependency format: #{value}") unless resolver_key + + Parts.new(resolver_key: resolver_key, source: source, requirement: requirement) + end + end +end diff --git a/src/resolvers/git.cr b/src/resolvers/git.cr index 955c8d4f..be9ca0bb 100644 --- a/src/resolvers/git.cr +++ b/src/resolvers/git.cr @@ -100,15 +100,15 @@ module Shards "git" end - private KNOWN_PROVIDERS = { - "www.github.com", - "github.com", - "www.bitbucket.com", - "bitbucket.com", - "www.gitlab.com", - "gitlab.com", - "www.codeberg.org", - "codeberg.org", + KNOWN_PROVIDERS = { + "www.github.com" => "github", + "github.com" => "github", + "www.bitbucket.com" => "bitbucket", + "bitbucket.com" => "bitbucket", + "www.gitlab.com" => "gitlab", + "gitlab.com" => "gitlab", + "www.codeberg.org" => "codeberg", + "codeberg.org" => "codeberg", } def self.normalize_key_source(key : String, source : String) : {String, String} @@ -117,7 +117,7 @@ module Shards uri = URI.parse(source) downcased_host = uri.host.try &.downcase scheme = uri.scheme.try &.downcase - if scheme.in?("git", "http", "https") && downcased_host && downcased_host.in?(KNOWN_PROVIDERS) + if scheme.in?("git", "http", "https") && downcased_host && downcased_host.in?(KNOWN_PROVIDERS.keys) # browsers are requested to enforce HTTP Strict Transport Security uri.scheme = "https" downcased_path = uri.path.downcase diff --git a/src/resolvers/resolver.cr b/src/resolvers/resolver.cr index 9adfe7cd..b1069f5c 100644 --- a/src/resolvers/resolver.cr +++ b/src/resolvers/resolver.cr @@ -103,6 +103,10 @@ module Shards private RESOLVER_CLASSES = {} of String => Resolver.class private RESOLVER_CACHE = {} of ResolverCacheKey => Resolver + def self.resolver_keys + RESOLVER_CLASSES.keys + end + def self.register_resolver(key, resolver) RESOLVER_CLASSES[key] = resolver end