diff --git a/.gitignore b/.gitignore
index e50b04af6..8e4ba06b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
/.crystal
/.shards
-/lib
/bin/shards
/bin/shards.dwarf
/bin/shards.exe
diff --git a/Makefile b/Makefile
index 48c744726..560d31086 100644
--- a/Makefile
+++ b/Makefile
@@ -46,9 +46,6 @@ BINDIR ?= $(DESTDIR)$(PREFIX)/bin
MANDIR ?= $(DESTDIR)$(PREFIX)/share/man
INSTALL ?= /usr/bin/install
-MOLINILLO_VERSION = $(shell $(CRYSTAL) eval 'require "yaml"; puts YAML.parse(File.read("shard.lock"))["shards"]["molinillo"]["version"]')
-MOLINILLO_URL = "https://github.com/crystal-lang/crystal-molinillo/archive/v$(MOLINILLO_VERSION).tar.gz"
-
# MSYS2 support (native Windows should use `Makefile.win` instead)
ifeq ($(OS),Windows_NT)
EXE := .exe
@@ -71,7 +68,7 @@ clean: ## Remove build artifacts
clean: clean_docs
rm -f bin/shards$(EXE)
-bin/shards$(EXE): $(SOURCES) $(TEMPLATES) lib
+bin/shards$(EXE): $(SOURCES) $(TEMPLATES)
@mkdir -p bin
$(EXPORTS) $(CRYSTAL) build $(FLAGS) src/shards.cr -o "$@"
@@ -96,7 +93,7 @@ test: test_unit test_integration
.PHONY: test_unit
test_unit: ## Run unit tests
-test_unit: lib
+test_unit:
$(CRYSTAL) spec ./spec/unit/ $(if $(skip_fossil),--tag ~fossil) $(if $(skip_git),--tag ~git) $(if $(skip_hg),--tag ~hg)
.PHONY: test_integration
@@ -104,13 +101,6 @@ test_integration: ## Run integration tests
test_integration: bin/shards$(EXE)
$(CRYSTAL) spec ./spec/integration/
-lib: shard.lock
- mkdir -p lib/molinillo
- $(SHARDS) install || (curl -L $(MOLINILLO_URL) | tar -xzf - -C lib/molinillo --strip-components=1)
-
-shard.lock: shard.yml
- ([ $(SHARDS) = false ] && touch $@) || $(SHARDS) update
-
man/%.gz: man/%
gzip -c -9 $< > $@
diff --git a/Makefile.win b/Makefile.win
index f5900d114..b737ec304 100644
--- a/Makefile.win
+++ b/Makefile.win
@@ -48,9 +48,6 @@ SHARDS_CONFIG_BUILD_COMMIT := $(shell git rev-parse --short HEAD)
SOURCE_DATE_EPOCH := $(shell git show -s --format=%ct HEAD)
export_vars = $(eval export SHARDS_CONFIG_BUILD_COMMIT SOURCE_DATE_EPOCH)
-MOLINILLO_VERSION = $(shell $(CRYSTAL) eval 'require "yaml"; puts YAML.parse(File.read("shard.lock"))["shards"]["molinillo"]["version"]')
-MOLINILLO_URL = "https://github.com/crystal-lang/crystal-molinillo/archive/v$(MOLINILLO_VERSION).tar.gz"
-
prefix ?= $(or $(ProgramW6432),$(ProgramFiles))\crystal## Install path prefix
BINDIR ?= $(prefix)
@@ -65,7 +62,7 @@ clean: ## Remove build artifacts
clean:
$(call RM,"bin\shards.exe")
-bin\shards.exe: $(SOURCES) $(TEMPLATES) lib
+bin\shards.exe: $(SOURCES) $(TEMPLATES)
@$(call MKDIR,"bin")
$(call export_vars)
$(CRYSTAL) build $(FLAGS) -o bin\shards.exe src\shards.cr
@@ -89,7 +86,6 @@ test: test_unit test_integration
.PHONY: test_unit
test_unit: ## Run unit tests
-test_unit: lib
$(CRYSTAL) spec $(if $(skip_fossil),--tag ~fossil )$(if $(skip_git),--tag ~git )$(if $(skip_hg),--tag ~hg ).\spec\unit
.PHONY: test_integration
@@ -97,13 +93,6 @@ test_integration: ## Run integration tests
test_integration: bin\shards.exe
$(CRYSTAL) spec .\spec\integration
-lib: shard.lock
- $(call MKDIR,"lib\molinillo")
- $(SHARDS) install || (curl -L $(MOLINILLO_URL) | tar -xzf - -C lib\molinillo --strip-components=1)
-
-shard.lock: shard.yml
- if not "$(SHARDS)" == "false" $(SHARDS) update
-
.PHONY: help
help: ## Show this help
@setlocal EnableDelayedExpansion &\
diff --git a/README.md b/README.md
index 5a05c30a9..385c1e71e 100644
--- a/README.md
+++ b/README.md
@@ -67,13 +67,6 @@ These requirements are only necessary for compiling Shards.
Please refer to for
instructions for your operating system.
-* `molinillo`
-
- The shard `molinillo` needs to be in the Crystal path.
- It is available at
- You can install it either with a pre-existing `shards` binary (running `shards install`)
- or just check out the repository at `lib/crystal-molinillo` (`make lib`).
-
* libyaml
On Debian/Ubuntu Linux you may install the `libyaml-dev` package.
@@ -90,9 +83,7 @@ These requirements are only necessary for compiling Shards.
### Getting started
It is strongly recommended to use `make` for building shards and developing it.
-The [`Makefile`](./Makefile) contains recipes for compiling and testing. Building
-with `make` also ensures the source dependency `molinillo` is installed. You don't
-need to take care of this yourself.
+The [`Makefile`](./Makefile) contains recipes for compiling and testing.
Run `make bin/shards` to build the binary.
* `release=1` for a release build (applies optimizations)
diff --git a/lib/.shards.info b/lib/.shards.info
new file mode 100644
index 000000000..c0d0af758
--- /dev/null
+++ b/lib/.shards.info
@@ -0,0 +1,6 @@
+---
+version: 1.0
+shards:
+ molinillo:
+ git: https://github.com/crystal-lang/crystal-molinillo.git
+ version: 0.2.0
diff --git a/lib/molinillo/.editorconfig b/lib/molinillo/.editorconfig
new file mode 100644
index 000000000..163eb75c8
--- /dev/null
+++ b/lib/molinillo/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*.cr]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
diff --git a/lib/molinillo/.github/workflows/crystal.yml b/lib/molinillo/.github/workflows/crystal.yml
new file mode 100644
index 000000000..49c8d4f61
--- /dev/null
+++ b/lib/molinillo/.github/workflows/crystal.yml
@@ -0,0 +1,18 @@
+name: Crystal CI
+
+on: [push]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ container:
+ image: crystallang/crystal
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Install dependencies
+ run: shards install
+ - name: Run tests
+ run: crystal spec
diff --git a/lib/molinillo/.gitignore b/lib/molinillo/.gitignore
new file mode 100644
index 000000000..0bbd4a9f4
--- /dev/null
+++ b/lib/molinillo/.gitignore
@@ -0,0 +1,9 @@
+/docs/
+/lib/
+/bin/
+/.shards/
+*.dwarf
+
+# Libraries don't need dependency lock
+# Dependencies will be locked in applications that use them
+/shard.lock
diff --git a/lib/molinillo/.gitmodules b/lib/molinillo/.gitmodules
new file mode 100644
index 000000000..39c50c56a
--- /dev/null
+++ b/lib/molinillo/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "spec/fixture"]
+ path = spec/fixture
+ url = https://github.com/CocoaPods/Resolver-Integration-Specs
diff --git a/lib/molinillo/.travis.yml b/lib/molinillo/.travis.yml
new file mode 100644
index 000000000..765f0e9cd
--- /dev/null
+++ b/lib/molinillo/.travis.yml
@@ -0,0 +1,6 @@
+language: crystal
+
+# Uncomment the following if you'd like Travis to run specs and check code formatting
+# script:
+# - crystal spec
+# - crystal tool format --check
diff --git a/lib/molinillo/LICENSE b/lib/molinillo/LICENSE
new file mode 100644
index 000000000..b977a143d
--- /dev/null
+++ b/lib/molinillo/LICENSE
@@ -0,0 +1,22 @@
+This project is licensed under the MIT license.
+
+Copyright (c) 2020 Manas Technology Solutions
+Copyright (c) 2014 Samuel E. Giddins segiddins@segiddins.me
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/molinillo/README.md b/lib/molinillo/README.md
new file mode 100644
index 000000000..8e09c51ed
--- /dev/null
+++ b/lib/molinillo/README.md
@@ -0,0 +1,43 @@
+# crystal-molinillo
+
+A port of [Molinillo](https://github.com/CocoaPods/Molinillo/) (generic dependency resolution algorithm) to [Crystal](https://crystal-lang.org)
+
+## Installation
+
+1. Add the dependency to your `shard.yml`:
+
+ ```yaml
+ dependencies:
+ molinillo:
+ github: crystal-lang/crystal-molinillo
+ ```
+
+2. Run `shards install`
+
+## Usage
+
+```crystal
+require "molinillo"
+```
+
+This was built to be used by [Shards](https://github.com/crystal-lang/shards). Check [`MolinilloSolver`](https://github.com/crystal-lang/shards/blob/master/src/molinillo_solver.cr) for an example of integration.
+
+## Development
+
+This code uses a subrepository with test fixtures. Make sure you clone the repository with `--recursive` before running tests:
+
+```
+git clone --recursive https://github.com/crystal-lang/crystal-molinillo
+```
+
+## Contributing
+
+1. Fork it ()
+2. Create your feature branch (`git checkout -b my-new-feature`)
+3. Commit your changes (`git commit -am 'Add some feature'`)
+4. Push to the branch (`git push origin my-new-feature`)
+5. Create a new Pull Request
+
+## Contributors
+
+- [Juan Wajnerman](https://github.com/waj) - creator and maintainer
diff --git a/lib/molinillo/lib b/lib/molinillo/lib
new file mode 120000
index 000000000..a96aa0ea9
--- /dev/null
+++ b/lib/molinillo/lib
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/lib/molinillo/shard.yml b/lib/molinillo/shard.yml
new file mode 100644
index 000000000..11b09219c
--- /dev/null
+++ b/lib/molinillo/shard.yml
@@ -0,0 +1,9 @@
+name: molinillo
+version: 0.2.0
+
+authors:
+ - Juan Wajnerman
+
+crystal: ">= 0.35.0, < 2.0.0"
+
+license: MIT
diff --git a/lib/molinillo/spec/dependency_graph/log_spec.cr b/lib/molinillo/spec/dependency_graph/log_spec.cr
new file mode 100644
index 000000000..0f102df81
--- /dev/null
+++ b/lib/molinillo/spec/dependency_graph/log_spec.cr
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "../spec_helper"
+
+alias DG = Molinillo::DependencyGraph(Int32, Int32)
+
+def shared_examples_for_replay(prepare)
+ it "replays the log" do
+ copy = DG.new
+ graph = DG.new.tap { |g| prepare.call(g) }
+ graph.log.each &.up(copy)
+ copy.should eq(graph)
+ end
+
+ it "can undo to an empty graph" do
+ graph = DG.new
+ tag = Reference.new
+ graph.tag(tag)
+ prepare.call(graph)
+ graph.rewind_to(tag)
+ graph.should eq(DG.new)
+ end
+end
+
+describe Molinillo::DependencyGraph::Log do
+ describe "with empty log" do
+ shared_examples_for_replay ->(g : DG) {}
+ end
+
+ describe "with some graph" do
+ shared_examples_for_replay ->(g : DG) do
+ g.add_child_vertex("Foo", 1, [nil] of String?, 4)
+ g.add_child_vertex("Bar", 2, ["Foo", nil], 3)
+ g.add_child_vertex("Baz", 3, %w(Foo Bar), 2)
+ g.add_child_vertex("Foo", 4, [] of String?, 1)
+ end
+ end
+end
diff --git a/lib/molinillo/spec/dependency_graph_spec.cr b/lib/molinillo/spec/dependency_graph_spec.cr
new file mode 100644
index 000000000..1bb871b82
--- /dev/null
+++ b/lib/molinillo/spec/dependency_graph_spec.cr
@@ -0,0 +1,76 @@
+require "./spec_helper"
+
+private def test_dependency_graph
+ graph = Molinillo::DependencyGraph(String, String).new
+ root = graph.add_vertex("Root", "Root", true)
+ root2 = graph.add_vertex("Root2", "Root2", true)
+ child = graph.add_child_vertex("Child", "Child", %w(Root), "Child")
+ {graph: graph, root: root, root2: root2, child: child}
+end
+
+describe Molinillo::DependencyGraph do
+ describe "in general" do
+ it "returns root vertices by name" do
+ data = test_dependency_graph
+ data[:graph].root_vertex_named("Root").should eq(data[:root])
+ end
+
+ it "returns vertices by name" do
+ data = test_dependency_graph
+ data[:graph].vertex_named("Root").should eq(data[:root])
+ data[:graph].vertex_named("Child").should eq(data[:child])
+ end
+
+ it "returns nil for non-existent root vertices" do
+ data = test_dependency_graph
+ data[:graph].root_vertex_named("missing").should be_nil
+ end
+
+ it "returns nil for non-existent vertices" do
+ data = test_dependency_graph
+ data[:graph].vertex_named("missing").should be_nil
+ end
+ end
+
+ describe "detaching a vertex" do
+ it "detaches a root vertex without successors" do
+ graph = Molinillo::DependencyGraph(String, String).new
+ root = graph.add_vertex("root", "root", true)
+ graph.detach_vertex_named(root.name)
+ graph.vertex_named(root.name).should be_nil
+ graph.vertices.should be_empty
+ end
+
+ it "detaches a root vertex with successors" do
+ graph = Molinillo::DependencyGraph(String, String).new
+ root = graph.add_vertex("root", "root", true)
+ child = graph.add_child_vertex("child", "child", %w(root), "child")
+ graph.detach_vertex_named(root.name)
+ graph.vertex_named(root.name).should be_nil
+ graph.vertex_named(child.name).should be_nil
+ graph.vertices.should be_empty
+ end
+
+ it "detaches a root vertex with successors with other parents" do
+ graph = Molinillo::DependencyGraph(String, String).new
+ root = graph.add_vertex("root", "root", true)
+ root2 = graph.add_vertex("root2", "root2", true)
+ child = graph.add_child_vertex("child", "child", %w(root root2), "child")
+ graph.detach_vertex_named(root.name)
+ graph.vertex_named(root.name).should be_nil
+ graph.vertex_named(child.name).should eq(child)
+ child.predecessors.should eq([root2])
+ graph.vertices.size.should eq(2)
+ end
+
+ it "detaches a vertex with predecessors" do
+ graph = Molinillo::DependencyGraph(String, String).new
+ parent = graph.add_vertex("parent", "parent", true)
+ child = graph.add_child_vertex("child", "child", %w(parent), "child")
+ graph.detach_vertex_named(child.name)
+ graph.vertex_named(child.name).should be_nil
+ graph.vertices.should eq({parent.name => parent})
+ parent.outgoing_edges.should be_empty
+ end
+ end
+end
diff --git a/lib/molinillo/spec/resolver_spec.cr b/lib/molinillo/spec/resolver_spec.cr
new file mode 100644
index 000000000..7c5a5fd06
--- /dev/null
+++ b/lib/molinillo/spec/resolver_spec.cr
@@ -0,0 +1,115 @@
+require "./spec_helper"
+
+module Molinillo
+ FIXTURE_CASE_DIR = FIXTURE_DIR / "case"
+
+ class TestCase
+ getter fixture : Fixture
+ getter name : String
+ @index : SpecificationProvider(Gem::Dependency | TestSpecification, TestSpecification)?
+ @requested : Array(Gem::Dependency | TestSpecification)?
+ @result : DependencyGraph(TestSpecification?, TestSpecification?)?
+ @conflicts : Set(String)?
+ @@all : Array(TestCase)?
+
+ def self.from_fixture(fixture_path)
+ fixture = File.open(fixture_path) { |f| Fixture.from_json(f) }
+ new(fixture)
+ end
+
+ def initialize(@fixture)
+ @name = fixture.name
+ end
+
+ def index
+ @index ||= TestIndex.from_fixture(@fixture.index || "awesome")
+ end
+
+ def requested
+ @requested ||= @fixture.requested.map do |(name, reqs)|
+ Gem::Dependency.new(name.delete("\x01"), reqs.split(',').map(&.chomp)).as(Gem::Dependency | TestSpecification)
+ end
+ end
+
+ def add_dependencies_to_graph(graph : DependencyGraph(P, P), parent, hash, all_parents = Set(DependencyGraph::Vertex(P, P)).new) forall P
+ name = hash.name
+ version = hash.version # Gem::Version.new(hash['version'])
+ dependency = index.specs[name].find { |s| Shards::Versions.compare(s.version, version) == 0 }.not_nil!
+ vertex = if parent
+ graph.add_vertex(name, dependency).tap do |v|
+ graph.add_edge(parent, v, dependency)
+ end
+ else
+ graph.add_vertex(name, dependency, true)
+ end
+ return unless all_parents.add?(vertex)
+ hash.dependencies.each do |dep|
+ add_dependencies_to_graph(graph, vertex, dep, all_parents)
+ end
+ end
+
+ def result
+ @result ||= @fixture.resolved.reduce(DependencyGraph(TestSpecification?, TestSpecification?).new) do |graph, r|
+ graph.tap do |g|
+ add_dependencies_to_graph(g, nil, r)
+ end
+ end
+ end
+
+ def base
+ @fixture.base.reduce(DependencyGraph(Gem::Dependency | TestSpecification, Gem::Dependency | TestSpecification).new) do |graph, r|
+ graph.tap do |g|
+ add_dependencies_to_graph(g, nil, r)
+ end
+ end
+ end
+
+ def conflicts
+ @conflicts ||= @fixture.conflicts.to_set
+ end
+
+ def self.all
+ @@all ||= Dir.glob(FIXTURE_CASE_DIR.to_s + "**/*.json").map { |fixture| TestCase.from_fixture(fixture) }
+ end
+
+ def resolve(index_class)
+ index = index_class.new(self.index.specs)
+ resolver = Resolver(Gem::Dependency | TestSpecification, TestSpecification).new(index, TestUI.new)
+ resolver.resolve(requested, base)
+ end
+
+ def run(index_class)
+ it name do
+ # skip 'does not yet reliably pass' if test_case.ignore?(index_class)
+ if fixture.conflicts.any?
+ error = expect_raises(ResolverError) { resolve(index_class) }
+ names = case error
+ when CircularDependencyError
+ error.vertices.map &.name
+ when VersionConflict
+ error.conflicts.keys
+ else
+ fail "Unexpected error type: #{error}"
+ end.to_set
+ names.should eq(self.conflicts)
+ else
+ result = resolve(index_class)
+
+ result.should eq(self.result)
+ end
+ end
+ end
+ end
+
+ describe Resolver do
+ describe "dependency resolution" do
+ describe "with the TestIndex index" do
+ TestCase.all.each &.run(TestIndex)
+ end
+ end
+ end
+end
+
+# it "list all cases" do
+# pp Molinillo::TestCase.all
+# end
diff --git a/lib/molinillo/spec/spec_helper.cr b/lib/molinillo/spec/spec_helper.cr
new file mode 100644
index 000000000..3fd059910
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper.cr
@@ -0,0 +1,5 @@
+require "spec"
+require "../src/molinillo"
+require "./spec_helper/*"
+
+FIXTURE_DIR = Path.new("spec/fixture")
diff --git a/lib/molinillo/spec/spec_helper/fixture.cr b/lib/molinillo/spec/spec_helper/fixture.cr
new file mode 100644
index 000000000..364f85a38
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/fixture.cr
@@ -0,0 +1,20 @@
+require "json"
+
+class Molinillo::Fixture
+ include JSON::Serializable
+
+ property name : String
+ property index : String?
+ property requested : Hash(String, String)
+ property base : Array(Dependency)
+ property resolved : Array(Dependency)
+ property conflicts : Array(String)
+end
+
+class Molinillo::Fixture::Dependency
+ include JSON::Serializable
+
+ property name : String
+ property version : String
+ property dependencies : Array(Dependency)
+end
diff --git a/lib/molinillo/spec/spec_helper/gem.cr b/lib/molinillo/spec/spec_helper/gem.cr
new file mode 100644
index 000000000..3bee2ecdd
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/gem.cr
@@ -0,0 +1,41 @@
+module Gem
+ class Dependency
+ property name : String
+ property requirement : Requirement
+
+ def initialize(@name, requirements : Array(String))
+ @requirement = Requirement.new(requirements)
+ end
+
+ def prerelease?
+ requirement.prerelease?
+ end
+
+ def to_s(io)
+ io << name
+ end
+ end
+
+ class Requirement
+ property requirements : Array(String)
+
+ def initialize(@requirements)
+ end
+
+ def satisfied_by?(version : String)
+ requirements.all? do |req|
+ Shards::Versions.matches?(version, req)
+ end
+ end
+
+ def prerelease?
+ requirements.any? { |r| Shards::Versions.prerelease?(r) }
+ end
+
+ def inspect(io)
+ io << '"'
+ io << requirements.join ", "
+ io << '"'
+ end
+ end
+end
diff --git a/lib/molinillo/spec/spec_helper/index.cr b/lib/molinillo/spec/spec_helper/index.cr
new file mode 100644
index 000000000..bdd827e0f
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/index.cr
@@ -0,0 +1,64 @@
+require "./specification"
+
+module Molinillo
+ FIXTURE_INDEX_DIR = FIXTURE_DIR / "index"
+
+ class TestIndex
+ getter specs : Hash(String, Array(TestSpecification))
+ include SpecificationProvider(Gem::Dependency | TestSpecification, TestSpecification)
+
+ def self.from_fixture(fixture_name)
+ new(TestIndex.specs_from_fixture(fixture_name))
+ end
+
+ @@specs_from_fixture = {} of String => Hash(String, Array(TestSpecification))
+
+ def self.specs_from_fixture(fixture_name)
+ @@specs_from_fixture[fixture_name] ||= begin
+ lines = File.read_lines(FIXTURE_INDEX_DIR / (fixture_name + ".json"))
+ lines = lines.map { |line| line.partition("//")[0] }
+ Hash(String, Array(TestSpecification)).from_json(lines.join '\n').tap do |all_specs|
+ all_specs.each do |name, specs|
+ specs.sort! { |a, b| Shards::Versions.compare(b.version, a.version) }
+ end
+ end
+ end
+ end
+
+ def initialize(@specs)
+ end
+
+ def requirement_satisfied_by?(requirement, activated, spec)
+ if Shards::Versions.prerelease?(spec.version) && !requirement.prerelease?
+ vertex = activated.vertex_named!(spec.name)
+ return false if vertex.requirements.none?(&.prerelease?)
+ end
+
+ case requirement
+ when TestSpecification
+ requirement.version == spec.version
+ when Gem::Dependency
+ requirement.requirement.satisfied_by?(spec.version)
+ end
+ end
+
+ def search_for(dependency : R)
+ case dependency
+ when Gem::Dependency
+ specs.fetch(dependency.name) { Array(TestSpecification).new }.select do |spec|
+ dependency.requirement.satisfied_by?(spec.version)
+ end
+ else
+ raise "BUG: Unexpected dependency type: #{dependency}"
+ end
+ end
+
+ def name_for(dependency)
+ dependency.name
+ end
+
+ def dependencies_for(specification : S)
+ specification.dependencies
+ end
+ end
+end
diff --git a/lib/molinillo/spec/spec_helper/specification.cr b/lib/molinillo/spec/spec_helper/specification.cr
new file mode 100644
index 000000000..b2e6b1950
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/specification.cr
@@ -0,0 +1,44 @@
+require "json"
+
+module Molinillo
+ class TestSpecification
+ include JSON::Serializable
+
+ property name : String
+ property version : String
+ @[JSON::Field(converter: Molinillo::DepConverter)]
+ property dependencies : Array(Gem::Dependency | TestSpecification)
+
+ def to_s(io)
+ io << "#{name} (#{version})"
+ end
+
+ def prerelease?
+ Shards::Versions.prerelease?(version)
+ end
+ end
+
+ module DepConverter
+ def self.from_json(parser)
+ deps =
+ if parser.kind.begin_object?
+ Hash(String, String).new(parser)
+ else
+ Hash(String, String).new.tap do |deps|
+ parser.read_array do
+ parser.read_begin_array
+ key = parser.read_string
+ value = parser.read_string
+ parser.read_end_array
+ deps[key] = value
+ end
+ end
+ end
+
+ deps.map do |name, requirement|
+ requirements = requirement.split(',').map(&.chomp)
+ Gem::Dependency.new(name, requirements).as(Gem::Dependency | TestSpecification)
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/spec/spec_helper/ui.cr b/lib/molinillo/spec/spec_helper/ui.cr
new file mode 100644
index 000000000..4ca1bcfd8
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/ui.cr
@@ -0,0 +1,15 @@
+module Molinillo
+ class TestUI
+ include UI
+
+ @output : IO?
+
+ def output
+ @output ||= if debug?
+ STDERR
+ else
+ File.open("/dev/null", "w")
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/spec/spec_helper/versions.cr b/lib/molinillo/spec/spec_helper/versions.cr
new file mode 100644
index 000000000..f209e7e96
--- /dev/null
+++ b/lib/molinillo/spec/spec_helper/versions.cr
@@ -0,0 +1,232 @@
+module Shards
+ module Versions
+ # :nodoc:
+ struct Segment
+ NON_ALPHANUMERIC = /[^a-zA-Z0-9]/
+ NATURAL_SORT_EXTRACT_NEXT_CHARS_AND_DIGITS = /^(\D*)(\d*)(.*)$/
+
+ protected getter! segment : String
+
+ def initialize(@str : String)
+ if index = @str.index('+')
+ @str = @str[0...index]
+ end
+ end
+
+ def next
+ @segment, _, @str = @str.partition(NON_ALPHANUMERIC)
+ segment
+ end
+
+ def empty?
+ segment.empty?
+ end
+
+ def to_i?
+ segment.to_i?(whitespace: false)
+ end
+
+ def <=>(b : self)
+ natural_sort(segment, b.segment)
+ end
+
+ # Original natural sorting algorithm from:
+ # https://github.com/sourcefrog/natsort/blob/master/natcmp.rb
+ # Copyright (C) 2003 by Alan Davies .
+ private def natural_sort(a, b)
+ if (a_num = a.to_i?(whitespace: false)) && (b_num = b.to_i?(whitespace: false))
+ return a_num <=> b_num
+ end
+
+ loop do
+ return 0 if a.empty? && b.empty?
+
+ a =~ NATURAL_SORT_EXTRACT_NEXT_CHARS_AND_DIGITS
+ a_chars, a_digits, a = $1, $2, $3
+
+ b =~ NATURAL_SORT_EXTRACT_NEXT_CHARS_AND_DIGITS
+ b_chars, b_digits, b = $1, $2, $3
+
+ ret = a_chars <=> b_chars
+ return ret unless ret == 0
+
+ a_num = a_digits.to_i?(whitespace: false)
+ b_num = b_digits.to_i?(whitespace: false)
+
+ if a_num && b_num
+ ret = a_num.to_i <=> b_num.to_i
+ return ret unless ret == 0
+ else
+ ret = a_digits <=> b_digits
+ return ret unless ret == 0
+ end
+ end
+ end
+
+ def only_zeroes?
+ return if empty?
+ yield unless to_i? == 0
+
+ loop do
+ self.next
+
+ return if empty?
+ yield unless to_i? == 0
+ end
+ end
+
+ def prerelease?
+ segment.each_char.any?(&.ascii_letter?)
+ end
+
+ def inspect(io)
+ @segment.inspect(io)
+ end
+ end
+
+ def self.sort(versions)
+ versions.sort { |a, b| compare(a, b) }
+ end
+
+ def self.compare(a, b)
+ if a == b
+ return 0
+ end
+
+ a_segment = Segment.new(a)
+ b_segment = Segment.new(b)
+
+ loop do
+ # extract next segment from version number ("1.0.2" => "1" then "0" then "2"):
+ a_segment.next
+ b_segment.next
+
+ # accept unbalanced version numbers ("1.0" == "1.0.0.0", "1.0" < "1.0.1")
+ if a_segment.empty?
+ b_segment.only_zeroes? { return b_segment.prerelease? ? -1 : 1 }
+ return 0
+ end
+
+ # accept unbalanced version numbers ("1.0.0.0" == "1.0", "1.0.1" > "1.0")
+ if b_segment.empty?
+ a_segment.only_zeroes? { return a_segment.prerelease? ? 1 : -1 }
+ return 0
+ end
+
+ # try to convert segments to numbers:
+ a_num = a_segment.to_i?
+ b_num = b_segment.to_i?
+
+ ret =
+ if a_num && b_num
+ # compare numbers (for natural 1, 2, ..., 10, 11 ordering):
+ b_num <=> a_num
+ elsif a_num
+ # b is preliminary version:
+ a_segment.only_zeroes? do
+ return b_segment <=> a_segment if a_segment.prerelease?
+ return -1
+ end
+ return -1
+ elsif b_num
+ # a is preliminary version:
+ b_segment.only_zeroes? do
+ return b_segment <=> a_segment if b_segment.prerelease?
+ return 1
+ end
+ return 1
+ else
+ # compare strings:
+ b_segment <=> a_segment
+ end
+
+ # if different return the result (older or newer), otherwise continue
+ # to the next segment:
+ return ret unless ret == 0
+ end
+ end
+
+ def self.prerelease?(str)
+ str.each_char do |char|
+ return true if char.ascii_letter?
+ break if char == '+'
+ end
+ false
+ end
+
+ protected def self.without_prereleases(versions)
+ versions.reject { |v| prerelease?(v) }
+ end
+
+ def self.resolve(versions, requirements : Enumerable(String), prereleases = false)
+ unless prereleases || requirements.any? { |r| prerelease?(r) }
+ versions = without_prereleases(versions)
+ end
+
+ matching_versions = requirements
+ .map { |requirement| resolve(versions, requirement) }
+ .reduce(versions) { |a, e| a & e }
+
+ sort(matching_versions)
+ end
+
+ def self.resolve(versions, requirement : String)
+ case requirement
+ when "*", ""
+ versions
+ when /~>\s*([^\s]+)/
+ ver = if idx = $1.rindex('.')
+ $1[0...idx]
+ else
+ $1
+ end
+ versions.select { |version| matches_approximate?(version, $1, ver) }
+ when /\s*(~>|>=|<=|>|<|=)\s*([^~<>=\s]+)\s*/
+ versions.select { |version| matches_operator?(version, $1, $2) }
+ else
+ versions.select { |version| matches_operator?(version, "=", requirement) }
+ end
+ end
+
+ def self.matches?(version : String, requirement : String)
+ case requirement
+ when "*", ""
+ true
+ when /~>\s*([^\s]+)\d*/
+ ver = if idx = $1.rindex('.')
+ $1[0...idx]
+ else
+ $1
+ end
+ matches_approximate?(version, $1, ver)
+ when /\s*(~>|>=|<=|>|<|!=|=)\s*([^~<>=\s]+)\s*/
+ matches_operator?(version, $1, $2)
+ else
+ matches_operator?(version, "=", requirement)
+ end
+ end
+
+ private def self.matches_approximate?(version, requirement, ver)
+ version.starts_with?(ver) &&
+ !version[ver.size]?.try(&.ascii_alphanumeric?) &&
+ (compare(version, requirement) <= 0)
+ end
+
+ private def self.matches_operator?(version, operator, requirement)
+ case operator
+ when ">="
+ compare(version, requirement) <= 0
+ when "<="
+ compare(version, requirement) >= 0
+ when ">"
+ compare(version, requirement) < 0
+ when "<"
+ compare(version, requirement) > 0
+ when "!="
+ compare(version, requirement) != 0
+ else
+ compare(version, requirement) == 0
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/spec/state_spec.cr b/lib/molinillo/spec/state_spec.cr
new file mode 100644
index 000000000..4592939a0
--- /dev/null
+++ b/lib/molinillo/spec/state_spec.cr
@@ -0,0 +1,29 @@
+require "./spec_helper"
+
+module Molinillo
+ describe ResolutionState do
+ describe DependencyState do
+ it "pops a possibility state" do
+ possibility1 = Resolver::Resolution::PossibilitySet(String, String).new(%w(), %w(possibility1))
+ possibility = Resolver::Resolution::PossibilitySet(String, String).new(%w(), %w(possibility))
+ state = DependencyState(String, String).new(
+ "name",
+ %w(requirement1 requirement2 requirement3),
+ DependencyGraph(Resolver::Resolution::PossibilitySet(String, String) | String | Nil, String).new,
+ "requirement",
+ [possibility1, possibility],
+ 0,
+ {} of String => Resolver::Resolution::Conflict(String, String),
+ [] of Resolver::Resolution::UnwindDetails(String, String)
+ )
+ possibility_state = state.pop_possibility_state
+ {% for attr in %w(name requirements activated requirement conflicts) %}
+ possibility_state.{{ attr.id }}.should eq(state.{{ attr.id }})
+ {% end %}
+ possibility_state.should be_a(PossibilityState(String, String))
+ possibility_state.depth.should eq(state.depth + 1)
+ possibility_state.possibilities.should eq([possibility])
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo.cr b/lib/molinillo/src/molinillo.cr
new file mode 100644
index 000000000..c2502ca4f
--- /dev/null
+++ b/lib/molinillo/src/molinillo.cr
@@ -0,0 +1,5 @@
+module Molinillo
+ VERSION = "0.2.0"
+end
+
+require "./molinillo/**"
diff --git a/lib/molinillo/src/molinillo/delegates/resolution_state.cr b/lib/molinillo/src/molinillo/delegates/resolution_state.cr
new file mode 100644
index 000000000..bbadf8b78
--- /dev/null
+++ b/lib/molinillo/src/molinillo/delegates/resolution_state.cr
@@ -0,0 +1,55 @@
+module Molinillo
+ # @!visibility private
+ module Delegates
+ # Delegates all {Molinillo::ResolutionState} methods to a `#state` property.
+ module ResolutionState(R, S)
+ # (see Molinillo::ResolutionState#name)
+ def name
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.name
+ end
+
+ # (see Molinillo::ResolutionState#requirements)
+ def requirements
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.requirements
+ end
+
+ # (see Molinillo::ResolutionState#activated)
+ def activated
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.activated
+ end
+
+ # (see Molinillo::ResolutionState#requirement)
+ def requirement
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.requirement
+ end
+
+ # (see Molinillo::ResolutionState#possibilities)
+ def possibilities
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.possibilities
+ end
+
+ # (see Molinillo::ResolutionState#depth)
+ def depth
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.depth
+ end
+
+ # (see Molinillo::ResolutionState#conflicts)
+ def conflicts
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.conflicts
+ end
+
+ # (see Molinillo::ResolutionState#unused_unwind_options)
+ def unused_unwind_options
+ current_state = state || Molinillo::ResolutionState(R, S).empty
+ current_state.unused_unwind_options
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/delegates/specification_provider.cr b/lib/molinillo/src/molinillo/delegates/specification_provider.cr
new file mode 100644
index 000000000..92deb45b5
--- /dev/null
+++ b/lib/molinillo/src/molinillo/delegates/specification_provider.cr
@@ -0,0 +1,78 @@
+module Molinillo
+ module Delegates
+ # Delegates all {Molinillo::SpecificationProvider} methods to a
+ # `#specification_provider` property.
+ module SpecificationProvider
+ # (see Molinillo::SpecificationProvider#search_for)
+ def search_for(dependency)
+ with_no_such_dependency_error_handling do
+ specification_provider.search_for(dependency)
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#dependencies_for)
+ def dependencies_for(specification)
+ with_no_such_dependency_error_handling do
+ specification_provider.dependencies_for(specification)
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#requirement_satisfied_by?)
+ def requirement_satisfied_by?(requirement, activated, spec)
+ with_no_such_dependency_error_handling do
+ specification_provider.requirement_satisfied_by?(requirement, activated, spec)
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#name_for)
+ def name_for(dependency)
+ with_no_such_dependency_error_handling do
+ specification_provider.name_for(dependency)
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#name_for_explicit_dependency_source)
+ def name_for_explicit_dependency_source
+ with_no_such_dependency_error_handling do
+ specification_provider.name_for_explicit_dependency_source
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#name_for_locking_dependency_source)
+ def name_for_locking_dependency_source
+ with_no_such_dependency_error_handling do
+ specification_provider.name_for_locking_dependency_source
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#sort_dependencies)
+ def sort_dependencies(dependencies, activated, conflicts)
+ with_no_such_dependency_error_handling do
+ specification_provider.sort_dependencies(dependencies, activated, conflicts)
+ end
+ end
+
+ # (see Molinillo::SpecificationProvider#allow_missing?)
+ def allow_missing?(dependency)
+ with_no_such_dependency_error_handling do
+ specification_provider.allow_missing?(dependency)
+ end
+ end
+
+ # Ensures any raised {NoSuchDependencyError} has its
+ # {NoSuchDependencyError#required_by} set.
+ # @yield
+ private def with_no_such_dependency_error_handling
+ yield
+ rescue error : NoSuchDependencyError
+ if state
+ # TODO
+ # vertex = activated.vertex_named(name_for(error.dependency))
+ # error.required_by += vertex.incoming_edges.map { |e| e.origin.name }
+ # error.required_by << name_for_explicit_dependency_source unless vertex.explicit_requirements.empty?
+ end
+ raise error
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph.cr b/lib/molinillo/src/molinillo/dependency_graph.cr
new file mode 100644
index 000000000..b473239b9
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph.cr
@@ -0,0 +1,190 @@
+class Molinillo::DependencyGraph(P, R)
+end
+
+require "./dependency_graph/log"
+require "./dependency_graph/vertex"
+
+class Molinillo::DependencyGraph(P, R)
+ # Enumerates through the vertices of the graph.
+ # @return [Array] The graph's vertices.
+ def each
+ # return vertices.values.each unless block_given?
+ vertices.values.each { |v| yield v }
+ end
+
+ getter log : Log(P, R)
+ getter vertices : Hash(String, Vertex(P, R))
+
+ # A directed edge of a {DependencyGraph}
+ # @attr [Vertex] origin The origin of the directed edge
+ # @attr [Vertex] destination The destination of the directed edge
+ # @attr [Object] requirement The requirement the directed edge represents
+ record Edge(P, R), origin : Vertex(P, R), destination : Vertex(P, R), requirement : R
+
+ def initialize
+ @vertices = {} of String => Vertex(P, R)
+ @log = Log(P, R).new
+ end
+
+ # Tags the current state of the dependency as the given tag
+ # @param [Object] tag an opaque tag for the current state of the graph
+ # @return [Void]
+ def tag(tag : Symbol | Reference)
+ log.tag(self, tag)
+ end
+
+ # Rewinds the graph to the state tagged as `tag`
+ # @param [Object] tag the tag to rewind to
+ # @return [Void]
+ def rewind_to(tag)
+ log.rewind_to(self, tag)
+ end
+
+ def inspect
+ "#"
+ end
+
+ def to_dot
+ dot_vertices = [] of String
+ dot_edges = [] of String
+ vertices.each do |n, v|
+ dot_vertices << " #{n} [label=\"{#{n}|#{v.payload}}\"]"
+ v.outgoing_edges.each do |e|
+ label = e.requirement
+ dot_edges << " #{e.origin.name} -> #{e.destination.name} [label=#{label.to_s.dump}]"
+ end
+ end
+
+ dot_vertices.uniq!
+ dot_vertices.sort!
+ dot_edges.uniq!
+ dot_edges.sort!
+
+ dot = dot_vertices.unshift("digraph G {").push("") + dot_edges.push("}")
+ dot.join("\n")
+ end
+
+ def ==(other)
+ super || begin
+ return false unless vertices.keys.to_set == other.vertices.keys.to_set
+ vertices.each do |name, vertex|
+ other_vertex = other.vertex_named(name)
+ return false unless other_vertex
+ return false unless vertex.payload == other_vertex.payload
+ return false unless other_vertex.successors.to_set == vertex.successors.to_set
+ end
+ true
+ end
+ end
+
+ # @param [String] name
+ # @param [Object] payload
+ # @param [Array] parent_names
+ # @param [Object] requirement the requirement that is requiring the child
+ # @return [void]
+ def add_child_vertex(name : String, payload : P, parent_names : Array(String?), requirement : R)
+ root = !(parent_names.delete(nil) || true)
+ vertex = add_vertex(name, payload, root)
+ vertex.explicit_requirements << requirement if root
+ parent_names.each do |parent_name|
+ parent_vertex = vertex_named!(parent_name)
+ add_edge(parent_vertex, vertex, requirement)
+ end
+ vertex
+ end
+
+ # Adds a vertex with the given name, or updates the existing one.
+ # @param [String] name
+ # @param [Object] payload
+ # @return [Vertex] the vertex that was added to `self`
+ def add_vertex(name : String, payload : P, root : Bool = false)
+ log.add_vertex(self, name, payload, root)
+ end
+
+ # Detaches the {#vertex_named} `name` {Vertex} from the graph, recursively
+ # removing any non-root vertices that were orphaned in the process
+ # @param [String] name
+ # @return [Array] the vertices which have been detached
+ def detach_vertex_named(name)
+ log.detach_vertex_named(self, name)
+ end
+
+ # @param [String] name
+ # @return [Vertex,nil] the vertex with the given name
+ def vertex_named(name) : Vertex(P, R)?
+ vertices[name]?
+ end
+
+ # @param [String] name
+ # @return [Vertex,nil] the vertex with the given name
+ def vertex_named!(name) : Vertex(P, R)
+ vertices[name]
+ end
+
+ # @param [String] name
+ # @return [Vertex,nil] the root vertex with the given name
+ def root_vertex_named(name) : Vertex(P, R)?
+ vertex = vertex_named(name)
+ vertex if vertex && vertex.root
+ end
+
+ # Adds a new {Edge} to the dependency graph
+ # @param [Vertex] origin
+ # @param [Vertex] destination
+ # @param [Object] requirement the requirement that this edge represents
+ # @return [Edge] the added edge
+ def add_edge(origin : Vertex(P, R), destination : Vertex(P, R), requirement : R)
+ if destination.path_to?(origin)
+ raise CircularDependencyError(P, R).new(path(destination, origin))
+ end
+ add_edge_no_circular(origin, destination, requirement)
+ end
+
+ # Sets the payload of the vertex with the given name
+ # @param [String] name the name of the vertex
+ # @param [Object] payload the payload
+ # @return [Void]
+ def set_payload(name, payload)
+ log.set_payload(self, name, payload)
+ end
+
+ # Adds a new {Edge} to the dependency graph without checking for
+ # circularity.
+ # @param (see #add_edge)
+ # @return (see #add_edge)
+ private def add_edge_no_circular(origin, destination, requirement)
+ log.add_edge_no_circular(self, origin.name, destination.name, requirement)
+ end
+
+ # Returns the path between two vertices
+ # @raise [ArgumentError] if there is no path between the vertices
+ # @param [Vertex] from
+ # @param [Vertex] to
+ # @return [Array] the shortest path from `from` to `to`
+ def path(from, to)
+ distances = Hash(String, Int32).new(vertices.size + 1)
+ distances[from.name] = 0
+ predecessors = {} of Vertex(P, R) => Vertex(P, R)
+ each do |vertex|
+ vertex.successors.each do |successor|
+ if distances[successor.name] > distances[vertex.name] + 1
+ distances[successor.name] = distances[vertex.name] + 1
+ predecessors[successor] = vertex
+ end
+ end
+ end
+
+ path = [to]
+ while before = predecessors[to]?
+ path << before
+ to = before
+ break if to == from
+ end
+
+ unless path.last == from
+ raise ArgumentError.new("There is no path from #{from.name} to #{to.name}")
+ end
+
+ path.reverse
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/action.cr b/lib/molinillo/src/molinillo/dependency_graph/action.cr
new file mode 100644
index 000000000..84e8b2079
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/action.cr
@@ -0,0 +1,21 @@
+module Molinillo
+ # An action that modifies a {DependencyGraph} that is reversible.
+ # @abstract
+ abstract class DependencyGraph::Action(P, R)
+ # Performs the action on the given graph.
+ # @param [DependencyGraph] graph the graph to perform the action on.
+ # @return [Void]
+ abstract def up(graph : DependencyGraph(P, R))
+
+ # Reverses the action on the given graph.
+ # @param [DependencyGraph] graph the graph to reverse the action on.
+ # @return [Void]
+ abstract def down(graph : DependencyGraph(P, R))
+
+ # @return [Action,Nil] The previous action
+ property previous : Action(P, R)?
+
+ # @return [Action,Nil] The next action
+ property next : Action(P, R)?
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/add_edge_no_circular.cr b/lib/molinillo/src/molinillo/dependency_graph/add_edge_no_circular.cr
new file mode 100644
index 000000000..71cce9a58
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/add_edge_no_circular.cr
@@ -0,0 +1,36 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class AddEdgeNoCircular(P, R) < Action(P, R)
+ getter origin : String
+ getter destination : String
+ getter requirement : R
+
+ def initialize(@origin : String, @destination : String, @requirement : R)
+ end
+
+ def up(graph)
+ edge = make_edge(graph)
+ edge.origin.outgoing_edges << edge
+ edge.destination.incoming_edges << edge
+ edge
+ end
+
+ def down(graph)
+ edge = make_edge(graph)
+ delete_first(edge.origin.outgoing_edges, edge)
+ delete_first(edge.destination.incoming_edges, edge)
+ end
+
+ # @param [DependencyGraph] graph the graph to find vertices from
+ # @return [Edge] The edge this action adds
+ def make_edge(graph)
+ Edge(P, R).new(graph.vertex_named!(origin), graph.vertex_named!(destination), requirement)
+ end
+
+ private def delete_first(array, item)
+ return unless index = array.index(item)
+ array.delete_at(index)
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/add_vertex.cr b/lib/molinillo/src/molinillo/dependency_graph/add_vertex.cr
new file mode 100644
index 000000000..97c58e463
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/add_vertex.cr
@@ -0,0 +1,35 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class AddVertex(P, R) < Action(P, R)
+ getter name : String
+ getter payload : P
+ getter root : Bool
+
+ @existing : {payload: P, root: Bool}?
+
+ def initialize(@name, @payload : P, @root)
+ end
+
+ def up(graph)
+ if existing = graph.vertices[name]?
+ @existing = {payload: existing.payload, root: existing.root}
+ end
+ vertex = existing || Vertex(P, R).new(name, payload)
+ graph.vertices[vertex.name] = vertex
+ vertex.payload ||= payload
+ vertex.root ||= root
+ vertex
+ end
+
+ def down(graph)
+ if existing = @existing
+ vertex = graph.vertices[name]
+ vertex.payload = existing[:payload]
+ vertex.root = existing[:root]
+ else
+ graph.vertices.delete(name)
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/delete_edge.cr b/lib/molinillo/src/molinillo/dependency_graph/delete_edge.cr
new file mode 100644
index 000000000..97fbb174c
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/delete_edge.cr
@@ -0,0 +1,33 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class DeleteEdge(P, R) < Action(P, R)
+ getter origin_name : String
+ getter destination_name : String
+ getter requirement : R
+
+ def initialize(@origin_name, @destination_name, @requirement)
+ end
+
+ def up(graph)
+ edge = make_edge(graph)
+ edge.origin.outgoing_edges.delete(edge)
+ edge.destination.incoming_edges.delete(edge)
+ end
+
+ def down(graph)
+ edge = make_edge(graph)
+ edge.origin.outgoing_edges << edge
+ edge.destination.incoming_edges << edge
+ edge
+ end
+
+ private def make_edge(graph)
+ Edge(P, R).new(
+ graph.vertex_named(origin_name),
+ graph.vertex_named(destination_name),
+ requirement
+ )
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/detach_vertex_named.cr b/lib/molinillo/src/molinillo/dependency_graph/detach_vertex_named.cr
new file mode 100644
index 000000000..a1592d966
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/detach_vertex_named.cr
@@ -0,0 +1,42 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class DetachVertexNamed(P, R) < Action(P, R)
+ getter name : String
+ @vertex : Vertex(P, R)?
+
+ def initialize(@name)
+ end
+
+ def up(graph)
+ return [] of Vertex(P, R) unless vertex = @vertex = graph.vertices.delete(name)
+
+ removed_vertices = [vertex] of Vertex(P, R)
+ vertex.outgoing_edges.each do |e|
+ v = e.destination
+ v.incoming_edges.delete(e)
+ if !v.root && v.incoming_edges.empty?
+ removed_vertices.concat graph.detach_vertex_named(v.name)
+ end
+ end
+
+ vertex.incoming_edges.each do |e|
+ v = e.origin
+ v.outgoing_edges.delete(e)
+ end
+
+ removed_vertices
+ end
+
+ def down(graph)
+ return unless vertex = @vertex
+ graph.vertices[vertex.name] = vertex
+ vertex.outgoing_edges.each do |e|
+ e.destination.incoming_edges << e
+ end
+ vertex.incoming_edges.each do |e|
+ e.origin.outgoing_edges << e
+ end
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/log.cr b/lib/molinillo/src/molinillo/dependency_graph/log.cr
new file mode 100644
index 000000000..ba10d38e7
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/log.cr
@@ -0,0 +1,85 @@
+require "./add_vertex"
+require "./add_edge_no_circular"
+
+class Molinillo::DependencyGraph::Log(P, R)
+ @current_action : Action(P, R)?
+ @first_action : Action(P, R)?
+
+ def tag(graph, tag)
+ push_action(graph, Tag(P, R).new(tag))
+ end
+
+ def add_vertex(graph, name : String, payload : P, root)
+ push_action(graph, AddVertex(P, R).new(name, payload, root))
+ end
+
+ def detach_vertex_named(graph, name)
+ push_action(graph, DetachVertexNamed(P, R).new(name))
+ end
+
+ def add_edge_no_circular(graph, origin, destination, requirement)
+ push_action(graph, AddEdgeNoCircular(P, R).new(origin, destination, requirement))
+ end
+
+ # {include:DependencyGraph#delete_edge}
+ # @param [Graph] graph the graph to perform the action on
+ # @param [String] origin_name
+ # @param [String] destination_name
+ # @param [Object] requirement
+ # @return (see DependencyGraph#delete_edge)
+ def delete_edge(graph, origin_name, destination_name, requirement)
+ push_action(graph, DeleteEdge.new(origin_name, destination_name, requirement))
+ end
+
+ # @macro action
+ def set_payload(graph, name, payload)
+ push_action(graph, SetPayload(P, R).new(name, payload))
+ end
+
+ # Pops the most recent action from the log and undoes the action
+ # @param [DependencyGraph] graph
+ # @return [Action] the action that was popped off the log
+ def pop!(graph)
+ return unless action = @current_action
+ unless @current_action = action.previous
+ @first_action = nil
+ end
+ action.down(graph)
+ action
+ end
+
+ # Enumerates each action in the log
+ # @yield [Action]
+ def each
+ action = @first_action
+ loop do
+ break unless action
+ yield action
+ action = action.next
+ end
+ self
+ end
+
+ def rewind_to(graph, tag)
+ tag_value = Tag::Value.new(tag)
+ loop do
+ action = pop!(graph)
+ raise "No tag #{tag.inspect} found" unless action
+ break if action.is_a?(Tag(P, R)) && action.tag == tag_value
+ end
+ end
+
+ # Adds the given action to the log, running the action
+ # @param [DependencyGraph] graph
+ # @param [Action] action
+ # @return The value returned by `action.up`
+ private def push_action(graph, action)
+ action.previous = @current_action
+ if current_action = @current_action
+ current_action.next = action
+ end
+ @current_action = action
+ @first_action ||= action
+ action.up(graph)
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/set_payload.cr b/lib/molinillo/src/molinillo/dependency_graph/set_payload.cr
new file mode 100644
index 000000000..d590755ce
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/set_payload.cr
@@ -0,0 +1,22 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class SetPayload(P, R) < Action(P, R)
+ getter name : String
+ getter payload : P
+ @old_payload : P?
+
+ def initialize(@name, @payload)
+ end
+
+ def up(graph)
+ vertex = graph.vertex_named!(name)
+ @old_payload = vertex.payload
+ vertex.payload = payload
+ end
+
+ def down(graph)
+ graph.vertex_named!(name).payload = @old_payload
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/tag.cr b/lib/molinillo/src/molinillo/dependency_graph/tag.cr
new file mode 100644
index 000000000..fd3252130
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/tag.cr
@@ -0,0 +1,42 @@
+require "./action"
+
+class Molinillo::DependencyGraph
+ class Tag(P, R) < Action(P, R)
+ abstract struct Value
+ def self.new(value : Reference)
+ ReferenceValue.new(value)
+ end
+
+ def self.new(value : Symbol)
+ OtherValue.new(value)
+ end
+ end
+
+ struct ReferenceValue < Value
+ @value : UInt64
+
+ def initialize(value : Reference)
+ @value = value.object_id
+ end
+ end
+
+ struct OtherValue < Value
+ @value : Symbol
+
+ def initialize(@value)
+ end
+ end
+
+ getter tag : Value
+
+ def up(graph)
+ end
+
+ def down(graph)
+ end
+
+ def initialize(tag)
+ @tag = Value.new(tag)
+ end
+ end
+end
diff --git a/lib/molinillo/src/molinillo/dependency_graph/vertex.cr b/lib/molinillo/src/molinillo/dependency_graph/vertex.cr
new file mode 100644
index 000000000..550370390
--- /dev/null
+++ b/lib/molinillo/src/molinillo/dependency_graph/vertex.cr
@@ -0,0 +1,58 @@
+class Molinillo::DependencyGraph::Vertex(P, R)
+ property root = false
+ property name : String
+ property payload : P
+ getter explicit_requirements : Array(R)
+ getter outgoing_edges : Array(Edge(P, R))
+ getter incoming_edges : Array(Edge(P, R))
+
+ def initialize(@name, @payload : P)
+ @explicit_requirements = Array(R).new
+ @outgoing_edges = Array(Edge(P, R)).new
+ @incoming_edges = Array(Edge(P, R)).new
+ end
+
+ # @return [Array