// moon-slice command line interface.
//
// Commands:
// slice --file F --line N --direction backward|forward [--json]
// graph --file F [--format dot]
// dead --file F
// html --file F --line N [--out report.html]
// dump --file F --line N
//
// The analysis pipeline is intentionally thin here: every decision lives in
// the library packages, and this file only parses flags and renders output.
///|
fn usage() -> Unit {
println(
"moon-slice — program slicing for MoonBit\n" +
"\n" +
"Commands:\n" +
" slice --file F --line N --direction backward|forward [--json] [--interprocedural]\n" +
" [--node ID] disambiguate several statements on the same line\n" +
" graph --file F [--format dot|json]\n" +
" callgraph --file F [--json]\n" +
" dead --file F [--json] [--format sarif] [--out FILE]\n" +
" [--baseline FILE | --write-baseline FILE] [--fail-on-findings]\n" +
" dead --dir D [--json] [--format sarif] [--out FILE] scan a tree\n" +
" [--baseline FILE | --write-baseline FILE] [--fail-on-findings]\n" +
" unused --dir D [--json] [--format sarif] [--out FILE] private functions\n" +
" [--fail-on-findings] nothing calls anymore\n" +
" path --file F --from VAR --to VAR [--json] [--format sarif]\n" +
" html --file F --line N [--out report.html] [--interprocedural] [--node ID]\n" +
" dump --file F --line N\n" +
" dump-errors / dump-cfg / dump-defuse / dump-pdg --file F\n" +
" version | help\n" +
"\n" +
"Options:\n" +
" --precise derive control dependence from post-dominators instead of the\n" +
" syntax tree. Exact for the CFG; drops the structural rule's\n" +
" over-approximations. Works with slice/graph/dead/html.\n" +
" --json machine-readable output; failures become\n" +
" {\"error\":{\"code\":..,\"message\":..}} and still exit 2.\n" +
" --format sarif (dead, unused, path) SARIF 2.1.0 for code-scanning\n" +
" uploads; `path` reports the flow as one result with the later\n" +
" statements as relatedLocations.\n" +
" --baseline FILE (dead only) hide findings listed in FILE. The file is\n" +
" the output of a previous --write-baseline run.\n" +
" --write-baseline FILE record the current findings and exit.\n" +
" --fail-on-findings (dead, unused) exit 1 when a finding remains after\n" +
" baseline filtering; output is written first.\n" +
" --dir D (dead, unused) scan every .mbt file under D. Mutually\n" +
" exclusive with --file; parse failures are reported, not fatal.\n" +
"\n" +
"Suppressing a finding: put `// moon-slice: ignore` on the line, or on its\n" +
"own line above it to cover the next line of code. Name the rules to narrow\n" +
"it: `// moon-slice: ignore[dead-store, unused-function]`. The three rule\n" +
"names are dead-store, unreachable-code and unused-function; anything else\n" +
"leaves the directive inert. Suppressed findings still count in the\n" +
"`suppressed` number (with the ones a baseline hides) and are written by\n" +
"--write-baseline, so a baseline stays a full picture.\n" +
"\n" +
"Exit codes: 0 success, 1 findings with --fail-on-findings, 2 usage error,\n" +
"unreadable input, parse error or write failure. When a line holds several\n" +
"statements, `dump --line N` lists\n" +
"their node ids; pass the wanted one with `--node ID`.\n",
)
}
///|
/// Pre-scan argv for `--json` so a failure during argument parsing still knows
/// whether the caller wants a machine-readable error.
fn prescan_json(argv : Array[String]) -> Bool {
let mut found = false
for arg in argv {
if arg == "--json" {
found = true
}
}
found
}
///|
/// Whether the invocation asked for `--json`. Failures consult this so a
/// machine consumer always gets an error object instead of prose. It is a cell
/// because the flag is parsed before the command runs, and the failure helpers
/// are shared by every command.
let json_mode : Array[Bool] = [false]
///|
let tool_version : String = "0.7.9"
///|
/// Emit a machine-readable error object when `--json` was requested, otherwise
/// the human line, then exit 2.
fn fail_code(code : String, message : String) -> Unit {
if json_mode[0] {
println(@report.to_error_json(code, message))
} else {
println("error: " + message)
}
@sys.exit(2)
}
///|
fn read_source(path : String) -> String {
@fs.read_file_to_string(path) catch {
@fs.IOError(msg) => {
fail_code("read_failed", "failed to read \{path}: \{msg}")
""
}
}
}
///|
fn known_command(command : String) -> Bool {
command == "slice" ||
command == "graph" ||
command == "compare" ||
command == "review" ||
command == "unused" ||
command == "callers" ||
command == "callgraph" ||
command == "dead" ||
command == "path" ||
command == "html" ||
command == "dump" ||
command == "dump-errors" ||
command == "dump-cfg" ||
command == "dump-defuse" ||
command == "dump-pdg"
}
///|
fn require_value(argv : Array[String], i : Int, _flag : String) -> String {
if i + 1 >= argv.length() {
fail_code("missing_value", "\{_flag} requires a value")
""
} else {
argv[i + 1]
}
}
///|
fn parse_line(value : String) -> Int {
@string.parse_int(value) catch {
_ => {
fail_code("bad_integer", "invalid integer for --line: \{value}")
0
}
}
}
///|
fn require_line(command : String, line : Int) -> Unit {
if (command == "slice" || command == "html" || command == "dump") && line <= 0 {
fail_code("missing_line", "--line is required for \{command}")
}
}
///|
fn select_target(
program : @core.Program,
line : Int32,
override_node : Int32?,
) -> Int32 {
match override_node {
Some(id) => {
match @query.node_exists(program, id) {
true => id
false => {
fail_code("unknown_node", "no node with id \{id}")
0
}
}
}
None => {
let candidates = @query.statement_candidates(program, line)
if candidates.length() == 0 {
fail_code("no_node_at_line", "no node starts at line \{line}")
}
if candidates.length() > 1 {
let ids = StringBuilder()
let mut first = true
for id in candidates {
if !first {
ids.write_char(',')
}
ids.write_string(id.to_string())
first = false
}
println(
"note: line \{line} has \{candidates.length()} statements; using node \{candidates[0]}. Pass --node ID to choose another (candidates: \{ids.to_string()})",
)
}
candidates[0]
}
}
}
///|
/// Control edges of a PDG as a set, for the `compare` command.
fn control_edge_set(pdg : @core.Pdg) -> Set[(Int32, Int32)] {
let out : Set[(Int32, Int32)] = Set([])
for edge in pdg.edges {
if edge.kind == @core.EdgeKind::Control {
out.add((edge.from, edge.to))
}
}
out
}
///|
fn pair_array_json(pairs : Array[(Int32, Int32)]) -> String {
let out = StringBuilder()
out.write_char('[')
let mut first = true
for (from, to) in pairs {
if !first {
out.write_char(',')
}
out.write_string("[")
out.write_string(from.to_string())
out.write_char(',')
out.write_string(to.to_string())
out.write_char(']')
first = false
}
out.write_char(']')
out.to_string()
}
///|
fn lines_text(lines : Array[Int32]) -> String {
let out = StringBuilder()
let mut first = true
for line in lines {
if !first {
out.write_char(',')
}
out.write_string(line.to_string())
first = false
}
out.to_string()
}
///|
fn analyze(
source : String,
name : String,
precise : Bool,
) -> (@core.Program, @core.Cfg, @core.DefUse, @core.Pdg) {
let program = @parser_adapter.parse_source(source, name)
let cfg = @cfg.build(program)
let defuse = @defuse.analyze(program, cfg)
let cdg = if precise {
@cdg.build_precise(program, cfg)
} else {
@cdg.build(program, cfg)
}
let pdg = @pdg.build(defuse, cdg)
(program, cfg, defuse, pdg)
}
///|
fn print_lines(result : @core.SliceResult) -> Unit {
let mut out = ""
let mut first = true
for line in result.lines {
if !first {
out = out + ","
}
out = out + line.to_string()
first = false
}
println(out)
}
///|
fn vars_str(vars : Array[@core.VarId]) -> String {
let mut out = ""
let mut first = true
for v in vars {
if !first {
out = out + ","
}
out = out + v.name + "#" + v.scope.to_string()
first = false
}
out
}
///|
fn edge_kind_name(kind : @core.EdgeKind) -> String {
match kind {
@core.EdgeKind::Data => "data"
@core.EdgeKind::Control => "control"
}
}
///|
fn kind_name(kind : @core.SNodeKind) -> String {
match kind {
@core.SNodeKind::Let => "Let"
@core.SNodeKind::LetMut => "LetMut"
@core.SNodeKind::Assign => "Assign"
@core.SNodeKind::If => "If"
@core.SNodeKind::While => "While"
@core.SNodeKind::For => "For"
@core.SNodeKind::Match => "Match"
@core.SNodeKind::Return => "Return"
@core.SNodeKind::Break => "Break"
@core.SNodeKind::Continue => "Continue"
@core.SNodeKind::Call => "Call"
@core.SNodeKind::Expr => "Expr"
}
}
///|
/// Baseline entries are `path:line`, so a path must compare equal regardless of
/// the separator the caller used.
fn normalize_path(path : String) -> String {
let out = StringBuilder()
for ch in path.iter() {
match ch {
'\\' => out.write_char('/')
_ => out.write_char(ch)
}
}
let joined = out.to_string()
if joined.has_prefix("./") { joined[2:].to_owned() } else { joined }
}
///|
fn baseline_key(file : String, line : Int32) -> String {
normalize_path(file) + ":" + line.to_string()
}
///|
/// Read a baseline written by `--write-baseline`. Blank lines and `#` comments
/// are ignored, so the file can carry a short header.
fn parse_baseline(text : String) -> Set[String] {
let keys : Set[String] = Set([])
for raw in text.split("\n") {
let line = raw.replace(old="\r", new="").to_owned()
if line != "" && !line.has_prefix("#") {
keys.add(normalize_path(line))
}
}
keys
}
///|
/// Drops the nodes whose line carries a matching `// moon-slice: ignore`
/// directive. The rule follows the node: a dead store and an unreachable
/// statement are different findings even when they share a line.
fn filter_suppressions(
program : @core.Program,
ids : Array[Int32],
dead : Array[Int32],
directives : @suppress.Directives,
) -> Array[Int32] {
let out : Array[Int32] = []
for id in ids {
match @query.node_by_id(program, id) {
None => out.push(id)
Some(node) => {
let rule = if dead.contains(id) { "dead-store" } else { "unreachable-code" }
if !@suppress.suppresses(directives, node.loc.start_line, rule) {
out.push(id)
}
}
}
}
out
}
///|
fn filter_baseline(
program : @core.Program,
ids : Array[Int32],
file : String,
baseline : Set[String],
) -> Array[Int32] {
let out : Array[Int32] = []
for id in ids {
match @query.node_by_id(program, id) {
None => out.push(id)
Some(node) =>
if !baseline.contains(baseline_key(file, node.loc.start_line)) {
out.push(id)
}
}
}
out
}
///|
fn write_baseline_file(
path : String,
file : String,
ids : Array[Int32],
program : @core.Program,
) -> Unit {
let out = StringBuilder()
out.write_string("# moon-slice baseline v1\n")
out.write_string("# path:line, one finding per line\n")
for id in ids {
match @query.node_by_id(program, id) {
None => ()
Some(node) => {
out.write_string(baseline_key(file, node.loc.start_line))
out.write_char('\n')
}
}
}
@fs.write_string_to_file(path, out.to_string()) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{path}: \{msg}")
}
println("wrote \{path} (\{ids.length()} finding(s))")
}
///|
fn is_mbt_file(name : String) -> Bool {
match name.find(".mbt") {
Some(index) => index == name.length() - 4
None => false
}
}
///|
fn skip_scan_dir(name : String) -> Bool {
name == ".git" ||
name == "_build" ||
name == "target" ||
name == "node_modules" ||
name == ".mooncakes"
}
///|
fn collect_mbt_files(dir : String, out : Array[String]) -> Unit {
let entries : Array[String] = @fs.read_dir(dir) catch {
@fs.IOError(_) => []
}
entries.sort_by(fn(a, b) { a.lexical_compare(b) })
for name in entries {
let path = if dir == "." { name } else { dir + "/" + name }
let directory = @fs.is_dir(path) catch {
@fs.IOError(_) => false
}
if directory {
if !skip_scan_dir(name) {
collect_mbt_files(path, out)
}
} else if is_mbt_file(name) {
out.push(path)
}
}
}
///|
fn render_findings_text(findings : Array[@report.SarifFinding]) -> String {
let out = StringBuilder()
let mut first = true
let mut previous = ""
for finding in findings {
let key = finding.file + ":" + finding.line.to_string()
if key != previous {
if !first {
out.write_char('\n')
}
out.write_string(key)
previous = key
first = false
}
}
out.to_string()
}
///|
fn write_scan_baseline(path : String, findings : Array[@report.SarifFinding]) -> Unit {
let out = StringBuilder()
out.write_string("# moon-slice baseline v1\n")
out.write_string("# path:line, one finding per line\n")
let mut previous = ""
for finding in findings {
let key = baseline_key(finding.file, finding.line)
if key != previous {
out.write_string(key)
out.write_char('\n')
previous = key
}
}
@fs.write_string_to_file(path, out.to_string()) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{path}: \{msg}")
}
println("wrote \{path} (\{findings.length()} finding(s))")
}
///|
fn read_scan_file(path : String, errors : Array[String]) -> String? {
let ok = [true]
let text = @fs.read_file_to_string(path) catch {
@fs.IOError(msg) => {
ok[0] = false
errors.push(path + ": " + msg)
""
}
}
if ok[0] { Some(text) } else { None }
}
///|
/// Scan every `.mbt` file under `dir`. Unlike the single-file path, a parse
/// failure in one file must not hide the others: failures are collected and
/// reported, and only successfully parsed files count towards `files`.
fn run_dead_dir(
dir : String,
format : String,
json : Bool,
out_path : String,
baseline_path : String,
write_baseline : String,
fail_on_findings : Bool,
) -> Unit {
let paths : Array[String] = []
let is_directory = @fs.is_dir(dir) catch {
@fs.IOError(_) => false
}
if !is_directory {
fail_code("read_failed", "not a directory: \{dir}")
}
collect_mbt_files(dir, paths)
paths.sort_by(fn(a, b) { a.lexical_compare(b) })
let baseline = if baseline_path == "" {
Set([])
} else {
parse_baseline(read_source(baseline_path))
}
let all : Array[@report.SarifFinding] = []
let errors : Array[String] = []
let mut scanned = 0
let mut inline_suppressed = 0
for path in paths {
let maybe_source = read_scan_file(path, errors)
match maybe_source {
None => ()
Some(source) => {
let directives = @suppress.parse(source)
let (program, cfg, defuse, _) = analyze(source, path, false)
if program.errors.length() > 0 {
errors.push(
path +
": " +
program.errors.length().to_string() +
" parser diagnostic(s)",
)
} else {
scanned = scanned + 1
let dead = @query.dead_nodes(program, defuse)
let unreach = @query.unreachable_nodes(program, cfg)
let ids = @query.union_sorted(dead, unreach)
for id in ids {
match @query.node_by_id(program, id) {
None => ()
Some(node) => {
let is_dead = dead.contains(id)
let rule = if is_dead { "dead-store" } else { "unreachable-code" }
if @suppress.suppresses(directives, node.loc.start_line, rule) {
inline_suppressed = inline_suppressed + 1
} else {
all.push(@report.SarifFinding::{
file: normalize_path(path),
line: node.loc.start_line,
rule,
message: if is_dead {
@report.sarif_dead_message(node)
} else {
"unreachable code: no execution path reaches this statement"
},
})
}
}
}
}
}
}
}
}
all.sort_by(fn(a, b) {
let by_file = a.file.lexical_compare(b.file)
if by_file < 0 {
-1
} else if by_file > 0 {
1
} else if a.line < b.line {
-1
} else if a.line > b.line {
1
} else {
let by_rule = a.rule.lexical_compare(b.rule)
if by_rule < 0 {
-1
} else if by_rule > 0 {
1
} else {
0
}
}
})
if write_baseline != "" {
write_scan_baseline(write_baseline, all)
return
}
let kept : Array[@report.SarifFinding] = []
let mut suppressed = inline_suppressed
for finding in all {
if baseline.contains(baseline_key(finding.file, finding.line)) {
suppressed = suppressed + 1
} else {
kept.push(finding)
}
}
let rendered = if format == "sarif" {
@report.to_sarif_findings(kept, suppressed, tool_version, scanned, errors.length())
} else if json {
@report.to_scan_json(kept, scanned, suppressed, errors)
} else {
let mut text = render_findings_text(kept)
for message in errors {
if text != "" {
text = text + "\n"
}
text = text + "# error: " + message
}
text
}
if out_path == "" {
println(rendered)
} else {
@fs.write_string_to_file(out_path, rendered) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out_path}: \{msg}")
}
println("wrote \{out_path}")
}
if fail_on_findings && kept.length() > 0 {
@sys.exit(1)
}
}
///|
fn write_unused_baseline(path : String, unused : Array[@liveness.UnusedFn]) -> Unit {
let out = StringBuilder()
out.write_string("# moon-slice baseline v1\n")
out.write_string("# path:line, one finding per line\n")
let mut previous = ""
for item in unused {
let key = baseline_key(item.path, item.line)
if key != previous {
out.write_string(key)
out.write_char('\n')
previous = key
}
}
@fs.write_string_to_file(path, out.to_string()) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{path}: \{msg}")
}
println("wrote \{path} (\{unused.length()} finding(s))")
}
///|
/// The directory a file belongs to. In MoonBit a package is one directory, so
/// this is the unit the liveness question is asked about.
fn package_key(path : String) -> String {
let normalized = normalize_path(path)
let mut last_separator = -1
let mut index = 0
for ch in normalized.iter() {
if ch == '/' {
last_separator = index
}
index = index + 1
}
if last_separator < 0 {
"."
} else {
normalized[:last_separator].to_owned()
}
}
///|
fn add_facts(
groups : Array[(String, Array[@liveness.FileFacts])],
facts : @liveness.FileFacts,
) -> Unit {
let key = package_key(facts.path)
let mut index = -1
let mut i = 0
while i < groups.length() {
if groups[i].0 == key {
index = i
}
i = i + 1
}
if index >= 0 {
groups[index].1.push(facts)
} else {
groups.push((key, [facts]))
}
}
///|
fn unused_json(
unused : Array[@liveness.UnusedFn],
packages : Int,
files : Int,
suppressed : Int,
errors : Array[String],
) -> String {
let out = StringBuilder()
out.write_string("{\"findings\":[")
let mut first = true
for item in unused {
if !first {
out.write_char(',')
}
first = false
out.write_string("{\"file\":")
out.write_string(@report.json_string(item.path))
out.write_string(",\"line\":")
out.write_string(item.line.to_string())
out.write_string(",\"name\":")
out.write_string(@report.json_string(item.name))
out.write_string("}")
}
out.write_string("],\"packages\":")
out.write_string(packages.to_string())
out.write_string(",\"files\":")
out.write_string(files.to_string())
out.write_string(",\"suppressed\":")
out.write_string(suppressed.to_string())
out.write_string(",\"errors\":[")
first = true
for message in errors {
if !first {
out.write_char(',')
}
first = false
out.write_string(@report.json_string(message))
}
out.write_string("]}")
out.to_string()
}
///|
/// Report private free functions that nothing in their package references.
///
/// The unit of analysis is the package (one directory), because that is the
/// scope a private function is visible in; a file-local answer would be wrong
/// for callers in sibling files.
fn run_unused_dir(
dir : String,
format : String,
json : Bool,
out_path : String,
baseline_path : String,
write_baseline : String,
fail_on_findings : Bool,
) -> Unit {
let paths : Array[String] = []
let is_directory = @fs.is_dir(dir) catch {
@fs.IOError(_) => false
}
if !is_directory {
fail_code("read_failed", "not a directory: \{dir}")
}
collect_mbt_files(dir, paths)
paths.sort_by(fn(a, b) { a.lexical_compare(b) })
let errors : Array[String] = []
let groups : Array[(String, Array[@liveness.FileFacts])] = []
let directives_by_file : Map[String, @suppress.Directives] = Map([])
let mut scanned = 0
for path in paths {
match read_scan_file(path, errors) {
None => ()
Some(source) => {
scanned = scanned + 1
let normalized = normalize_path(path)
directives_by_file.set(normalized, @suppress.parse(source))
add_facts(groups, @liveness.collect(normalized, source))
}
}
}
groups.sort_by(fn(a, b) { a.0.lexical_compare(b.0) })
let all : Array[@liveness.UnusedFn] = []
for group in groups {
for item in @liveness.unused_functions(group.1) {
all.push(item)
}
}
if write_baseline != "" {
write_unused_baseline(write_baseline, all)
return
}
let baseline = if baseline_path == "" {
Set([])
} else {
parse_baseline(read_source(baseline_path))
}
let unused : Array[@liveness.UnusedFn] = []
let mut suppressed = 0
for item in all {
let directives = match directives_by_file.get(item.path) {
Some(found) => found
None => @suppress.empty()
}
if baseline.contains(baseline_key(item.path, item.line)) {
suppressed = suppressed + 1
} else if @suppress.suppresses(
directives,
item.line,
"unused-function",
) {
suppressed = suppressed + 1
} else {
unused.push(item)
}
}
let findings : Array[@report.SarifFinding] = []
for item in unused {
findings.push(@report.SarifFinding::{
file: item.path,
line: item.line,
rule: "unused-function",
message: "private function `" +
item.name +
"` is never referenced in its package",
})
}
let rendered = if format == "sarif" {
@report.to_sarif_findings(
findings,
suppressed,
tool_version,
scanned,
errors.length(),
)
} else if json {
unused_json(unused, groups.length(), scanned, suppressed, errors)
} else {
let text = StringBuilder()
let mut first = true
for item in unused {
if !first {
text.write_char('\n')
}
first = false
text.write_string(item.path)
text.write_char(':')
text.write_string(item.line.to_string())
text.write_string(": ")
text.write_string(item.name)
}
let mut rendered = text.to_string()
for message in errors {
if rendered != "" {
rendered = rendered + "\n"
}
rendered = rendered + "# error: " + message
}
rendered
}
if out_path == "" {
println(rendered)
} else {
@fs.write_string_to_file(out_path, rendered) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out_path}: \{msg}")
}
println("wrote \{out_path}")
}
if fail_on_findings && unused.length() > 0 {
@sys.exit(1)
}
}
///|
fn slice_files_json(
slices : Array[@interproc.FileSlice],
errors : Array[String],
) -> String {
let out = StringBuilder()
out.write_string("{\"files\":[")
let mut first = true
for file in slices {
if !first {
out.write_char(',')
}
first = false
out.write_string("{\"file\":")
out.write_string(@report.json_string(file.path))
out.write_string(",\"nodes\":[")
out.write_string(lines_text(file.nodes))
out.write_string("],\"lines\":[")
out.write_string(lines_text(file.lines))
out.write_string("]}")
}
out.write_string("],\"errors\":[")
first = true
for message in errors {
if !first {
out.write_char(',')
}
first = false
out.write_string(@report.json_string(message))
}
out.write_string("]}")
out.to_string()
}
///|
/// Slice one file, with callees resolved across the whole `dir` tree.
///
/// `--interprocedural` decides whether callee bodies are pulled in; without it
/// this is the same slice as `slice --file`, only reported per file so the
/// caller never has to guess which file a line number belongs to.
fn run_slice_package(
dir : String,
file : String,
line : Int32,
override_node : Int32?,
direction : String,
interproc : Bool,
precise : Bool,
json : Bool,
out_path : String,
) -> Unit {
let is_directory = @fs.is_dir(dir) catch {
@fs.IOError(_) => false
}
if !is_directory {
fail_code("read_failed", "not a directory: \{dir}")
}
let target = normalize_path(file)
let paths : Array[String] = []
collect_mbt_files(dir, paths)
paths.sort_by(fn(a, b) { a.lexical_compare(b) })
let errors : Array[String] = []
let files : Array[@interproc.PackageFile] = []
let mut found_target = false
for path in paths {
let normalized = normalize_path(path)
match read_scan_file(path, errors) {
None => ()
Some(source) =>
match @interproc.build_file(normalized, source, precise~) {
None => errors.push(normalized + ": parser diagnostic(s)")
Some(built) => {
if normalized == target {
found_target = true
}
files.push(built)
}
}
}
}
if !found_target {
fail_code("missing_file", "\{file} is not a readable .mbt file under \{dir}")
}
let index = @interproc.index(files)
let mut target_file : @interproc.PackageFile? = None
for candidate in files {
if candidate.path == target {
target_file = Some(candidate)
}
}
let program = match target_file {
Some(built) => built.program
None => {
fail_code("missing_file", "\{file} is not a readable .mbt file under \{dir}")
return
}
}
let built = match target_file {
Some(value) => value
None => return
}
let node = select_target(program, line, override_node)
let sliced = if direction == "forward" {
@slice.forward(built.pdg, node, program=program)
} else {
@slice.backward(built.pdg, node, program=program)
}
let slices = if interproc {
@interproc.expand(index, target, sliced)
} else {
[
@interproc.FileSlice::{
path: target,
nodes: sliced.nodes,
lines: @query.visible_lines(program, sliced.nodes),
},
]
}
let rendered = if json {
slice_files_json(slices, errors)
} else {
let text = StringBuilder()
let mut first = true
for slice in slices {
if !first {
text.write_char('\n')
}
first = false
text.write_string(slice.path)
text.write_string(": ")
text.write_string(lines_text(slice.lines))
}
let mut rendered = text.to_string()
for message in errors {
if rendered != "" {
rendered = rendered + "\n"
}
rendered = rendered + "# error: " + message
}
rendered
}
if out_path == "" {
println(rendered)
} else {
@fs.write_string_to_file(out_path, rendered) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out_path}: \{msg}")
}
println("wrote \{out_path}")
}
}
///|
fn run_callers(
dir : String,
name : String,
json : Bool,
out_path : String,
) -> Unit {
let is_directory = @fs.is_dir(dir) catch {
@fs.IOError(_) => false
}
if !is_directory {
fail_code("read_failed", "not a directory: \{dir}")
}
let paths : Array[String] = []
collect_mbt_files(dir, paths)
paths.sort_by(fn(a, b) { a.lexical_compare(b) })
let errors : Array[String] = []
let files : Array[@interproc.PackageFile] = []
for path in paths {
match read_scan_file(path, errors) {
None => ()
Some(source) =>
match @interproc.build_file(normalize_path(path), source) {
None => errors.push(normalize_path(path) + ": parser diagnostic(s)")
Some(built) => files.push(built)
}
}
}
let found = @interproc.callers(@interproc.index(files), name)
let rendered = if json {
let out = StringBuilder()
out.write_string("{\"name\":")
out.write_string(@report.json_string(name))
out.write_string(",\"files\":")
out.write_string(files.length().to_string())
out.write_string(",\"callers\":[")
let mut first = true
for site in found {
if !first {
out.write_char(',')
}
first = false
out.write_string("{\"file\":")
out.write_string(@report.json_string(site.path))
out.write_string(",\"line\":")
out.write_string(site.line.to_string())
out.write_string(",\"caller\":")
out.write_string(@report.json_string(site.caller))
out.write_string("}")
}
out.write_string("],\"errors\":[")
first = true
for message in errors {
if !first {
out.write_char(',')
}
first = false
out.write_string(@report.json_string(message))
}
out.write_string("]}")
out.to_string()
} else {
let text = StringBuilder()
let mut first = true
for site in found {
if !first {
text.write_char('\n')
}
first = false
text.write_string(site.path)
text.write_char(':')
text.write_string(site.line.to_string())
text.write_string(": ")
text.write_string(site.caller)
}
text.to_string()
}
if out_path == "" {
println(rendered)
} else {
@fs.write_string_to_file(out_path, rendered) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out_path}: \{msg}")
}
println("wrote \{out_path}")
}
}
///|
fn main {
let argv = @env.args()
json_mode[0] = prescan_json(argv)
if argv.length() < 2 {
usage()
return
}
let command = argv[1]
if command == "version" || command == "--version" {
println("moon-slice " + tool_version)
return
}
if command == "help" || command == "--help" {
usage()
return
}
if !known_command(command) {
fail_code("unknown_command", "unknown command \{command}")
}
let mut file = ""
let mut line = 0
let mut direction = "backward"
let mut format = "dot"
let mut out = ""
let mut json = false
let mut interproc = false
let mut from_var = ""
let mut to_var = ""
let mut node_override : Int32? = None
let mut precise_cdg = false
let mut diff_path = ""
let mut with_control = false
let mut baseline_path = ""
let mut write_baseline = ""
let mut fail_on_findings = false
let mut dir_path = ""
let mut name_var = ""
let mut i = 2
while i < argv.length() {
let arg = argv[i]
if arg == "--file" {
file = require_value(argv, i, arg)
i = i + 2
} else if arg == "--line" {
line = parse_line(require_value(argv, i, arg))
i = i + 2
} else if arg == "--direction" {
direction = require_value(argv, i, arg)
if direction != "backward" && direction != "forward" {
fail_code("bad_direction", "unsupported --direction \{direction} (use backward or forward)")
}
i = i + 2
} else if arg == "--format" {
format = require_value(argv, i, arg)
if format != "dot" && format != "json" && format != "sarif" {
fail_code("bad_format", "unsupported --format \{format} (use dot, json, or sarif)")
}
if format == "sarif" &&
command != "dead" &&
command != "unused" &&
command != "path" {
fail_code(
"bad_format",
"--format sarif is only supported by dead, unused and path",
)
}
i = i + 2
} else if arg == "--baseline" {
baseline_path = require_value(argv, i, arg)
i = i + 2
} else if arg == "--write-baseline" {
write_baseline = require_value(argv, i, arg)
i = i + 2
} else if arg == "--fail-on-findings" {
fail_on_findings = true
i = i + 1
} else if arg == "--dir" {
dir_path = require_value(argv, i, arg)
i = i + 2
} else if arg == "--name" {
name_var = require_value(argv, i, arg)
i = i + 2
} else if arg == "--out" {
out = require_value(argv, i, arg)
i = i + 2
} else if arg == "--from" {
from_var = require_value(argv, i, arg)
i = i + 2
} else if arg == "--to" {
to_var = require_value(argv, i, arg)
i = i + 2
} else if arg == "--node" {
node_override = Some(parse_line(require_value(argv, i, arg)).to_int64().to_int())
i = i + 2
} else if arg == "--json" {
json = true
i = i + 1
} else if arg == "--interprocedural" {
interproc = true
i = i + 1
} else if arg == "--precise" {
precise_cdg = true
i = i + 1
} else if arg == "--with-control" {
with_control = true
i = i + 1
} else if arg == "--diff" {
diff_path = require_value(argv, i, arg)
i = i + 2
} else {
fail_code("unknown_argument", "unknown argument \{arg}")
}
}
if command == "review" {
if diff_path == "" {
fail_code("missing_diff", "--diff is required for review")
}
run_review(diff_path, json)
return
}
if command == "unused" {
if dir_path == "" {
fail_code("missing_dir", "unused scans a package: --dir D is required")
}
if file != "" {
fail_code("bad_flag", "unused works on a package directory, not a single file")
}
run_unused_dir(
dir_path,
format,
json,
out,
baseline_path,
write_baseline,
fail_on_findings,
)
return
}
// Package-scope slicing: --dir D names the package root, --file F the file to
// slice inside it. Callees are then looked up across every file under D.
if command == "callers" {
if dir_path == "" {
fail_code("missing_dir", "callers scans a package: --dir D is required")
}
if name_var == "" {
fail_code("missing_name", "callers needs --name F: the function to look for")
}
if file != "" {
fail_code("bad_flag", "callers works on a package directory, not a single file")
}
run_callers(dir_path, name_var, json, out)
return
}
if dir_path != "" && command == "slice" {
if file == "" {
fail_code(
"missing_file",
"slice --dir D needs --file F: the file to slice inside D",
)
}
require_line(command, line)
run_slice_package(
dir_path,
file,
line.to_int64().to_int(),
node_override,
direction,
interproc,
precise_cdg,
json,
out,
)
return
}
if dir_path != "" {
if command != "dead" {
fail_code("bad_flag", "--dir is only supported by dead, unused and slice")
}
if file != "" {
fail_code("bad_flag", "use either --file or --dir, not both")
}
run_dead_dir(
dir_path,
format,
json,
out,
baseline_path,
write_baseline,
fail_on_findings,
)
return
}
if file == "" {
fail_code("missing_file", "--file is required")
}
require_line(command, line)
if command == "path" && (from_var == "" || to_var == "") {
fail_code("missing_path_args", "--from and --to are required")
}
if (baseline_path != "" || write_baseline != "") && command != "dead" {
fail_code("bad_flag", "--baseline and --write-baseline are only supported by dead")
}
if baseline_path != "" && write_baseline != "" {
fail_code("bad_flag", "use either --baseline or --write-baseline, not both")
}
if fail_on_findings && command != "dead" && command != "unused" {
fail_code(
"bad_flag",
"--fail-on-findings is only supported by dead and unused",
)
}
let source = read_source(file)
let (program, cfg, defuse, pdg) = analyze(source, file, precise_cdg)
if command != "dump-errors" && program.errors.length() > 0 {
fail_code("parse_error", "source contains \{program.errors.length()} parser diagnostic(s); use dump-errors to inspect them")
}
match command {
"slice" => {
let target = select_target(program, line.to_int64().to_int(), node_override)
let result = if direction == "forward" {
@slice.forward(pdg, target, program~)
} else {
@slice.backward(pdg, target, program~)
}
let result = @core.SliceResult::{
nodes: result.nodes,
lines: @query.visible_lines(program, result.nodes),
}
let result = if interproc {
@query.expand_calls(program, pdg, result)
} else {
result
}
if json {
println(@report.to_json(program, result))
} else {
print_lines(result)
}
}
"graph" =>
if format == "json" {
println(@report.to_graph_json(program, pdg))
} else {
println(@report.to_dot(pdg))
}
"compare" => {
// Show what the post-dominator builder buys: the control edges the
// structural rule invents, and (with --line) how the slice changes.
let precise_cdg = @cdg.build_precise(program, cfg)
let precise_pdg = @pdg.build(defuse, precise_cdg)
let structural = control_edge_set(pdg)
let precise = control_edge_set(precise_pdg)
let removed : Array[(Int32, Int32)] = []
for edge in structural {
if !precise.contains(edge) {
removed.push(edge)
}
}
let added : Array[(Int32, Int32)] = []
for edge in precise {
if !structural.contains(edge) {
added.push(edge)
}
}
if json {
println(
"{\"structural_control_edges\":" +
structural.length().to_string() +
",\"precise_control_edges\":" +
precise.length().to_string() +
",\"removed\":" +
pair_array_json(removed) +
",\"added\":" +
pair_array_json(added) +
"}",
)
} else {
println("structural control edges: " + structural.length().to_string())
println("precise control edges: " + precise.length().to_string())
println("removed by precise: " + removed.length().to_string())
for (from, to) in removed {
println(
" n" +
from.to_string() +
" -> n" +
to.to_string() +
" (lines " +
program.nodes[from].loc.start_line.to_string() +
" -> " +
program.nodes[to].loc.start_line.to_string() +
")",
)
}
println("added by precise: " + added.length().to_string())
for (from, to) in added {
println(
" n" +
from.to_string() +
" -> n" +
to.to_string() +
" (lines " +
program.nodes[from].loc.start_line.to_string() +
" -> " +
program.nodes[to].loc.start_line.to_string() +
")",
)
}
if line > 0 {
let target = select_target(program, line.to_int64().to_int(), node_override)
let before = @slice.backward(pdg, target, program~)
let after = @slice.backward(precise_pdg, target, program~)
let before_lines = @query.visible_lines(program, before.nodes)
let after_lines = @query.visible_lines(program, after.nodes)
println("slice line " + line.to_string() + " (structural): " + lines_text(before_lines))
println("slice line " + line.to_string() + " (precise): " + lines_text(after_lines))
}
}
}
"callgraph" => {
let edges = @query.call_edges(program)
if json {
println(@report.to_call_json(edges))
} else if edges.length() == 0 {
println("no internal calls")
} else {
println(@report.to_call_dot(edges))
}
}
"dead" => {
let dead = @query.dead_nodes(program, defuse)
let unreach = @query.unreachable_nodes(program, cfg)
let all = @query.union_sorted(dead, unreach)
if write_baseline != "" {
write_baseline_file(write_baseline, file, all, program)
return
}
let baseline = if baseline_path == "" {
Set([])
} else {
parse_baseline(read_source(baseline_path))
}
let filtered = filter_baseline(program, all, file, baseline)
let kept = filter_suppressions(
program,
filtered,
dead,
@suppress.parse(source),
)
let suppressed = all.length() - kept.length()
let result = @core.SliceResult::{
nodes: kept,
lines: @query.visible_lines(program, kept),
}
let rendered = if format == "sarif" {
@report.to_sarif(file, program, kept, dead, suppressed, tool_version)
} else if json {
@report.to_dead_json(program, result, suppressed)
} else {
lines_text(result.lines)
}
if out == "" {
println(rendered)
} else {
@fs.write_string_to_file(out, rendered) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out}: \{msg}")
}
println("wrote \{out}")
}
if fail_on_findings && kept.length() > 0 {
@sys.exit(1)
}
}
"html" => {
let target = select_target(program, line.to_int64().to_int(), node_override)
let result = @slice.backward(pdg, target, program~)
let result = @core.SliceResult::{
nodes: result.nodes,
lines: @query.visible_lines(program, result.nodes),
}
let result = if interproc {
@query.expand_calls(program, pdg, result)
} else {
result
}
let html = @report.to_html(
source,
program,
pdg,
result,
line.to_int64().to_int(),
target,
)
if out == "" {
println(html)
} else {
@fs.write_string_to_file(out, html) catch {
@fs.IOError(msg) => fail_code("write_failed", "failed to write \{out}: \{msg}")
}
println("wrote \{out}")
}
}
"path" => {
if from_var == "" || to_var == "" {
println("error: --from and --to are required")
return
}
let mut starts = @query.nodes_defining_name(program, from_var)
if starts.length() == 0 {
starts = @query.nodes_with_name(program, from_var)
}
let mut goals = @query.nodes_using_name(program, to_var)
if goals.length() == 0 {
goals = @query.nodes_with_name(program, to_var)
}
match @query.path_between(pdg, starts, goals, with_control=with_control) {
Some(path) => {
let result = @core.SliceResult::{
nodes: path,
lines: @query.lines_of(program, path),
}
if format == "sarif" {
println(
@report.to_sarif_path(
file,
program,
path,
from_var,
to_var,
tool_version,
),
)
} else if json {
println(@report.to_json(program, result))
} else {
let mut line_out = ""
let mut first = true
for id in path {
if !first {
line_out = line_out + " -> "
}
line_out = line_out +
"n" +
id.to_string() +
" L" +
program.nodes[id].loc.start_line.to_string()
first = false
}
println(line_out)
}
}
None =>
if json {
println("{\"found\":false}")
} else {
println("no path from \{from_var} to \{to_var}")
}
}
}
"dump-errors" => {
println("errors \{program.errors.length()}")
for e in program.errors {
println(e)
}
}
"dump-cfg" => {
let cfg = @cfg.build(program)
println("entry \{cfg.entry} exit \{cfg.exit}")
for edge in cfg.edges {
println("cfg \{edge.0} -> \{edge.1}")
}
}
"dump-defuse" =>
for pair in defuse.reaching {
println("du \{pair.0} -> \{pair.1}")
}
"dump-pdg" =>
for edge in pdg.edges {
println("pdg \{edge.from} -> \{edge.to} \{edge_kind_name(edge.kind)}")
}
"dump" => {
let ids = @query.nodes_at_line(program, line.to_int64().to_int())
if ids.length() == 0 {
println("no nodes at line \{line}")
}
for id in ids {
let node = program.nodes[id]
println(
"node \{node.id} kind \{kind_name(node.kind)} line \{node.loc.start_line} defs [\{vars_str(node.defs)}] uses [\{vars_str(node.uses)}] children \{node.children.length()}",
)
}
}
_ => usage()
}
}