diff --git a/src/glimpse.gleam b/src/glimpse.gleam index 87054e2..deb48a3 100644 --- a/src/glimpse.gleam +++ b/src/glimpse.gleam @@ -2,6 +2,7 @@ import glance import gleam/dict import gleam/list import gleam/result +import glimpse/error pub type Module { /// A Module is a wrapper of a glance.Module, but maintains some additional @@ -14,15 +15,11 @@ pub type Package { /// A Package has a name and a collection of modules. Each module is named /// according to its import. A module can be converted to a filename ///(relative to the global source / directory) simply by appending '.gleam' - Package(name: String, modules: dict.Dict(String, Module)) -} - -pub type LoadError(a) { - LoadError(a) - ParseError( - glance_error: glance.Error, - module_name: String, - module_content: String, + Package( + /// The name of the package. Also names the entrypoint module. + name: String, + /// Mapping of all modules in the project, from their name to their Module instance + modules: dict.Dict(String, Module), ) } @@ -35,9 +32,9 @@ pub type LoadError(a) { pub fn load_package( package_name: String, loader: fn(String) -> Result(String, a), -) -> Result(Package, LoadError(a)) { +) -> Result(Package, error.GlimpseError(a)) { let package = Package(package_name, dict.new()) - load_module_recurse(package, [package_name], loader) + load_package_recurse(package, [package_name], loader) } /// Given an existing Gleam Module and its name, parse its imports to determine @@ -64,23 +61,23 @@ pub fn filter_new_dependencies(module: Module, package: Package) -> List(String) |> list.filter(fn(dep) { !dict.has_key(package.modules, dep) }) } -fn load_module_recurse( +fn load_package_recurse( package: Package, modules: List(String), loader: fn(String) -> Result(String, a), -) -> Result(Package, LoadError(a)) { +) -> Result(Package, error.GlimpseError(a)) { case modules { [] -> Ok(package) [module_name, ..rest] -> { case dict.has_key(package.modules, module_name) { - True -> load_module_recurse(package, rest, loader) + True -> load_package_recurse(package, rest, loader) False -> { use module_content <- result.try( - loader(module_name) |> result.map_error(LoadError), + loader(module_name) |> result.map_error(error.LoadError), ) use glance_module <- result.try( glance.module(module_content) - |> result.map_error(ParseError(_, module_name, module_content)), + |> result.map_error(error.ParseError(_, module_name, module_content)), ) let glimpse_module = load_module(glance_module, module_name) let unknown_dependencies = @@ -90,7 +87,7 @@ fn load_module_recurse( ..package, modules: dict.insert(package.modules, module_name, glimpse_module), ) - load_module_recurse( + load_package_recurse( recurse_package, list.append(unknown_dependencies, rest), loader, diff --git a/src/glimpse/error.gleam b/src/glimpse/error.gleam new file mode 100644 index 0000000..dfff75d --- /dev/null +++ b/src/glimpse/error.gleam @@ -0,0 +1,16 @@ +import glance + +pub type GlimpseError(a) { + LoadError(a) + ParseError( + glance_error: glance.Error, + module_name: String, + module_content: String, + ) + ImportError(GlimpseImportError) +} + +pub type GlimpseImportError { + CircularDependencyError(module_name: String) + MissingImportError(module_name: String) +} diff --git a/src/glimpse/internal/import_dependencies.gleam b/src/glimpse/internal/import_dependencies.gleam new file mode 100644 index 0000000..c19fe21 --- /dev/null +++ b/src/glimpse/internal/import_dependencies.gleam @@ -0,0 +1,76 @@ +import gleam/dict +import gleam/list +import gleam/result +import gleam/set +import glimpse/error + +/// Type alias for sorting dependencies. +pub type ImportGraph = + dict.Dict(String, List(String)) + +type FoldState { + FoldState(visited: set.Set(String), oldest_list_first: List(String)) +} + +/// Given a dict representing a graph mapping module names to the names of modules +/// that module imports, return a list of all modules in the graph that are reachable +/// via import from the given entry_point module. +/// +/// Any modules in the graph not reachable from the entry_point will be exculided. +/// +/// The resulting list will be ordered from leaf node to entry_point. If you process it +/// in order from head to tail, you will never encounter a module that imports a module +/// that has not already be processed. +/// +/// Returns a CircularDependencyError if there are circular depnedencies, or a NotFoundError +/// if a module imports a module that is not in the input graph. +pub fn sort_dependencies( + dependencies: ImportGraph, + entry_point: String, +) -> Result(List(String), error.GlimpseError(a)) { + sort_dependencies_recursive(dependencies, set.new(), set.new(), entry_point) + |> result.map_error(error.ImportError) + |> result.map(list.reverse) +} + +/// Perform a depth-first sorting of the graph. ancestors of the current node +/// are maintained in a set to detect cycles, and a visited set is used +/// to avoid replicated work. +/// +/// This function is NOT currently tail recursive. +fn sort_dependencies_recursive( + maybe_dag: ImportGraph, + ancestors: set.Set(String), + visited: set.Set(String), + module: String, +) -> Result(List(String), error.GlimpseImportError) { + case + set.contains(ancestors, module), + dict.get(maybe_dag, module), + set.contains(visited, module) + { + True, _, _ -> Error(error.CircularDependencyError(module)) + False, Error(_), _ -> Error(error.MissingImportError(module)) + False, Ok(_), True -> Ok([]) + False, Ok([]), False -> Ok([module]) + False, Ok(dependencies), False -> { + let next_ancestors = set.insert(ancestors, module) + list.fold(dependencies, Ok(FoldState(visited, [])), fn(state_result, dep) { + use state <- result.try(state_result) + use sort_result <- result.try(sort_dependencies_recursive( + maybe_dag, + next_ancestors, + state.visited, + dep, + )) + let next_visited = + set.from_list(sort_result) |> set.union(state.visited) + Ok(FoldState( + next_visited, + list.append(sort_result, state.oldest_list_first), + )) + }) + |> result.map(fn(state) { list.prepend(state.oldest_list_first, module) }) + } + } +} diff --git a/test/load_package_test.gleam b/test/load_package_test.gleam index 9b9ce58..17eb625 100644 --- a/test/load_package_test.gleam +++ b/test/load_package_test.gleam @@ -2,6 +2,7 @@ import glance import gleam/dict import gleeunit/should import glimpse +import glimpse/error pub fn ok_module(contents: String) -> glance.Module { glance.module(contents) @@ -90,7 +91,7 @@ import b", pub fn loader_error_test() { glimpse.load_package("main_module", fn(_mod) { Error("I am error") }) |> should.be_error - |> should.equal(glimpse.LoadError("I am error")) + |> should.equal(error.LoadError("I am error")) } fn expect_modules_equal( diff --git a/test/sort_dependencies_test.gleam b/test/sort_dependencies_test.gleam new file mode 100644 index 0000000..35003ed --- /dev/null +++ b/test/sort_dependencies_test.gleam @@ -0,0 +1,118 @@ +import gleam/dict +import gleeunit/should +import glimpse/error +import glimpse/internal/import_dependencies + +pub fn sort_empty_dependencies_test() { + let graph = dict.from_list([#("main_module", [])]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_ok + |> should.equal(["main_module"]) +} + +pub fn sort_simple_dependency_test() { + let graph = + dict.from_list([#("main_module", ["other_module"]), #("other_module", [])]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_ok + |> should.equal(["other_module", "main_module"]) +} + +pub fn sort_diamond_dependency_test() { + let graph = + dict.from_list([ + #("main_module", ["a", "b"]), + #("a", ["c"]), + #("b", ["c"]), + #("c", []), + ]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_ok + |> should.equal(["c", "a", "b", "main_module"]) +} + +pub fn sort_arbitrary_complicated_dependency_test() { + let graph = + dict.from_list([ + #("main_module", ["a"]), + #("a", ["b", "c"]), + #("b", ["d", "g"]), + #("c", ["d"]), + #("d", ["e", "f"]), + #("e", ["g"]), + #("f", ["g", "h"]), + #("g", []), + #("h", []), + ]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_ok + |> should.equal(["g", "e", "h", "f", "d", "b", "c", "a", "main_module"]) +} + +pub fn sort_complete_binary_tree_dependency_test() { + let graph = + dict.from_list([ + #("main_module", ["a"]), + #("a", ["b", "c"]), + #("b", ["d", "e"]), + #("c", ["f", "g"]), + #("d", ["h", "i"]), + #("e", ["j", "k"]), + #("f", ["l", "m"]), + #("g", ["n", "o"]), + #("h", []), + #("i", []), + #("j", []), + #("k", []), + #("l", []), + #("m", []), + #("n", []), + #("o", []), + ]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_ok + |> should.equal([ + "h", "i", "d", "j", "k", "e", "b", "l", "m", "f", "n", "o", "g", "c", "a", + "main_module", + ]) +} + +pub fn sort_circular_import_test() { + let graph = + dict.from_list([ + #("main_module", ["a"]), + #("a", ["b"]), + #("b", ["c"]), + #("c", ["a"]), + ]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_error + |> should.equal(error.ImportError(error.CircularDependencyError("a"))) +} + +pub fn sort_missing_import_test() { + let graph = + dict.from_list([ + #("main_module", ["a"]), + #("a", ["b"]), + #("b", ["c"]), + #("c", ["a"]), + ]) + + graph + |> import_dependencies.sort_dependencies("main_module") + |> should.be_error + |> should.equal(error.ImportError(error.CircularDependencyError("a"))) +}