diff --git a/spec/unit/dependency_definition_spec.cr b/spec/unit/dependency_definition_spec.cr new file mode 100644 index 00000000..9e43facc --- /dev/null +++ b/spec/unit/dependency_definition_spec.cr @@ -0,0 +1,79 @@ +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", Version.new("1.2.3")) + + # GitHub urls + expect_parses("https://github.com/foo/bar", "github", "foo/bar", Any) + expect_parses("https://github.com/foo/bar#1.2.3", "github", "foo/bar", Version.new("1.2.3")) + + # GitHub urls from clone popup + expect_parses("https://github.com/foo/bar.git", "github", "foo/bar", Any) + expect_parses("https://github.com/foo/bar.git#1.2.3", "github", "foo/bar", Version.new("1.2.3")) + expect_parses("git@github.com:foo/bar.git", "git", "git@github.com:foo/bar.git", Any) + expect_parses("git@github.com:foo/bar.git#1.2.3", "git", "git@github.com:foo/bar.git", Version.new("1.2.3")) + + # GitLab short syntax + expect_parses("gitlab:foo/bar", "gitlab", "foo/bar", Any) + expect_parses("gitlab:foo/bar#1.2.3", "gitlab", "foo/bar", Version.new("1.2.3")) + + # 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) + + # unknown https urls + expect_raises Shards::Error, "Cannot determine resolver for HTTPS URI" do + Shards::DependencyDefinition.parts_from_cli("https://example.com/foo/bar") + end + + # 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) + {% if flag?(:windows) %} + expect_parses(".\\relative\\windows", "path", "./relative/windows", Any) + expect_parses("..\\relative\\windows", "path", "../relative/windows", Any) + {% else %} + expect_parses("./#{local_relative}", "path", "./#{local_relative}", Any) + {% end %} + # Path file schema + expect_raises Shards::Error, "Invalid file URI" do + Shards::DependencyDefinition.parts_from_cli("file://#{local_relative}") + end + expect_parses("file:#{local_relative}", "path", local_relative, Any) + expect_parses("file:#{local_absolute}", "path", local_absolute, 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) + expect_parses("git+https://example.org/foo/bar", "git", "https://example.org/foo/bar", Any) + expect_parses("git:https://example.org/foo/bar", "git", "https://example.org/foo/bar", 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..f97fa5af --- /dev/null +++ b/src/dependency_definition.cr @@ -0,0 +1,96 @@ +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 + uri = URI.parse(value) + + # fragment parsing for version requirement + requirement = Any + if fragment = uri.fragment + uri.fragment = nil + value = value.rchop("##{fragment}") + requirement = Version.new(fragment) + end + + case scheme = uri.scheme + when Nil + case value + when .starts_with?("./"), .starts_with?("../") + Parts.new("path", Path[value].to_posix.to_s, Any) + when .starts_with?(".\\"), .starts_with?("..\\") + {% if flag?(:windows) %} + Parts.new("path", Path[value].to_posix.to_s, Any) + {% else %} + raise Shards::Error.new("Invalid dependency format: #{value}") + {% end %} + when .starts_with?("git@") + Parts.new("git", value, requirement) + else + raise Shards::Error.new("Invalid dependency format: #{value}") + end + when "file" + raise Shards::Error.new("Invalid file URI: #{uri}") if !uri.host.in?(nil, "", "localhost") || uri.port || uri.user + Parts.new("path", uri.path, Any) + when "https" + if resolver_key = GitResolver::KNOWN_PROVIDERS[uri.host]? + Parts.new(resolver_key, uri.path[1..-1].rchop(".git"), requirement) # drop first "/"" + else + raise Shards::Error.new("Cannot determine resolver for HTTPS URI: #{value}") + end + else + scheme, _, subscheme = scheme.partition('+') + subscheme = subscheme.presence + if Resolver.find_class(scheme) + if uri.host.nil? || subscheme + uri.scheme = subscheme + end + return Parts.new(scheme, uri.to_s, requirement) + end + raise Shards::Error.new("Invalid dependency format: #{value}") + end + 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..7e9fd462 100644 --- a/src/resolvers/resolver.cr +++ b/src/resolvers/resolver.cr @@ -100,7 +100,7 @@ module Shards end private record ResolverCacheKey, key : String, name : String, source : String - private RESOLVER_CLASSES = {} of String => Resolver.class + RESOLVER_CLASSES = {} of String => Resolver.class private RESOLVER_CACHE = {} of ResolverCacheKey => Resolver def self.register_resolver(key, resolver)