
Mutate.pudu
Pudu211 lines7.8 KB
1/** @Tools.Mutate.Harness — mutation testing over the source tree */2module Mutate34import Std.Char as Char5import Std.Env as Env6import Std.Io as Io7import Std.List as List8import Std.Option as Option9import Std.Process as Process10import Std.Result as Result11import Std.Text as Text1213/** @Tools.Mutate.Mutant — one single-point change to one file */14type Mutant = { file: Str, line: Int, column: Int, from: Str, to: Str }1516/** @Tools.Mutate.Verdict — what the checks said about one mutant */17type Verdict = Killed | Survived | Invalid181920const OPERATORS: Array[(Str, Str)] = [21 ("==", "!="), ("!=", "=="), ("<=", "<"), (">=", ">"), ("<", "<="), (">", ">="),22 ("&&", "||"), ("||", "&&"), ("true", "false"), ("false", "true"), ("+ 1", "- 1"), ("- 1", "+ 1"), ("!", "")23]242526const MUTANT_TIMEOUT_MS: Int = 30000272829const DOMAIN: Str = "src/PuduLangHttpClient/Domain"303132fn main() -> Int {33 let every = Option.unwrapOr(Option.andThen(Env.option("--every"), |given: Str| Text.countOf(given)), 1)34 let threshold = Option.unwrapOr(Option.andThen(Env.option("--threshold"), |given: Str| Text.countOf(given)), 0)35 let files = match Env.option("--file") {36 case Some(one) => [one]37 case None => sources(if Env.hasFlag("--domain") { DOMAIN } else { "src" })38 }39 var all: Array[Mutant] = []40 for file in files {41 all = all.concat(mutantsOf(file, Result.unwrapOr(Io.read(file), "")))42 }43 var chosen: Array[Mutant] = []44 var index = 045 for mutant in all {46 if index % (if every > 0 { every } else { 1 }) == 0 { chosen = chosen.push(mutant) }47 index = index + 148 }49 if Env.hasFlag("--dry-run") {50 for mutant in chosen { let _listed = Io.writeLine(describe(&mutant)) }51 let _counted = Io.writeLine(show(chosen.length()) + " mutants")52 return 053 }54 let suites = Option.unwrapOr(Env.option("--suites"), "test")55 let compiler = Env.variableOr("PUDU_BIN", "pudu")56 if !Process.isAvailable(compiler) {57 let _said = Io.writeErrorLine("Mutate: " + compiler + " was not found; set PUDU_BIN")58 return 159 }60 var killed = 061 var survived = 062 var invalid = 063 var number = 064 for mutant in chosen {65 number = number + 166 match judge(compiler, suites, &mutant) {67 case Killed => { killed = killed + 1 }68 case Survived => {69 survived = survived + 170 let _reported = Io.writeLine("survived " + describe(&mutant))71 }72 case Invalid => { invalid = invalid + 1 }73 }74 let _progress = Io.writeErrorLine("[" + show(number) + "/" + show(chosen.length()) + "] " + describe(&mutant))75 }76 let scored = killed + survived77 let score = if scored == 0 { 100 } else { killed * 100 / scored }78 let _summary = Io.writeLine("killed " + show(killed) + ", survived " + show(survived) + ", invalid " + show(invalid) + ", score " + show(score) + "%")79 if score < threshold { 1 } else { 0 }80}81828384fn judge(compiler: Str, suites: Str, mutant: &Mutant) -> Verdict {85 let original = Result.unwrapOr(Io.read(mutant.file), "")86 let _mutated = Io.write(mutant.file, applied(original, mutant))87 let verdict = match Process.within(compiler, &["check", mutant.file], MUTANT_TIMEOUT_MS) {88 case Ok(Some(checked)) => {89 if checked.status != 0 || checked.output.contains("error[") {90 Invalid91 } else {92 match Process.within(compiler, &["test", suites], MUTANT_TIMEOUT_MS) {93 case Ok(Some(tested)) => if tested.status == 0 { Survived } else { Killed }94 case Ok(None) => Killed95 case Err(_) => Invalid96 }97 }98 }99 case _ => Invalid100 }101 let _restored = Io.write(mutant.file, original)102 verdict103}104105106fn mutantsOf(file: Str, text: Str) -> Array[Mutant] {107 var found: Array[Mutant] = []108 var lineNumber = 0109 for line in text.split("\n") {110 lineNumber = lineNumber + 1111 let trimmed = line.trim()112 if !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("import ") {113 let code = codeMask(line)114 for operator in OPERATORS {115 var at = 0116 while at < line.length() {117 if matchesAt(line, &code, at, operator[0]) {118 found = found.push(Mutant{file: file, line: lineNumber, column: at + 1, from: operator[0], to: operator[1]})119 }120 at = at + 1121 }122 }123 }124 }125 found126}127128129fn matchesAt(line: Str, code: &Array[Bool], at: Int, operator: Str) -> Bool {130 let end = at + operator.length()131 if end > line.length() || line.slice(at, end) != operator { return false }132 var position = at133 while position < end {134 if !code[position] { return false }135 position = position + 1136 }137 let before = if at > 0 { line.slice(at - 1, at) } else { "" }138 let after = if end < line.length() { line.slice(end, end + 1) } else { "" }139 if operator == "true" || operator == "false" { return !isWord(before) && !isWord(after) }140 if operator == "<" { return after != "=" && after != "-" && before != "<" && isSpaced(before, after) }141 if operator == ">" { return after != "=" && before != "-" && before != "=" && before != ">" && isSpaced(before, after) }142 if operator == "!" { return after != "=" && (isWord(after) || after == "(") }143 if operator == "- 1" || operator == "+ 1" { return !isWord(after) }144 true145}146147148fn isSpaced(before: Str, after: Str) -> Bool { before == " " && after == " " }149150151fn codeMask(line: Str) -> Array[Bool] {152 var mask: Array[Bool] = []153 var inString = false154 var escaped = false155 var commented = false156 var index = 0157 let characters = line.chars()158 for character in characters {159 if commented {160 mask = mask.push(false)161 } else if inString {162 mask = mask.push(false)163 if escaped { escaped = false } else if character == '\\' { escaped = true } else if character == '"' { inString = false }164 } else if character == '"' {165 inString = true166 mask = mask.push(false)167 } else if character == '/' && index + 1 < characters.length() && characters[index + 1] == '/' {168 commented = true169 mask = mask.push(false)170 } else if character == '\'' {171 mask = mask.push(false)172 } else {173 mask = mask.push(true)174 }175 index = index + 1176 }177 mask178}179180181fn applied(text: Str, mutant: &Mutant) -> Str {182 let lines = text.split("\n")183 let target = lines[mutant.line - 1]184 let start = mutant.column - 1185 let changed = target.slice(0, start) + mutant.to + target.drop(start + mutant.from.length())186 lines.slice(0, mutant.line - 1).concat([changed]).concat(lines.slice(mutant.line, lines.length())).join("\n")187}188189190fn describe(mutant: &Mutant) -> Str {191 mutant.file + ":" + show(mutant.line) + ":" + show(mutant.column) + " " + mutant.from + " -> " + (if mutant.to.isEmpty() { "(removed)" } else { mutant.to })192}193194195fn isWord(text: Str) -> Bool {196 !text.isEmpty() && Text.allChars(text, |c: Char| Char.isAlphanumeric(c) || c == '_')197}198199200fn sources(directory: Str) -> Array[Str] {201 var found: Array[Str] = []202 for path in List.sortBy(&Result.unwrapOr(Io.listPaths(directory), []), fn(left: Str, right: Str) -> Bool { left < right }) {203 if path.endsWith(".pudu") {204 found = found.push(path)205 } else if !path.contains(".") {206 found = found.concat(sources(path))207 }208 }209 found210}211