///|
/// Exit codes follow grep: 0 success, 1 a module was not found, 2 a usage
/// error or a failure such as an unreadable index.
const USAGE : String =
#|usage: mooncakes-graph [options]
#|
#|commands:
#| dependents MODULE... modules that depend on MODULE, as a tree
#| deps MODULE... modules that MODULE depends on, as a tree
#| -L N descend at most N levels
#| --noreport omit the module count
#| -J tree's JSON layout
#| versions every published version as JSONL, one per line
#| modules every module's description, repository, license and
#| keywords from its current version, as JSONL
#| stats counts and the index's fetch time, as JSON
#| top modules ranked by how much they are depended on, as TSV
#| -n N rows to print (default 20, 0 for all)
#| --by KEY direct | transitive | owners (default transitive)
#|
#|options:
#| --index DIR registry index directory (default: $MOON_HOME/registry/index)
#|
#|exit status: 0 ok, 1 module not found, 2 usage error or failure
///|
fn main {
let args = @env.args()
let code = run(args[1:]) catch {
@args.CliError(option~, message~, ..) => {
let flag = if option.is_empty() {
""
} else if option.length() == 1 {
"-\{option}: "
} else {
"--\{option}: "
}
eprintln("mooncakes-graph: \{flag}\{message}")
eprintln(USAGE)
2
}
Fatal(message) => {
eprintln("mooncakes-graph: \{message}")
2
}
e => {
eprintln("mooncakes-graph: \{e}")
2
}
}
if code != 0 {
@sys.exit(code)
}
}
///|
/// A failure with a message meant for the user.
suberror Fatal {
Fatal(String)
}
///|
fn run(args : ArrayView[String]) -> Int32 raise {
guard args is [command, .. rest] else {
eprintln(USAGE)
return 2
}
match command {
"dependents" => tree_command(Dependents, rest)
"deps" => tree_command(Deps, rest)
"versions" => versions(rest)
"modules" => modules(rest)
"stats" => stats(rest)
"top" => top(rest)
"-h" | "--help" | "help" => {
println(USAGE)
0
}
_ => {
eprintln("mooncakes-graph: unknown command '\{command}'")
eprintln(USAGE)
2
}
}
}
///|
let common_options : Array[@args.OptionSpec] = [@args.option("index")]
///|
/// The index as the commands see it.
struct Loaded {
graph : @graph.Graph
/// When `moon update` last moved the index (Unix seconds), if known.
fetched_at : Int64?
}
///|
fn load_index(parsed : @args.ParsedArgs) -> Loaded raise {
let dir = match parsed.last_value("index") {
Some(d) => d
None =>
match @index_fs.index_dir() {
Some(d) => d
None => raise Fatal("cannot locate the index: set MOON_HOME or HOME")
}
}
let index = @index_fs.read(dir) catch {
NotFound(..) =>
raise Fatal("no index at \{dir} (run `moon update` to fetch it)")
Unreadable(path~, message~) =>
raise Fatal("cannot read the index at \{path}: \{message}")
}
if index.skipped > 0 {
eprintln("mooncakes-graph: skipped \{index.skipped} unreadable index lines")
}
{ graph: @graph.Graph::new(index.modules), fetched_at: index.fetched_at, }
}
///|
fn versions(args : ArrayView[String]) -> Int32 raise {
let parsed = @args.parse(args, common_options)
if parsed.operands.length() > 0 {
raise @args.CliError(
kind=UnexpectedOptionValue,
option="",
message="versions takes no operands",
)
}
let graph = load_index(parsed).graph
let out = StringBuilder()
let mut first = true
for node in 0.. Int32 raise {
let parsed = @args.parse(args, common_options)
if parsed.operands.length() > 0 {
raise @args.CliError(
kind=UnexpectedOptionValue,
option="",
message="modules takes no operands",
)
}
let graph = load_index(parsed).graph
let out = StringBuilder()
for i in 0.. 0 {
out.write_char('\n')
}
out.write_string(Json::object(obj).stringify())
}
if graph.size() > 0 {
println(out.to_string())
}
0
}
///|
/// The index at a glance: when it was fetched and what the graph holds.
/// `edges` are the graph's (dependencies on other modules in the index);
/// `dangling_edges` are dependencies on modules missing from it.
fn stats(args : ArrayView[String]) -> Int32 raise {
let parsed = @args.parse(args, common_options)
if parsed.operands.length() > 0 {
raise usage_error("", "stats takes no operands")
}
let index = load_index(parsed)
let s = index.graph.stats()
let count = fn(n : Int32) { Json::number(n.to_double()) }
let out = Json::object(
Map([
(
"index_fetched_at",
match index.fetched_at {
Some(t) => Json::string(format_time(t))
None => Json::null()
},
),
("modules", count(s.modules)),
("versions", count(s.versions)),
("modules_with_deps", count(s.modules_with_deps)),
("edges", count(s.edges)),
("dangling_edges", count(s.dangling_edges)),
("all_yanked", count(s.all_yanked)),
]),
)
println(out.stringify(indent=2))
0
}
///|
fn usage_error(option : String, message : String) -> Error {
@args.CliError(kind=InvalidValue, option~, message~)
}
///|
fn top(args : ArrayView[String]) -> Int32 raise {
let parsed = @args.parse(
args,
[
..common_options,
@args.option("n", short='n', allow_attached=true),
@args.option("by"),
],
)
if parsed.operands.length() > 0 {
raise usage_error("", "top takes no operands")
}
let limit = match parsed.last_value("n") {
Some(v) =>
@string.parse_int(v) catch {
_ => raise usage_error("n", "not a number")
}
None => 20
}
let by : @graph.Metric = match parsed.last_value("by") {
None | Some("transitive") => Transitive
Some("direct") => Direct
Some("owners") => Owners
Some(_) => raise usage_error("by", "expected direct, transitive or owners")
}
let graph = load_index(parsed).graph
let m = graph.metrics()
let order = m.ranking(by)
let rows = if limit <= 0 || limit > order.length() {
order.length()
} else {
limit
}
let out = StringBuilder()
out.write_string("module\tdirect\ttransitive\towners")
for i in order[:rows] {
out.write_string(
"\n\{graph.name(i)}\t\{m.get(i, Direct)}\t\{m.get(i, Transitive)}\t\{m.get(i, Owners)}",
)
}
println(out.to_string())
0
}
///|
fn tree_command(
direction : @graph.Direction,
args : ArrayView[String],
) -> Int32 raise {
let parsed = @args.parse(
args,
[
..common_options,
@args.option("L", short='L', allow_attached=true),
@args.flag("noreport"),
@args.flag("J", short='J'),
],
)
if parsed.operands.is_empty() {
raise usage_error("", "expected at least one module")
}
let max_depth = match parsed.last_value("L") {
Some(v) => {
let n = @string.parse_int(v) catch {
_ => raise usage_error("L", "not a number")
}
if n < 1 {
raise usage_error("L", "must be at least 1")
}
Some(n)
}
None => None
}
let index = load_index(parsed)
let graph = index.graph
let roots = []
let mut missing = false
for name in parsed.operands {
match graph.find(name) {
Some(i) => roots.push(i)
None => {
eprintln("mooncakes-graph: \{name}: no such module")
missing = true
}
}
}
if roots.is_empty() {
return 1
}
let forest = graph.forest(roots, direction, max_depth?)
let noreport = parsed.contains("noreport")
let as_of = index.fetched_at.map(format_time)
if parsed.contains("J") {
let report = if noreport {
None
} else {
let fields : Map[String, Json] = Map([
("type", Json::string("report")),
("modules", Json::number(forest.modules.to_double())),
])
if as_of is Some(t) {
fields["index_fetched_at"] = Json::string(t)
}
Some(Json::object(fields))
}
print_raw(render_json(graph, forest, graph.metrics(), report))
} else {
let report = if noreport {
None
} else {
match as_of {
Some(t) => Some("\{module_count(forest.modules)} (index as of \{t})")
None => Some(module_count(forest.modules))
}
}
print_raw(render_text(graph, forest, report))
}
if missing {
1
} else {
0
}
}
///|
/// Prints text that already ends with a newline.
fn print_raw(text : String) -> Unit {
if text.has_suffix("\n") {
println(text[:text.length() - 1].to_owned())
} else {
println(text)
}
}
///|
/// Unix seconds as `YYYY-MM-DDTHH:MMZ`.
fn format_time(secs : Int64) -> String {
let days = (secs / 86400L).to_int()
let rem = (secs % 86400L).to_int()
// Howard Hinnant's civil_from_days.
let z = days + 719468
let era = (if z >= 0 { z } else { z - 146096 }) / 146097
let doe = z - era * 146097
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let d = doy - (153 * mp + 2) / 5 + 1
let m = if mp < 10 { mp + 3 } else { mp - 9 }
let y = yoe + era * 400 + (if m <= 2 { 1 } else { 0 })
let pad = fn(n : Int32) { if n < 10 { "0\{n}" } else { "\{n}" } }
"\{y}-\{pad(m)}-\{pad(d)}T\{pad(rem / 3600)}:\{pad(rem % 3600 / 60)}Z"
}