diff options
Diffstat (limited to '.run/src/bin/check-docs-structure.rs')
| -rw-r--r-- | .run/src/bin/check-docs-structure.rs | 352 |
1 files changed, 0 insertions, 352 deletions
diff --git a/.run/src/bin/check-docs-structure.rs b/.run/src/bin/check-docs-structure.rs deleted file mode 100644 index ac13da2..0000000 --- a/.run/src/bin/check-docs-structure.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Checks that every translated docs directory mirrors the structure of the -//! reference (English) docs directory exactly. -//! -//! The language directories are declared in `.config/docs-lang.txt`, one path -//! per line (relative to `./docs/`). The first line is the reference -//! directory; every other line is a translation that must match it. -//! -//! For each file pair the tool compares a *structural signature*: one token per -//! line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced code -//! blocks (including their language tag), `@@@` hidden-compilation lines, blank -//! lines, blockquotes, lists and plain text. Translated text is allowed to -//! differ; the structure is not. - -use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; - -use colored::Colorize; -use tools::println_cargo_style; - -const DOCS_DIR: &str = "./docs"; -const LANG_CONFIG: &str = ".config/docs-lang.txt"; - -fn main() { - println_cargo_style!("Checking: docs structure consistency across languages ..."); - - let repo_root = find_git_repo().expect("Cannot find git repo root"); - let docs_dir = repo_root.join(DOCS_DIR); - - let lang_lines = read_lang_config(&repo_root); - if lang_lines.is_empty() { - println!("No language directories declared in {LANG_CONFIG}, nothing to check."); - return; - } - - let reference = docs_dir.join(&lang_lines[0]); - if !reference.is_dir() { - eprintln!( - "Reference docs directory `{}` does not exist.", - reference.display() - ); - std::process::exit(1); - } - - let mut failed = false; - - for lang in &lang_lines[1..] { - let lang_dir = docs_dir.join(lang); - println!("\nChecking `{lang}` against `{}` ...", lang_lines[0]); - if !lang_dir.is_dir() { - eprintln!(" ERROR: `{}` does not exist.", lang_dir.display()); - failed = true; - continue; - } - if check_lang_dir(&reference, &lang_dir).is_err() { - failed = true; - } - } - - if failed { - println!(); - eprintln!( - "{} Fix the differences above.", - "Docs structure check FAILED.".red().bold() - ); - std::process::exit(1); - } - - println_cargo_style!("Done: docs structure is consistent across all languages!"); -} - -fn read_lang_config(repo_root: &Path) -> Vec<String> { - let path = repo_root.join(LANG_CONFIG); - let Ok(content) = fs::read_to_string(&path) else { - return Vec::new(); - }; - content - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .map(|l| l.trim_start_matches("./").to_string()) - .collect() -} - -/// Returns `Err(())` when the translated directory does not mirror the reference. -fn check_lang_dir(reference: &Path, lang: &Path) -> Result<(), ()> { - let mut failed = false; - - let ref_files = collect_md_files(reference); - let lang_files = collect_md_files(lang); - - let ref_set: BTreeSet<PathBuf> = ref_files.clone().into_iter().collect(); - let lang_set: BTreeSet<PathBuf> = lang_files.clone().into_iter().collect(); - - let missing: Vec<PathBuf> = ref_set.difference(&lang_set).cloned().collect(); - let extra: Vec<PathBuf> = lang_set.difference(&ref_set).cloned().collect(); - - if !missing.is_empty() { - failed = true; - println!(" ERROR: files missing in translation:"); - for f in &missing { - println!(" - {}", f.display()); - } - } - if !extra.is_empty() { - failed = true; - println!(" ERROR: extra files in translation:"); - for f in &extra { - println!(" - {}", f.display()); - } - } - - // Compare the structural signature of every file present in both sides. - for file in &ref_files { - if !lang_set.contains(file) { - continue; - } - let ref_path = reference.join(file); - let lang_path = lang.join(file); - match compare_signature(&ref_path, &lang_path) { - Ok(()) => {} - Err(diff) => { - failed = true; - eprintln!( - " {}: structure mismatch in `{}`", - "ERROR".red().bold(), - file.display().to_string().cyan() - ); - for line in diff { - println!(" {line}"); - } - } - } - } - - if failed { Err(()) } else { Ok(()) } -} - -fn collect_md_files(dir: &Path) -> Vec<PathBuf> { - let mut out = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - let Ok(entries) = fs::read_dir(¤t) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.extension().is_some_and(|e| e == "md") { - out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); - } - } - } - out.sort(); - out -} - -/// Compare the structural signatures of two markdown files. -/// -/// Returns a list of human-readable diff lines on the first structural -/// difference found (all differences up to a small window are reported). -fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> { - let ref_content = fs::read_to_string(ref_path).unwrap_or_default(); - let lang_content = fs::read_to_string(lang_path).unwrap_or_default(); - - let ref_sig = signature_of(&ref_content); - let lang_sig = signature_of(&lang_content); - - if ref_sig == lang_sig { - return Ok(()); - } - - let ref_lines: Vec<&str> = ref_content.lines().collect(); - let lang_lines: Vec<&str> = lang_content.lines().collect(); - - let mut diffs = Vec::new(); - let mut window = 0; - let max = ref_sig.len().max(lang_sig.len()); - for i in 0..max { - let ref_tok = ref_sig.get(i); - let lang_tok = lang_sig.get(i); - if ref_tok == lang_tok { - continue; - } - if window >= 5 { - diffs.push(format!("... ({}-line window truncated)", max - i)); - break; - } - window += 1; - let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>"); - let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>"); - diffs.push(format!( - " {}: {}", - "line".yellow().bold(), - (i + 1).to_string().yellow() - )); - diffs.push(format!( - " {} : {} {}", - "expect".green().bold(), - format!("`{}`", token_label(ref_tok.map_or("<eof>", String::as_str))).green(), - display_line(ref_line).cyan() - )); - diffs.push(format!( - " {} : {} {}", - "found".red().bold(), - format!( - "`{}`", - token_label(lang_tok.map_or("<eof>", String::as_str)) - ) - .red(), - display_line(lang_line).cyan() - )); - if ref_sig.len() != lang_sig.len() && window >= 5 { - diffs.push(format!( - " note: reference has {} lines, translation has {} lines", - ref_sig.len(), - lang_sig.len() - )); - break; - } - } - if diffs.is_empty() { - diffs.push("signatures differ in length (see line count note)".to_string()); - } - Err(diffs) -} - -/// Human-readable label for a structural token. -fn token_label(token: &str) -> String { - match token { - "B" => "blank".to_string(), - "A" => "@@@".to_string(), - "Q" => "quote".to_string(), - "L" => "list".to_string(), - "P" => "text".to_string(), - t if t.starts_with("H") => format!("heading-{}", &t[1..]), - t if t.starts_with("F:") => { - let lang = &t[2..]; - if lang.is_empty() { - "fence".to_string() - } else { - format!("fence:{lang}") - } - } - _ => token.to_string(), - } -} - -/// Render a source line for display: blank lines become `<blank>`. -fn display_line(line: &str) -> String { - if line.trim().is_empty() { - "<blank>".to_string() - } else { - truncate(line) - } -} - -/// Build the structural signature of a markdown file. -fn signature_of(content: &str) -> Vec<String> { - let mut sig = Vec::new(); - let mut in_fence = false; - let mut fence_lang = String::new(); - - for raw_line in content.lines() { - let line = raw_line.trim(); - - if in_fence { - if line.starts_with("```") { - in_fence = false; - sig.push(format!("F:{}", fence_lang)); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else { - sig.push("P".to_string()); - } - continue; - } - - if line.starts_with("```") { - in_fence = true; - fence_lang = line.trim_start_matches("```").trim().to_string(); - sig.push(format!("F:{fence_lang}")); - } else if line.starts_with('#') { - let level = line.chars().take_while(|c| *c == '#').count(); - sig.push(format!("H{level}")); - } else if line.starts_with("<h") || line.starts_with("</h") { - // HTML headings (e.g. `<h1 align="center">` / `</h1>`) - let level = line - .trim_start_matches(['<', '/']) - .chars() - .next() - .and_then(|c| c.to_digit(10)) - .unwrap_or(1); - sig.push(format!("H{level}")); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else if line.starts_with('>') { - sig.push("Q".to_string()); - } else if is_list_line(line) { - sig.push("L".to_string()); - } else { - sig.push("P".to_string()); - } - } - - // An unclosed fence is still a fence line; the signature already recorded it. - sig -} - -fn is_list_line(line: &str) -> bool { - let trimmed = line.trim_start(); - trimmed.starts_with("- ") - || trimmed.starts_with("* ") - || trimmed.starts_with("+ ") - || is_numbered_list(trimmed) -} - -/// A numbered list item: `1. text`, `1) text`, `10. text`, ... -fn is_numbered_list(line: &str) -> bool { - let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count(); - if digit_count == 0 { - return false; - } - let rest = &line[digit_count..]; - (rest.starts_with(". ") || rest.starts_with(") ")) - && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t') -} - -fn truncate(line: &str) -> String { - const MAX: usize = 60; - if line.chars().count() <= MAX { - line.to_string() - } else { - let cut: String = line.chars().take(MAX).collect(); - format!("{cut}...") - } -} - -fn find_git_repo() -> Option<PathBuf> { - let mut current = std::env::current_dir().ok()?; - loop { - if current.join(".git").is_dir() { - return Some(current); - } - current = current.parent()?.to_path_buf(); - } -} |
