aboutsummaryrefslogtreecommitdiff
path: root/.run/src/bin/check-docs-structure.rs
diff options
context:
space:
mode:
Diffstat (limited to '.run/src/bin/check-docs-structure.rs')
-rw-r--r--.run/src/bin/check-docs-structure.rs352
1 files changed, 352 insertions, 0 deletions
diff --git a/.run/src/bin/check-docs-structure.rs b/.run/src/bin/check-docs-structure.rs
new file mode 100644
index 0000000..ac13da2
--- /dev/null
+++ b/.run/src/bin/check-docs-structure.rs
@@ -0,0 +1,352 @@
+//! 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(&current) 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();
+ }
+}