#[path = "approximation.rs"]
mod approximation;
use super::{future, number, regex, ClaimAssessment, Facts};
use std::sync::LazyLock;
static PATH: LazyLock<regex::Regex> =
LazyLock::new(|| regex(r"\b[\w./-]+\.(?:rs|py|js|ts|tsx|jsx|md|toml|json|yaml|yml|c|h|cpp)\b"));
static QUANTITY: LazyLock<regex::Regex> = LazyLock::new(|| {
regex(
r"(?i)(?:[\d,]+\s*[- ]\s*lines?\b|\bnet\s+[+−-]?[\d,]+|\d[\d,]*\s*(?:→|->)|\d[\d,]*\s+(?:insertions?|deletions?|files? changed))",
)
});
static APPROX: LazyLock<regex::Regex> =
LazyLock::new(|| regex(r"(?i)~|≈|\b(?:about|roughly|approximately|around)\b"));
static PAIR: LazyLock<regex::Regex> = LazyLock::new(|| {
regex(r"(?i)([\d,]+)\s*(?:→|->)\s*([\d,]+)(?:\s+lines)?(?:\s*\(?net\s+([+−-][\d,]+)\)?)?")
});
static NET: LazyLock<regex::Regex> = LazyLock::new(|| regex(r"(?i)\bnet\s+([+−-]?[\d,]+)"));
static SIZE: LazyLock<regex::Regex> = LazyLock::new(|| regex(r"(?i)([\d,]+)\s*(?:-\s*)?lines?\b"));
static CHANGE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex(r"(?i)\b(?:reduc\w*|drop\w*|decreas\w*|shrank|shorter|fewer|removed|cut|down)\b")
});
static COMPARISON: LazyLock<regex::Regex> = LazyLock::new(|| {
regex(r"(?i)\b(?:difference|increase\w*|grew|larger|longer|added|delta|more|less|changed)\b")
});
fn visible(text: &str) -> String {
static CODE: LazyLock<regex::Regex> = LazyLock::new(|| regex(r"`+[^`\n]*`+"));
CODE.replace_all(text, |caps: ®ex::Captures<'_>| {
let inner = caps[0].trim_matches('`');
if PATH.find(inner).is_some_and(|m| m.as_str() == inner) {
inner.into()
} else {
" ".repeat(caps[0].len())
}
})
.into_owned()
}
pub(super) fn approximate(text: &str) -> bool {
APPROX.is_match(text)
}
pub(super) fn classify(clause: &str, facts: &Facts<'_>) -> Option<ClaimAssessment> {
let text = visible(clause);
if future(&text) || !QUANTITY.is_match(&text) {
return None;
}
if approximate(&text) {
return Some(approximation::classify(&text, facts));
}
if text.contains("insertion") || text.contains("deletion") || text.contains("files changed") {
return Some(ClaimAssessment::Unverified(
"no scoped diffstat observation",
));
}
if let Some(assessment) = descriptors(&text, facts) {
return Some(assessment);
}
let paths: Vec<_> = PATH.find_iter(&text).map(|m| m.as_str()).collect();
if paths.len() > 1 {
return Some(ClaimAssessment::Unverified(
"multiple file subjects in one clause",
));
}
let (before, after, subject) = if let Some(path) = paths.first() {
let Some(resolved) = super::super::files::resolve(facts.before, facts.after, path) else {
return Some(ClaimAssessment::Unverified(
"missing or ambiguous file path",
));
};
(
facts.before.and_then(|s| s.lines(&resolved)),
facts.after.and_then(|s| s.lines(&resolved)),
resolved,
)
} else if NET.is_match(&text) {
if !facts
.before
.zip(facts.after)
.is_some_and(|(a, b)| a.same_paths(b))
{
return Some(ClaimAssessment::Unverified(
"objective file sets differ or are unavailable",
));
}
(
facts.before.and_then(|s| s.total_lines()),
facts.after.and_then(|s| s.total_lines()),
"objective file total".into(),
)
} else {
return Some(ClaimAssessment::Unverified("no unique file subject"));
};
let unverified = || {
Some(ClaimAssessment::Unverified(
"scoped line count unavailable or unsupported phrasing",
))
};
if let Some(pair) = PAIR.captures(&text) {
let (Some(before), Some(after)) = (before, after) else {
return unverified();
};
let residue = text.replacen(&pair[0], "", 1);
if QUANTITY.is_match(&residue) || COMPARISON.is_match(&residue) || CHANGE.is_match(&residue)
{
return unverified();
}
if number(&pair[1]).is_none()
|| number(&pair[2]).is_none()
|| pair.get(3).is_some_and(|m| number(m.as_str()).is_none())
{
return unverified();
}
let matches = number(&pair[1]) == Some(before)
&& number(&pair[2]) == Some(after)
&& pair
.get(3)
.is_none_or(|m| number(m.as_str()) == Some(after - before));
return Some(if matches {
ClaimAssessment::Correct
} else {
ClaimAssessment::Corrected(format!(
"{subject}: {before} → {after} lines (net {:+})",
after - before
))
});
}
if let Some(net) = NET.captures(&text) {
let residue = text.replacen(&net[0], "", 1);
if QUANTITY.is_match(&residue) || COMPARISON.is_match(&residue) || CHANGE.is_match(&residue)
{
return unverified();
}
let (Some(before), Some(after)) = (before, after) else {
return unverified();
};
return Some(compare(
number(&net[1]),
after - before,
format!("{subject}: net {:+} lines", after - before),
));
}
let sizes: Vec<_> = SIZE.captures_iter(&text).collect();
if sizes.len() != 1 {
return unverified();
}
let expected = number(&sizes[0][1]);
let Some(path) = paths.first() else {
return unverified();
};
let path = regex::escape(path);
if CHANGE.is_match(&text) {
let reduction = regex(&format!(
r"(?i)(?:{path}\s+(?:was\s+)?(?:reduced|dropped|shortened|shrunk)\s+by|reduced\s+{path}\s+by)\s+[\d,]+\s+lines\b"
));
if !reduction.is_match(&text) {
return unverified();
}
let (Some(before), Some(after)) = (before, after) else {
return unverified();
};
return Some(compare(
expected,
before - after,
format!("{subject}: reduction {} lines", before - after),
));
}
if COMPARISON.is_match(&text) {
return unverified();
}
let absolute = regex(&format!(
r"(?i){path}\s*(?::|is(?:\s+now)?|now|after|before)\s*:?\s*[\d,]+\s+lines\b"
));
let descriptor = regex(&format!(
r"(?i)(?:{path}\s*\([\d,]+\s+lines\)|[\d,]+-line\s+{path})"
));
if !absolute.is_match(&text) {
if descriptor.is_match(&text)
&& expected.is_some()
&& (expected == before || expected == after)
{
return Some(ClaimAssessment::Correct);
}
return unverified();
}
let value = if text.to_lowercase().contains("before") {
before
} else {
after
};
let Some(value) = value else {
return unverified();
};
Some(compare(
expected,
value,
format!("{subject}: {value} lines"),
))
}
fn compare(claimed: Option<i64>, actual: i64, observed: String) -> ClaimAssessment {
if claimed.is_none() {
return ClaimAssessment::Unverified("unsupported number syntax");
}
if claimed == Some(actual) {
ClaimAssessment::Correct
} else {
ClaimAssessment::Corrected(observed)
}
}
fn descriptors(text: &str, facts: &Facts<'_>) -> Option<ClaimAssessment> {
static DESCRIPTOR: LazyLock<regex::Regex> = LazyLock::new(|| {
regex(&format!(
r"(?i)({})\s*\(([\d,]+)\s+lines(?:\s*(?:→|->)\s*largest in repo)?\)",
PATH.as_str()
))
});
let matches: Vec<_> = DESCRIPTOR.captures_iter(text).collect();
if matches.is_empty() {
return None;
}
let residue = DESCRIPTOR.replace_all(text, " ");
if QUANTITY.is_match(&residue) || COMPARISON.is_match(&residue) || CHANGE.is_match(&residue) {
return None;
}
for size in matches {
let path = super::super::files::resolve(facts.before, facts.after, &size[1]);
let expected = number(&size[2]);
let matches = path.is_some_and(|path| {
expected.is_some()
&& (expected == facts.before.and_then(|s| s.lines(&path))
|| expected == facts.after.and_then(|s| s.lines(&path)))
});
if !matches {
return Some(ClaimAssessment::Unverified(
"absolute size lacks a matching unambiguous observation",
));
}
}
Some(ClaimAssessment::Correct)
}