Skip to content

Commit 0f2521e

Browse files
authored
Merge pull request #259 from crystal-lang/feature/sat-solver
Solving dependencies now relies a SAT solver, capable to handle conflicts, and to choose the closest solution to the ideal one: update everything, update/add/remove some with minimal impact, ...
2 parents a2a5e79 + 14171cd commit 0f2521e

33 files changed

+1523
-481
lines changed

man/shards.1

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ dependencies aren't satisfied.
4141
.PP
4242
\fBinit\fR
4343
.RS 4
44-
Initializes a shard folder and creates a default \fIshard.yml\fR.
44+
Initializes a default \fIshard.yml\fR in the current folder.
4545
.RE
4646
.PP
4747
\fBinstall\fR
@@ -56,37 +56,52 @@ a requirement, but may succeed if a new dependency was added, as long as it
5656
doesn't generate a conflict, thus generating a new \fIshard.lock\fR file.
5757
.RE
5858
.PP
59-
\fBlist\fR
59+
\fBlist [--tree]\fR
6060
.RS 4
61-
Lists the installed dependencies and their versions.
61+
Lists the installed dependencies and their versions. Specifying \fI--tree\fR
62+
will list nested dependencies in a tree manner, instead of a flattened list.
63+
.RE
64+
.PP
65+
\fBlock [--update [<shards>]]\fR
66+
.RS 4
67+
Resolves dependencies and creates or updates the \fIshard.lock\fR file as per
68+
the \fBinstall\fR command, but never installs the dependencies.
69+
.PP
70+
Specifying \fI--update\fR follows the same semantics as the \fBupdate\fR
71+
command.
6272
.RE
6373
.PP
6474
\fBprune\fR
6575
.RS 4
6676
Removes unused dependencies from \fIlib\fR folder.
6777
.RE
6878
.PP
69-
\fBupdate\fR
79+
\fBupdate [<shards>]\fR
7080
.RS 4
7181
Resolves and updates all dependencies into the \fIlib\fR folder again,
7282
whatever the locked versions and commits in the \fIshard.lock\fR file.
83+
.PP
84+
Specifying \fIshards\fR will update these dependencies only, trying to be as
85+
conservative as possible with other dependencies, respecting the locked versions
86+
and commits in the \fIshard.lock\fR file.
87+
.PP
7388
Eventually generates a new \fIshard.lock\fR file.
7489
.RE
7590
.PP
7691
\fBversion\fR [\fI<path>\fR]
7792
.RS 4
78-
Prints the current version of the shard.
93+
Print the current version of the shard located at \fIpath\fR.
7994
.RE
8095
.SH OPTIONS
8196
.PP
8297
\fB\-\-version\fR
8398
.RS 4
84-
Prints the \fIshards\fR version.
99+
Print the \fIshards\fR version.
85100
.RE
86101
.PP
87102
\fB\-h, \-\-help\fR
88103
.RS 4
89-
Prints usage synopsis.
104+
Print usage synopsis.
90105
.RE
91106
.PP
92107
\fB\-\-no-color\fR
@@ -103,12 +118,12 @@ locked dependencies will be installed. Commands will fail if dependencies in
103118
.PP
104119
\fB\-q, \-\-quiet\fR
105120
.RS 4
106-
Decreases the log verbosity, printing only warnings and errors.
121+
Decrease the log verbosity, printing only warnings and errors.
107122
.RE
108123
.PP
109124
\fB\-v, \-\-verbose\fR
110125
.RS 4
111-
Increases the log verbosity, printing all debug statements.
126+
Increase the log verbosity, printing all debug statements.
112127
.REAUTHOR
113128
Written by Julien Portalier.
114129
.SH "REPORTING BUGS"

src/cli.cr

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ module Shards
77
shards [<options>...] [<command>]
88
99
Commands:
10-
build [<targets>] [<options>] - Builds the specified <targets> in `bin` path.
11-
check - Verifies all dependencies are installed.
12-
init - Initializes a shard folder.
13-
install - Installs dependencies from `shard.lock` file.
14-
list [--tree] - Lists installed dependencies.
15-
outdated [--pre] - Lists dependencies that are outdated.
16-
prune - Removes unused dependencies from `lib` folder.
17-
update - Updates dependencies and `shards.lock`.
18-
version [<path>] - Prints the current version of the shard.
10+
build [<targets>] [<options>] - Build the specified <targets> in `bin` path.
11+
check - Verify all dependencies are installed.
12+
init - Initialize a `shard.yml` file.
13+
install - Install dependencies, creating or using the `shard.lock` file.
14+
list [--tree] - List installed dependencies.
15+
lock [--update] [<shards>] - Lock dependencies in `shard.lock` but doesn't install them.
16+
outdated [--pre] - List dependencies that are outdated.
17+
prune - Remove unused dependencies from `lib` folder.
18+
update [<shards>] - Update dependencies and `shard.lock`.
19+
version [<path>] - Print the current version of the shard.
1920
2021
Options:
2122
HELP
@@ -28,12 +29,12 @@ module Shards
2829
path = Dir.current
2930

3031
opts.on("--no-color", "Disable colored output.") { self.colors = false }
31-
opts.on("--version", "Prints the `shards` version.") { puts self.version_string; exit }
32+
opts.on("--version", "Print the `shards` version.") { puts self.version_string; exit }
3233
opts.on("--production", "Run in release mode. No development dependencies and strict sync between shard.yml and shard.lock.") { self.production = true }
3334
opts.on("--local", "Don't update remote repositories, use the local cache only.") { self.local = true }
34-
opts.on("-v", "--verbose", "Increases the log verbosity, printing all debug statements.") { self.logger.level = Logger::Severity::DEBUG }
35-
opts.on("-q", "--quiet", "Decreases the log verbosity, printing only warnings and errors.") { self.logger.level = Logger::Severity::WARN }
36-
opts.on("-h", "--help", "Prints usage synopsis.") { self.display_help_and_exit(opts) }
35+
opts.on("-v", "--verbose", "Increase the log verbosity, printing all debug statements.") { self.logger.level = Logger::Severity::DEBUG }
36+
opts.on("-q", "--quiet", "Decrease the log verbosity, printing only warnings and errors.") { self.logger.level = Logger::Severity::WARN }
37+
opts.on("-h", "--help", "Print usage synopsis.") { self.display_help_and_exit(opts) }
3738

3839
opts.unknown_args do |args, options|
3940
case args[0]? || DEFAULT_COMMAND
@@ -47,12 +48,22 @@ module Shards
4748
Commands::Install.run(path)
4849
when "list"
4950
Commands::List.run(path, tree: args.includes?("--tree"))
51+
when "lock"
52+
Commands::Lock.run(
53+
path,
54+
args[1..-1].reject(&.starts_with?("--")),
55+
print: args.includes?("--print"),
56+
update: args.includes?("--update")
57+
)
5058
when "outdated"
5159
Commands::Outdated.run(path, prereleases: args.includes?("--pre"))
5260
when "prune"
5361
Commands::Prune.run(path)
5462
when "update"
55-
Commands::Update.run(path)
63+
Commands::Update.run(
64+
path,
65+
args[1..-1].reject(&.starts_with?("--"))
66+
)
5667
when "version"
5768
Commands::Version.run(args[1]? || path)
5869
else

src/commands/build.cr

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module Shards
1010
end
1111

1212
if targets.empty?
13-
targets = manager.spec.targets.map(&.name)
13+
targets = spec.targets.map(&.name)
1414
end
1515

1616
targets.each do |name|
@@ -23,15 +23,15 @@ module Shards
2323
end
2424

2525
private def build(target, options)
26-
Shards.logger.info "Building: #{target.name}"
26+
Shards.logger.info { "Building: #{target.name}" }
2727

2828
args = [
2929
"build",
3030
"-o", File.join(Shards.bin_path, target.name),
3131
target.main,
3232
]
3333
options.each { |option| args << option }
34-
Shards.logger.debug "crystal #{args.join(' ')}"
34+
Shards.logger.debug { "crystal #{args.join(' ')}" }
3535

3636
error = IO::Memory.new
3737
status = Process.run("crystal", args: args, output: Process::Redirect::Inherit, error: error)

src/commands/check.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ require "../versions"
44
module Shards
55
module Commands
66
class Check < Command
7-
def run(*args)
7+
def run
88
if has_dependencies?
99
locks # ensures that lockfile exists
1010
verify(spec.dependencies)

src/commands/command.cr

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
require "../lock"
2-
require "../manager"
32
require "../spec"
43

54
module Shards
@@ -22,10 +21,10 @@ module Shards
2221
@lockfile_path = File.join(@path, LOCK_FILENAME)
2322
end
2423

25-
abstract def run(*args)
24+
abstract def run(*args, **kwargs)
2625

27-
def self.run(path, *args)
28-
new(path).run(*args)
26+
def self.run(path, *args, **kwargs)
27+
new(path).run(*args, **kwargs)
2928
end
3029

3130
def spec
@@ -40,13 +39,9 @@ module Shards
4039
File.basename(spec_path)
4140
end
4241

43-
def manager
44-
@manager ||= Manager.new(spec)
45-
end
46-
4742
def locks
4843
@locks ||= if lockfile?
49-
Lock.from_file(lockfile_path)
44+
Shards::Lock.from_file(lockfile_path)
5045
else
5146
raise Error.new("Missing #{LOCK_FILENAME}. Please run 'shards install'")
5247
end
@@ -55,5 +50,10 @@ module Shards
5550
def lockfile?
5651
File.exists?(lockfile_path)
5752
end
53+
54+
def write_lockfile(packages)
55+
Shards.logger.info { "Writing #{LOCK_FILENAME}" }
56+
Shards::Lock.write(packages, LOCK_FILENAME)
57+
end
5858
end
5959
end

src/commands/init.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ require "ecr/macros"
44
module Shards
55
module Commands
66
class Init < Command
7-
def run(*args)
7+
def run
88
if File.exists?(shard_path)
99
raise Error.new("#{SPEC_FILENAME} already exists")
1010
end

src/commands/install.cr

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,100 @@
11
require "./command"
2+
require "../solver"
23

34
module Shards
45
module Commands
5-
# OPTIMIZE: avoid updating GIT caches until required
66
class Install < Command
7-
def run(*args)
7+
def run
8+
Shards.logger.info { "Resolving dependencies" }
9+
10+
solver = Solver.new(spec)
11+
812
if lockfile?
9-
manager.locks = locks
10-
manager.resolve
11-
install(manager.packages, locks)
12-
else
13-
manager.resolve
14-
install(manager.packages)
13+
# install must be as conservative as possible:
14+
solver.locks = locks
1515
end
1616

17-
if generate_lockfile?
18-
manager.to_lock(lockfile_path)
17+
solver.prepare(development: !Shards.production?)
18+
19+
if packages = solver.solve
20+
return if packages.empty?
21+
22+
if lockfile?
23+
validate(packages)
24+
end
25+
26+
install(packages)
27+
28+
if generate_lockfile?(packages)
29+
write_lockfile(packages)
30+
end
31+
else
32+
solver.each_conflict do |message|
33+
Shards.logger.warn { "Conflict #{message}" }
34+
end
35+
Shards.logger.error { "Failed to resolve dependencies" }
1936
end
2037
end
2138

22-
private def install(packages : Set, locks : Array(Dependency))
39+
private def validate(packages)
2340
packages.each do |package|
24-
version = nil
25-
26-
if lock = locks.find { |dependency| dependency.name == package.name }
27-
if version = lock["version"]?
28-
unless package.matching_versions.includes?(version)
29-
raise LockConflict.new("#{package.name} requirements changed")
30-
end
31-
elsif version = lock["commit"]?
32-
unless package.matches?(version)
33-
raise LockConflict.new("#{package.name} requirements changed")
34-
end
41+
if lock = locks.find { |d| d.name == package.name }
42+
if version = lock.version?
43+
validate_locked_version(package, version)
44+
elsif commit = lock["commit"]?
45+
validate_locked_commit(package, commit)
3546
else
3647
raise InvalidLock.new # unknown lock resolver
3748
end
3849
elsif Shards.production?
3950
raise LockConflict.new("can't install new dependency #{package.name} in production")
4051
end
41-
42-
install(package, version)
4352
end
4453
end
4554

46-
private def install(packages : Set)
47-
packages
48-
.compact_map { |package| install(package) }
49-
.each(&.postinstall)
55+
private def validate_locked_version(package, version)
56+
return if Shards.production? && package.version == version
57+
return if Versions.matches?(version, package.spec.version)
58+
raise LockConflict.new("#{package.name} requirements changed")
59+
end
5060

51-
# always install executables because the path resolver never installs
52-
# dependencies, but uses them as-is:
53-
packages.each(&.install_executables)
61+
private def validate_locked_commit(package, commit)
62+
return if commit == package.commit
63+
raise LockConflict.new("#{package.name} requirements changed")
5464
end
5565

56-
private def install(package : Package, version = nil)
57-
version ||= package.version
66+
private def install(packages : Array(Package))
67+
# first install all dependencies:
68+
installed = packages.compact_map { |package| install(package) }
69+
70+
# then execute the postinstall script of installed dependencies (with
71+
# access to all transitive dependencies):
72+
installed.each(&.postinstall)
73+
74+
# always install executables because the path resolver never actually
75+
# installs dependencies:
76+
packages.each(&.install_executables)
77+
end
5878

59-
if package.installed?(version)
60-
Shards.logger.info "Using #{package.name} (#{package.report_version})"
79+
private def install(package : Package)
80+
if package.installed?
81+
Shards.logger.info { "Using #{package.name} (#{package.report_version})" }
6182
return
6283
end
6384

64-
Shards.logger.info "Installing #{package.name} (#{package.report_version})"
65-
package.install(version)
85+
Shards.logger.info { "Installing #{package.name} (#{package.report_version})" }
86+
package.install
6687
package
6788
end
6889

69-
private def generate_lockfile?
70-
!Shards.production? && manager.packages.any? && (!lockfile? || outdated_lockfile?)
90+
private def generate_lockfile?(packages)
91+
!Shards.production? && !packages.empty? && (!lockfile? || outdated_lockfile?(packages))
7192
end
7293

73-
private def outdated_lockfile?
74-
locks.size != manager.packages.size
94+
private def outdated_lockfile?(packages)
95+
a = packages.map { |x| {x.name, x.version, x.commit} }
96+
b = locks.map { |x| {x.name, x["version"]?, x["commit"]?} }
97+
a != b
7598
end
7699
end
77100
end

0 commit comments

Comments
 (0)