use crate::declare_detector;
use crate::diagnostic::{Action, Deb822Action, Diagnostic, ParagraphSelector};
use crate::{Certainty, FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_workspace::Workspace;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::PathBuf;
include!(concat!(env!("OUT_DIR"), "/spelling_corrections_case.rs"));
const LABEL: &str = "Fix capitalization errors in package description.";
struct Correction {
span: Range<usize>,
word: String,
correction: String,
}
fn bracket_spans(text: &str) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut spans = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' {
if let Some(rel) = bytes[i + 1..].iter().position(|&b| b == b']') {
let close = i + 1 + rel;
if close > i + 1 {
spans.push(i..close + 1);
i = close + 1;
continue;
}
}
}
i += 1;
}
spans
}
fn word_tokens(text: &str, brackets: &[Range<usize>]) -> Vec<Range<usize>> {
let mut tokens = Vec::new();
let mut start: Option<usize> = None;
for (idx, ch) in text.char_indices() {
let boundary = ch.is_whitespace() || brackets.iter().any(|b| b.contains(&idx));
match (boundary, start) {
(true, Some(s)) => {
tokens.push(s..idx);
start = None;
}
(false, None) => start = Some(idx),
_ => {}
}
}
if let Some(s) = start {
tokens.push(s..text.len());
}
tokens
}
fn core_range(text: &str, token: &Range<usize>) -> Range<usize> {
let mut start = token.start;
if text[token.clone()].starts_with('(') {
start += 1; }
let trimmed = text[start..token.end].trim_end_matches([')', '.', ',', '?', '!', ':', ';']);
start..start + trimmed.len()
}
fn find_corrections(extended: &str, map: &HashMap<&str, &str>) -> Vec<Correction> {
let mut corrections = Vec::new();
for m in lazy_regex::regex!(r"meta\s+package").find_iter(extended) {
corrections.push(Correction {
span: m.start()..m.end(),
word: "meta package".to_string(),
correction: "metapackage".to_string(),
});
}
let brackets = bracket_spans(extended);
for token in word_tokens(extended, &brackets) {
let core = core_range(extended, &token);
if core.is_empty() {
continue;
}
let word = &extended[core.clone()];
if let Some(&correction) = map.get(word) {
corrections.push(Correction {
span: core,
word: word.to_string(),
correction: correction.to_string(),
});
}
}
corrections
}
fn apply_corrections(extended: &str, corrections: &[Correction]) -> String {
let mut ordered: Vec<&Correction> = corrections.iter().collect();
ordered.sort_by(|a, b| b.span.start.cmp(&a.span.start));
let mut result = extended.to_string();
for c in ordered {
result.replace_range(c.span.clone(), &c.correction);
}
result
}
pub fn detect(
ws: &dyn Workspace,
_preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
let control_rel = PathBuf::from("debian/control");
let control = match ws.parsed_control() {
Ok(c) => c,
Err(debian_workspace::Error::NotFound) => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
let mut diagnostics = Vec::new();
for binary in control.binaries() {
let auto_generated = binary.get("Auto-Built-Package").is_some();
let Some(description) = binary.description() else {
continue;
};
let (synopsis, extended) = match description.split_once('\n') {
Some((s, e)) => (s, Some(e)),
None => (description.as_str(), None),
};
let syn_corrections = if auto_generated {
Vec::new()
} else {
find_corrections(synopsis, &map)
};
let ext_corrections = extended.map_or_else(Vec::new, |e| find_corrections(e, &map));
if syn_corrections.is_empty() && ext_corrections.is_empty() {
continue;
}
let Some(package) = binary.name() else {
continue;
};
let new_synopsis = apply_corrections(synopsis, &syn_corrections);
let new_description = match extended {
Some(extended) => format!(
"{new_synopsis}\n{}",
apply_corrections(extended, &ext_corrections)
),
None => new_synopsis,
};
let set_field = Action::Deb822(Deb822Action::SetField {
file: control_rel.clone(),
paragraph: ParagraphSelector::Binary {
package: package.clone(),
},
field: "Description".into(),
value: new_description,
});
let sources = [
(
"capitalization-error-in-description-synopsis",
&syn_corrections,
),
("capitalization-error-in-description", &ext_corrections),
];
for (tag, corrections) in sources {
let mut seen = HashSet::new();
for correction in corrections {
if !seen.insert(correction.word.as_str()) {
continue;
}
let issue = LintianIssue::binary_with_info(
&package,
tag,
Visibility::Info,
vec![correction.word.clone(), correction.correction.clone()],
);
diagnostics.push(
Diagnostic::with_actions(
issue,
format!(
"Description contains a capitalization error: {} should be {}.",
correction.word, correction.correction
),
LABEL,
vec![set_field.clone()],
)
.with_certainty(Certainty::Possible),
);
}
}
}
Ok(diagnostics)
}
declare_detector! {
name: "capitalization-error-in-description",
tags: [
"capitalization-error-in-description",
"capitalization-error-in-description-synopsis",
],
triggers: [
debian_workspace::Trigger::Deb822Field {
file: "debian/control",
paragraph_key: "Package",
field: "Description",
},
],
detect: |ws, prefs| detect(ws, prefs),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detector::Detector;
use crate::Version;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn run_apply(base: &Path) -> Result<crate::FixerResult, FixerError> {
let version: Version = "1.0".parse().unwrap();
let adapter = DetectorImpl;
let ws = debian_workspace::fs_workspace::FsWorkspace::new(
base,
Some("test".into()),
Some(version),
);
adapter.apply(&ws, &FixerPreferences::default())
}
fn corrections(extended: &str) -> Vec<(String, String)> {
let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
find_corrections(extended, &map)
.into_iter()
.map(|c| (c.word, c.correction))
.collect()
}
#[test]
fn test_bracket_spans() {
let empty: Vec<Range<usize>> = vec![];
assert_eq!(bracket_spans("a [bc] d"), vec![2..6]);
assert_eq!(bracket_spans("a [] b"), empty);
assert_eq!(bracket_spans("[a] [b]"), vec![0..3, 4..7]);
assert_eq!(bracket_spans("no brackets"), empty);
}
#[test]
fn test_core_range() {
let strip = |s: &str| {
let r = core_range(s, &(0..s.len()));
s[r].to_string()
};
assert_eq!(strip("linux"), "linux");
assert_eq!(strip("(linux)"), "linux");
assert_eq!(strip("linux."), "linux");
assert_eq!(strip("linux),"), "linux");
assert_eq!(strip("(linux"), "linux");
}
#[test]
fn test_find_corrections_simple() {
assert_eq!(
corrections("This runs on linux systems."),
vec![("linux".to_string(), "Linux".to_string())]
);
}
#[test]
fn test_find_corrections_punctuation() {
assert_eq!(
corrections("Built with (gnome)."),
vec![("gnome".to_string(), "GNOME".to_string())]
);
}
#[test]
fn test_find_corrections_case_sensitive() {
assert!(corrections("Runs on Linux.").is_empty());
}
#[test]
fn test_find_corrections_skips_brackets() {
assert!(corrections("Install one of [linux gnome].").is_empty());
}
#[test]
fn test_find_corrections_meta_package() {
assert_eq!(
corrections("This is a meta package."),
vec![("meta package".to_string(), "metapackage".to_string())]
);
}
#[test]
fn test_find_corrections_order_and_dedup_input() {
let found = corrections("A meta package using linux and more linux.");
assert_eq!(
found,
vec![
("meta package".to_string(), "metapackage".to_string()),
("linux".to_string(), "Linux".to_string()),
("linux".to_string(), "Linux".to_string()),
]
);
}
#[test]
fn test_apply_corrections_rewrites_all() {
let map: HashMap<&str, &str> = SPELLING_CORRECTIONS_CASE.iter().copied().collect();
let extended = "Built for linux with gnome; a meta package for linux.";
let found = find_corrections(extended, &map);
assert_eq!(
apply_corrections(extended, &found),
"Built for Linux with GNOME; a metapackage for Linux."
);
}
#[test]
fn test_fix_extended_description() {
let tmp = TempDir::new().unwrap();
let debian = tmp.path().join("debian");
fs::create_dir(&debian).unwrap();
let control = debian.join("control");
fs::write(
&control,
"Source: test\n\nPackage: test\nDescription: A test package\n It runs on linux.\n",
)
.unwrap();
let result = run_apply(tmp.path()).unwrap();
assert_eq!(result.description, LABEL);
assert_eq!(result.certainty, Some(Certainty::Possible));
assert_eq!(
fs::read_to_string(&control).unwrap(),
"Source: test\n\nPackage: test\nDescription: A test package\n It runs on Linux.\n",
);
}
#[test]
fn test_fix_multiple_corrections() {
let tmp = TempDir::new().unwrap();
let debian = tmp.path().join("debian");
fs::create_dir(&debian).unwrap();
let control = debian.join("control");
fs::write(
&control,
"Source: test\n\nPackage: test\nDescription: A test package\n Built for linux using gnome.\n",
)
.unwrap();
let result = run_apply(tmp.path()).unwrap();
let tags = result.fixed_lintian_tags();
assert_eq!(tags, vec!["capitalization-error-in-description"; 2]);
assert_eq!(
fs::read_to_string(&control).unwrap(),
"Source: test\n\nPackage: test\nDescription: A test package\n Built for Linux using GNOME.\n",
);
}
#[test]
fn test_fix_synopsis() {
let tmp = TempDir::new().unwrap();
let debian = tmp.path().join("debian");
fs::create_dir(&debian).unwrap();
let control = debian.join("control");
fs::write(
&control,
"Source: test\n\nPackage: test\nDescription: tool for linux\n A clean extended line.\n",
)
.unwrap();
let result = run_apply(tmp.path()).unwrap();
let tags = result.fixed_lintian_tags();
assert_eq!(tags, vec!["capitalization-error-in-description-synopsis"]);
assert_eq!(
fs::read_to_string(&control).unwrap(),
"Source: test\n\nPackage: test\nDescription: tool for Linux\n A clean extended line.\n",
);
}
#[test]
fn test_auto_generated_synopsis_skipped() {
let tmp = TempDir::new().unwrap();
let debian = tmp.path().join("debian");
fs::create_dir(&debian).unwrap();
let original = "Source: test\n\nPackage: test\nAuto-Built-Package: debug-symbols\nDescription: tool for linux\n A clean extended line.\n";
fs::write(debian.join("control"), original).unwrap();
assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
}
#[test]
fn test_no_correction() {
let tmp = TempDir::new().unwrap();
let debian = tmp.path().join("debian");
fs::create_dir(&debian).unwrap();
let original =
"Source: test\n\nPackage: test\nDescription: A test package\n A clean extended line.\n";
fs::write(debian.join("control"), original).unwrap();
assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
}
#[test]
fn test_no_control_file() {
let tmp = TempDir::new().unwrap();
assert!(matches!(run_apply(tmp.path()), Err(FixerError::NoChanges)));
}
}