// `review`: diff-aware slicing.
//
// Given a unified diff, find the `.mbt` lines it adds, forward-slice each of
// them, and report the lines and functions those changes can affect. This is the
// question a reviewer actually asks before merging: "what does this diff touch
// beyond what it appears to touch?"
//
// The diff is read from a file rather than obtained by running git here, so the
// command stays testable and has no process dependency; `tools/review.sh`
// produces the diff with git.
///|
priv struct DiffHunk {
path : String
lines : Array[Int32]
}
///|
/// Parse a unified diff into `path -> added/modified new-file line numbers`.
///
/// Only added lines are collected: a deleted line has no new-file position to
/// slice, and a context line is unchanged behaviour. Callers are expected to
/// produce the diff with `-U0`, which keeps this simple and deterministic.
fn parse_diff(diff : String) -> Array[DiffHunk] {
let hunks : Array[DiffHunk] = []
let mut current : DiffHunk? = None
let mut new_line : Int32 = 0
for raw in diff.split("\n") {
let line = raw.to_owned()
if line.has_prefix("+++ ") {
let path = line[4:].to_owned()
let path = if path.has_prefix("b/") { path[2:].to_owned() } else { path }
if path == "/dev/null" {
current = None
} else {
let hunk : DiffHunk = { path, lines: [], }
hunks.push(hunk)
current = Some(hunk)
}
} else if line.has_prefix("@@ ") {
// `@@ -a,b +c,d @@` — only the new-file start matters.
let plus = line.find(" +")
match plus {
None => ()
Some(index) => {
let rest = line[index + 2:].to_owned()
let comma = rest.find(",")
let space = rest.find(" ")
let stop = match (comma, space) {
(Some(c), Some(s)) => if c < s { c } else { s }
(Some(c), None) => c
(None, Some(s)) => s
(None, None) => rest.length()
}
let digits = rest[:stop].to_owned()
new_line = @string.parse_int(digits) catch { _ => 0 }
}
}
} else if line.has_prefix("+") && !line.has_prefix("+++") {
match current {
None => ()
Some(hunk) =>
if new_line > 0 {
if !hunk.lines.contains(new_line) {
hunk.lines.push(new_line)
}
new_line = new_line + 1
}
}
} else if line.has_prefix(" ") {
// Context line (only with -U>0): it exists in the new file too.
if new_line > 0 {
new_line = new_line + 1
}
}
}
hunks
}
///|
/// Read a file, or `None` when it cannot be read. `review` skips unreadable
/// files (a diff may reference a deleted path) instead of failing the run.
fn read_optional(path : String) -> String? {
let ok = [true]
let text = @fs.read_file_to_string(path) catch {
@fs.IOError(_) => {
ok[0] = false
""
}
}
if ok[0] {
Some(text)
} else {
None
}
}
///|
/// Lines affected by a diff, per file, computed by forward-slicing every
/// changed line that resolves to a statement.
fn review_file(
path : String,
changed : Array[Int32],
) -> Array[Int32] {
let source = match read_optional(path) {
None => return []
Some(text) => text
}
let program = @parser_adapter.parse_source(source, path)
if program.errors.length() > 0 {
return []
}
let cfg = @cfg.build(program)
let defuse = @defuse.analyze(program, cfg)
let cdg = @cdg.build(program, cfg)
let pdg = @pdg.build(defuse, cdg)
let affected : Array[Int32] = []
for line in changed {
let node = match @query.target_node(program, line) {
Some(id) => id
None => continue
}
let slice = @slice.forward(pdg, node, program~)
for id in slice.nodes {
let node = program.nodes[id]
let line = node.loc.start_line
if line > 0 && !affected.contains(line) {
affected.push(line)
}
}
}
affected.sort()
affected
}
///|
/// Function names that contain at least one of `lines`.
fn functions_touching(
program : @core.Program,
lines : Array[Int32],
) -> Array[String] {
let out : Array[String] = []
for function in program.functions {
let mut hit = false
for id in lines {
if id >= function.entry && id <= function.exit {
hit = true
}
}
if hit && !out.contains(function.name) {
out.push(function.name)
}
}
out
}
///|
fn lines_csv(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 names_json(names : Array[String]) -> String {
let out = StringBuilder()
out.write_char('[')
let mut first = true
for name in names {
if !first {
out.write_char(',')
}
out.write_string(@report.json_string(name))
first = false
}
out.write_char(']')
out.to_string()
}
///|
/// Entry point for `review --diff FILE [--json]`.
fn run_review(diff_path : String, json : Bool) -> Unit {
let diff = match read_optional(diff_path) {
Some(text) => text
None => {
fail_code("read_failed", "failed to read \{diff_path}")
""
}
}
let hunks = parse_diff(diff)
let records = StringBuilder()
let mut first = true
let mut total_changed = 0
let mut total_affected = 0
for hunk in hunks {
if hunk.lines.length() == 0 {
continue
}
let affected = review_file(hunk.path, hunk.lines)
let source = match read_optional(hunk.path) {
None => ""
Some(text) => text
}
let program = @parser_adapter.parse_source(source, hunk.path)
let functions = functions_touching(program, affected)
total_changed = total_changed + hunk.lines.length()
total_affected = total_affected + affected.length()
if json {
if !first {
records.write_char(',')
}
records.write_string("{\"file\":")
records.write_string(@report.json_string(hunk.path))
records.write_string(",\"changed\":[")
records.write_string(lines_csv(hunk.lines))
records.write_string("],\"affected\":[")
records.write_string(lines_csv(affected))
records.write_string("],\"functions\":")
records.write_string(names_json(functions))
records.write_char('}')
first = false
} else {
println(
hunk.path +
": changed " +
hunk.lines.length().to_string() +
" line(s) -> affected " +
affected.length().to_string() +
" line(s)" +
(if functions.length() > 0 {
" in " + functions.join(", ")
} else {
""
}),
)
if affected.length() > 0 {
println(" affected lines: " + lines_csv(affected))
}
}
}
if json {
println(
"{\"files\":" +
hunks.length().to_string() +
",\"changed\":" +
total_changed.to_string() +
",\"affected\":" +
total_affected.to_string() +
",\"records\":[" +
records.to_string() +
"]}",
)
} else {
println(
"review: " +
hunks.length().to_string() +
" file(s), " +
total_changed.to_string() +
" changed line(s), " +
total_affected.to_string() +
" affected line(s)",
)
}
}