Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-mcp

Model Context Protocol server for Pudu language documentation and compiler tools

0.1.1Apache-2.01

InstallClose

Mutate.pudu

Pudu210 lines7.6 KB

GitHub ↗
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.Result as Result10import Std.Text as Text11import PuduLangMcp.Services.Process.Bounded as Bounded12import PuduLangMcp.Services.Toolchain as Toolchain1314/** @Tools.Mutate.Mutant — one single-point change to one file */15type Mutant = { file: Str, line: Int, column: Int, from: Str, to: Str }1617/// Operator replacements, tried at every code position in this order.18const OPERATORS: Array[(Str, Str)] = [19  ("==", "!="), ("!=", "=="), ("<=", "<"), (">=", ">"), ("<", "<="), (">", ">="),20  ("&&", "||"), ("||", "&&"), ("true", "false"), ("false", "true"), ("+ 1", "- 1"), ("- 1", "+ 1"), ("!", "")21]2223/// Longest a mutant's test run may take before it counts as killed.24const MUTANT_TIMEOUT_MS: Int = 1800002526/// Most bytes kept from one run's output.27const RUN_CAP_BYTES: Int = 10485762829/// Files under `src/` that are never mutated.30const EXCLUDED: Array[Str] = ["src/Main.pudu"]3132/// The directory of generated code, which is never mutated.33const GENERATED: Str = "src/PuduLangMcp/Generated/"3435/// Runs the harness and answers 0, or 1 when the score is below the threshold.36fn main() -> Int {37  let every = Option.unwrapOr(Option.andThen(Env.option("--every"), |given: Str| Text.countOf(given)), 1)38  let threshold = Option.unwrapOr(Option.andThen(Env.option("--threshold"), |given: Str| Text.countOf(given)), 0)39  let files = match Env.option("--file") {40    case Some(one) => [one]41    case None => sources("src")42  }43  var all: Array[Mutant] = []44  for file in files {45    all = all.concat(mutantsOf(file, Result.unwrapOr(Io.read(file), "")))46  }47  var chosen: Array[Mutant] = []48  var index = 049  for mutant in all {50    if index % (if every > 0 { every } else { 1 }) == 0 { chosen = chosen.push(mutant) }51    index = index + 152  }53  if Env.hasFlag("--dry-run") {54    for mutant in chosen { let _listed = Io.writeLine(describe(&mutant)) }55    let _counted = Io.writeLine(show(chosen.length()) + " mutants")56    return 057  }58  let toolchain = Toolchain.locate()59  if toolchain.compiler == None {60    let _said = Io.writeErrorLine("Mutate: pudu was not found; set PUDU_BIN")61    return 162  }63  var killed = 064  var survived = 065  var invalid = 066  var number = 067  for mutant in chosen {68    number = number + 169    match judge(&toolchain, &mutant) {70      case "killed" => { killed = killed + 1 }71      case "survived" => {72        survived = survived + 173        let _reported = Io.writeLine("survived  " + describe(&mutant))74      }75      case _ => { invalid = invalid + 1 }76    }77    let _progress = Io.writeErrorLine("[" + show(number) + "/" + show(chosen.length()) + "] " + describe(&mutant))78  }79  let scored = killed + survived80  let score = if scored == 0 { 100 } else { killed * 100 / scored }81  let _summary = Io.writeLine("killed " + show(killed) + ", survived " + show(survived) + ", invalid " + show(invalid) + ", score " + show(score) + "%")82  if score < threshold { 1 } else { 0 }83}8485/// Applies one mutant, runs the checks, restores the file, and answers the verdict.86fn judge(toolchain: &Toolchain.Toolchain, mutant: &Mutant) -> Str {87  let original = Result.unwrapOr(Io.read(mutant.file), "")88  let _mutated = Io.write(mutant.file, applied(original, mutant))89  let compiled = toolchain.run(["check", mutant.file], "", "", MUTANT_TIMEOUT_MS, RUN_CAP_BYTES)90  let verdict = match compiled {91    case Ok(checked) => {92      if checked.status != 0 {93        "invalid"94      } else {95        match toolchain.run(["test", "test"], "", "", MUTANT_TIMEOUT_MS, RUN_CAP_BYTES) {96          case Ok(tested) => if tested.status == 0 && !tested.timedOut { "survived" } else { "killed" }97          case Err(_) => "invalid"98        }99      }100    }101    case Err(_) => "invalid"102  }103  let _restored = Io.write(mutant.file, original)104  verdict105}106107/// Every mutant of one file's text.108fn mutantsOf(file: Str, text: Str) -> Array[Mutant] {109  var found: Array[Mutant] = []110  var lineNumber = 0111  for line in text.split("\n") {112    lineNumber = lineNumber + 1113    let trimmed = line.trim()114    if !trimmed.startsWith("//") && !trimmed.startsWith("/*") {115      let code = codeMask(line)116      for operator in OPERATORS {117        var at = 0118        while at < line.length() {119          if matchesAt(line, &code, at, operator[0]) {120            found = found.push(Mutant{file: file, line: lineNumber, column: at + 1, from: operator[0], to: operator[1]})121          }122          at = at + 1123        }124      }125    }126  }127  found128}129130/// Whether an operator occurs at a code position and is not part of a longer operator or word.131fn matchesAt(line: Str, code: &Array[Bool], at: Int, operator: Str) -> Bool {132  let end = at + operator.length()133  if end > line.length() || line.slice(at, end) != operator { return false }134  var position = at135  while position < end {136    if !code[position] { return false }137    position = position + 1138  }139  let before = if at > 0 { line.slice(at - 1, at) } else { "" }140  let after = if end < line.length() { line.slice(end, end + 1) } else { "" }141  if operator == "true" || operator == "false" { return !isWord(before) && !isWord(after) }142  if operator == "<" { return after != "=" && after != "-" && before != "<" }143  if operator == ">" { return after != "=" && before != "-" && before != "=" && before != ">" }144  if operator == "!" { return after != "=" && (isWord(after) || after == "(") }145  if operator == "- 1" || operator == "+ 1" { return !isWord(after) }146  true147}148149/// For each character of a line, whether it is code rather than a string literal or comment.150fn codeMask(line: Str) -> Array[Bool] {151  var mask: Array[Bool] = []152  var inString = false153  var escaped = false154  var commented = false155  var index = 0156  let characters = line.chars()157  for character in characters {158    if commented {159      mask = mask.push(false)160    } else if inString {161      mask = mask.push(false)162      if escaped { escaped = false } else if character == '\\' { escaped = true } else if character == '"' { inString = false }163    } else if character == '"' {164      inString = true165      mask = mask.push(false)166    } else if character == '/' && index + 1 < characters.length() && characters[index + 1] == '/' {167      commented = true168      mask = mask.push(false)169    } else if character == '\'' {170      mask = mask.push(false)171    } else {172      mask = mask.push(true)173    }174    index = index + 1175  }176  mask177}178179/// The text with one mutant applied.180fn applied(text: Str, mutant: &Mutant) -> Str {181  let lines = text.split("\n")182  let target = lines[mutant.line - 1]183  let start = mutant.column - 1184  let changed = target.slice(0, start) + mutant.to + target.drop(start + mutant.from.length())185  lines.slice(0, mutant.line - 1).concat([changed]).concat(lines.slice(mutant.line, lines.length())).join("\n")186}187188/// A mutant as `file:line:column  from → to`.189fn describe(mutant: &Mutant) -> Str {190  mutant.file + ":" + show(mutant.line) + ":" + show(mutant.column) + "  " + mutant.from + " → " + (if mutant.to.isEmpty() { "(removed)" } else { mutant.to })191}192193/// Whether text is one identifier character.194fn isWord(text: Str) -> Bool {195  !text.isEmpty() && Text.allChars(text, |c: Char| Char.isAlphanumeric(c) || c == '_')196}197198/// Every mutable source file under a directory, sorted.199fn sources(directory: Str) -> Array[Str] {200  var found: Array[Str] = []201  for path in List.sortBy(&Result.unwrapOr(Io.listPaths(directory), []), fn(left: Str, right: Str) -> Bool { left < right }) {202    if path.endsWith(".pudu") {203      if !path.startsWith(GENERATED) && !EXCLUDED.contains(path) { found = found.push(path) }204    } else if !path.contains(".") {205      found = found.concat(sources(path))206    }207  }208  found209}210