Skip to content

Dependency cli parsing #673

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
63 changes: 63 additions & 0 deletions spec/unit/dependency_definition_spec.cr
Original file line number Diff line number Diff line change
@@ -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/[email protected]", "github", "Foo/Bar", VersionReq.new("~> 1.2.3"))

# GitHub urls
expect_parses("https://github.com/foo/bar", "github", "foo/bar", Any)
Copy link
Member

@straight-shoota straight-shoota May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I'm wondering about why this gets normalized to github resolver instead of "git", "https://github.com/foo/bar".
Both options are valid. So we only need to decide which one we want to pick.
I suppose the reason for github is that it's more concise? That's nice but not strictly necessary.
git would be closer to the original input.

Copy link
Member Author

@bcardiff bcardiff May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me closer to the intent is to have github: foo/bar, because I'm assuming the user is copy-pasting the url in the browser. We do preserve the format with trailing .git as those are copied from the clone popup.

At some point maybe it's worth configuring shards to map all github source to be resolved in some specific way, but is a separate story.


# GitHub urls from clone popup
expect_parses("https://github.com/foo/bar.git", "github", "foo/bar", Any)
expect_parses("[email protected]:foo/bar.git", "git", "[email protected]: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("[email protected]:foo/bar.git", "git", "[email protected]: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("[email protected]:foo/bar.git", "git", "[email protected]: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
1 change: 1 addition & 0 deletions src/dependency.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions src/dependency_definition.cr
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions src/resolvers/git.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/resolvers/resolver.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down