Skip to content

Commit

Permalink
Add a function to sort import dependencies from leaf to root
Browse files Browse the repository at this point in the history
  • Loading branch information
dusty-phillips committed Sep 9, 2024
1 parent c23042c commit 872fc05
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 18 deletions.
31 changes: 14 additions & 17 deletions src/glimpse.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
)
}

Expand All @@ -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
Expand All @@ -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 =
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/glimpse/error.gleam
Original file line number Diff line number Diff line change
@@ -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)
}
76 changes: 76 additions & 0 deletions src/glimpse/internal/import_dependencies.gleam
Original file line number Diff line number Diff line change
@@ -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) })
}
}
}
3 changes: 2 additions & 1 deletion test/load_package_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
118 changes: 118 additions & 0 deletions test/sort_dependencies_test.gleam
Original file line number Diff line number Diff line change
@@ -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")))
}

0 comments on commit 872fc05

Please sign in to comment.