From 273a2aab42ff9b650f42cd837516e5aff7f24a5b Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 25 Aug 2026 20:24:04 -0500 Subject: [PATCH 01/10] fix(lint): use as_chunks for the fixed-size RGBA strides clippy 1.98 now flags --- src/commands/preview.rs | 2 +- src/raster.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/preview.rs b/src/commands/preview.rs index 9249532..aa9e27f 100644 --- a/src/commands/preview.rs +++ b/src/commands/preview.rs @@ -170,7 +170,7 @@ mod tests { let path = dir.path().join("preview").join(name); let (w, h, rgba) = decode(&path); assert_eq!((w, h), (612, 842), "{name}"); - assert!(rgba.chunks_exact(4).all(|px| px[3] == 255)); + assert!(rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 255)); } } diff --git a/src/raster.rs b/src/raster.rs index b5188d5..4313ef9 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -111,7 +111,7 @@ impl<'a> PdfDocument<'a> { let mut rgba = pixmap.data_as_u8_slice().to_vec(); // Pages render on an opaque white base, so premultiplied equals direct; // force alpha anyway so compositors see a solid channel. - for px in rgba.chunks_exact_mut(4) { + for px in rgba.as_chunks_mut::<4>().0 { px[3] = 255; } let ink_coverage = ink_coverage(&rgba); @@ -164,7 +164,7 @@ impl<'a> PdfDocument<'a> { /// and antialiased edges count proportionally. pub fn ink_coverage(rgba: &[u8]) -> f64 { let mut ink = 0.0; - for px in rgba.chunks_exact(4) { + for px in rgba.as_chunks::<4>().0 { let luminance = (px[0] as u32 + px[1] as u32 + px[2] as u32) / 3; ink += 1.0 - luminance as f64 / 255.0; } @@ -193,7 +193,7 @@ mod tests { assert_eq!((page.width, page.height), (612, 842)); assert_eq!(page.rgba.len(), page.width * page.height * 4); assert!( - page.rgba.chunks_exact(4).all(|px| px[3] == 255), + page.rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 255), "alpha must be forced to 255" ); } From f1b1bf5cfe4edd93674b2ddbaa6d955d3bac5f86 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 25 Aug 2026 20:30:22 -0500 Subject: [PATCH 02/10] fix(spell): resolve LaTeX accent macros before word splitting --- src/linter/spell.rs | 687 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 651 insertions(+), 36 deletions(-) diff --git a/src/linter/spell.rs b/src/linter/spell.rs index a8c99a0..45a54d4 100644 --- a/src/linter/spell.rs +++ b/src/linter/spell.rs @@ -11,7 +11,7 @@ fn line_of(source: &str, offset: usize) -> usize { let offset = offset.min(source.len()); 1 + source[..offset].matches('\n').count() } -use crate::texparse::{tokenize_with_spans, Token}; +use crate::texparse::{tokenize_with_spans, SpannedToken, Token}; use crate::texutil::strip_empty_groups; /// Project-local whitelist filenames to check, in order. Also the order @@ -639,6 +639,334 @@ fn load_project_whitelist(root: &Path) -> HashSet { set } +const ACCENT_COMMANDS: &[&str] = &[ + "'", "`", "^", "\"", "~", "=", ".", "c", "v", "u", "H", "r", "k", +]; + +fn is_accent_command(name: &str) -> bool { + ACCENT_COMMANDS.contains(&name) +} + +fn is_letter_form_accent(name: &str) -> bool { + matches!(name, "c" | "v" | "u" | "H" | "r" | "k") +} + +fn accent_to_char(name: &str) -> Option { + match name { + "'" => Some('\''), + "`" => Some('`'), + "^" => Some('^'), + "\"" => Some('"'), + "~" => Some('~'), + "=" => Some('='), + "." => Some('.'), + "c" => Some('c'), + "v" => Some('v'), + "u" => Some('u'), + "H" => Some('H'), + "r" => Some('r'), + "k" => Some('k'), + _ => None, + } +} + +fn compose_accent(accent: char, base: char) -> Option { + Some(match (accent, base) { + ('\'', 'a') => 'á', + ('\'', 'e') => 'é', + ('\'', 'i') => 'í', + ('\'', 'o') => 'ó', + ('\'', 'u') => 'ú', + ('\'', 'y') => 'ý', + ('\'', 'A') => 'Á', + ('\'', 'E') => 'É', + ('\'', 'I') => 'Í', + ('\'', 'O') => 'Ó', + ('\'', 'U') => 'Ú', + ('\'', 'Y') => 'Ý', + ('`', 'a') => 'à', + ('`', 'e') => 'è', + ('`', 'i') => 'ì', + ('`', 'o') => 'ò', + ('`', 'u') => 'ù', + ('`', 'A') => 'À', + ('`', 'E') => 'È', + ('`', 'I') => 'Ì', + ('`', 'O') => 'Ò', + ('`', 'U') => 'Ù', + ('^', 'a') => 'â', + ('^', 'e') => 'ê', + ('^', 'i') => 'î', + ('^', 'o') => 'ô', + ('^', 'u') => 'û', + ('^', 'A') => 'Â', + ('^', 'E') => 'Ê', + ('^', 'I') => 'Î', + ('^', 'O') => 'Ô', + ('^', 'U') => 'Û', + ('"', 'a') => 'ä', + ('"', 'e') => 'ë', + ('"', 'i') => 'ï', + ('"', 'o') => 'ö', + ('"', 'u') => 'ü', + ('"', 'A') => 'Ä', + ('"', 'E') => 'Ë', + ('"', 'I') => 'Ï', + ('"', 'O') => 'Ö', + ('"', 'U') => 'Ü', + ('~', 'a') => 'ã', + ('~', 'n') => 'ñ', + ('~', 'o') => 'õ', + ('~', 'A') => 'Ã', + ('~', 'N') => 'Ñ', + ('~', 'O') => 'Õ', + ('=', 'a') => 'ā', + ('=', 'e') => 'ē', + ('=', 'i') => 'ī', + ('=', 'o') => 'ō', + ('=', 'u') => 'ū', + ('=', 'A') => 'Ā', + ('=', 'E') => 'Ē', + ('=', 'I') => 'Ī', + ('=', 'O') => 'Ō', + ('=', 'U') => 'Ū', + ('.', 'a') => 'ȧ', + ('.', 'e') => 'ė', + ('.', 'o') => 'ȯ', + ('.', 'A') => 'Ȧ', + ('.', 'E') => 'Ė', + ('.', 'O') => 'Ȯ', + ('c', 'c') => 'ç', + ('c', 'C') => 'Ç', + ('c', 's') => 'ş', + ('c', 'S') => 'Ş', + ('c', 't') => 'ţ', + ('c', 'T') => 'Ţ', + ('v', 'c') => 'č', + ('v', 'C') => 'Č', + ('v', 's') => 'š', + ('v', 'S') => 'Š', + ('v', 'z') => 'ž', + ('v', 'Z') => 'Ž', + ('v', 'e') => 'ě', + ('v', 'E') => 'Ě', + ('v', 'r') => 'ř', + ('v', 'R') => 'Ř', + ('v', 'n') => 'ň', + ('v', 'N') => 'Ň', + ('u', 'a') => 'ă', + ('u', 'A') => 'Ă', + ('u', 'e') => 'ĕ', + ('u', 'E') => 'Ĕ', + ('u', 'i') => 'ĭ', + ('u', 'I') => 'Ĭ', + ('u', 'o') => 'ŏ', + ('u', 'O') => 'Ŏ', + ('u', 'u') => 'ŭ', + ('u', 'U') => 'Ŭ', + ('H', 'o') => 'ő', + ('H', 'O') => 'Ő', + ('H', 'u') => 'ű', + ('H', 'U') => 'Ű', + ('r', 'a') => 'å', + ('r', 'A') => 'Å', + ('r', 'u') => 'ů', + ('r', 'U') => 'Ů', + ('k', 'a') => 'ą', + ('k', 'A') => 'Ą', + ('k', 'e') => 'ę', + ('k', 'E') => 'Ę', + _ => return None, + }) +} + +fn extract_base_from_text_start(text: &str) -> Option<(char, usize)> { + let first_non_ws = text.find(|c: char| !c.is_whitespace())?; + let remaining = &text[first_non_ws..]; + + if remaining.starts_with('{') && remaining.len() >= 3 { + let inner = &remaining[1..]; + if let Some(base) = inner.chars().next() { + if base.is_ascii_alphabetic() { + let after_base = &inner[base.len_utf8()..]; + if after_base.starts_with('}') { + let total_skip = first_non_ws + 1 + base.len_utf8() + 1; + return Some((base, total_skip)); + } + } + } + } + + let base = remaining.chars().next()?; + if base.is_ascii_alphabetic() { + Some((base, first_non_ws + base.len_utf8())) + } else { + None + } +} + +enum AccentBaseSource { + FromArgs, + FromNextText { + chars_to_skip: usize, + }, + FromDotlessIJ { + extra_tokens_to_skip: usize, + chars_to_skip_in_last: usize, + }, +} + +fn try_resolve_accent( + name: &str, + args: &[String], + tokens: &[SpannedToken], + accent_idx: usize, +) -> Option<(char, AccentBaseSource)> { + let accent_char = accent_to_char(name)?; + + if is_letter_form_accent(name) && !args.is_empty() { + let arg = args[0].trim(); + if arg.len() == 1 { + if let Some(base) = arg.chars().next() { + if base.is_ascii_alphabetic() { + if let Some(composed) = compose_accent(accent_char, base) { + return Some((composed, AccentBaseSource::FromArgs)); + } + } + } + } + } + + let next_idx = accent_idx + 1; + if next_idx >= tokens.len() { + return None; + } + + match &tokens[next_idx].token { + Token::Text(t) => { + if t == "{" { + let nn_idx = next_idx + 1; + if nn_idx < tokens.len() { + if let Token::Command { + name: ij_name, + args: ij_args, + } = &tokens[nn_idx].token + { + if (ij_name == "i" || ij_name == "j") && ij_args.is_empty() { + let nnn_idx = nn_idx + 1; + if nnn_idx < tokens.len() { + if let Token::Text(closing) = &tokens[nnn_idx].token { + if closing.starts_with('}') { + let base = if ij_name == "i" { 'i' } else { 'j' }; + if let Some(composed) = compose_accent(accent_char, base) { + let chars_to_skip = + if closing.len() > 1 { 1 } else { 0 }; + return Some(( + composed, + AccentBaseSource::FromDotlessIJ { + extra_tokens_to_skip: 3, + chars_to_skip_in_last: chars_to_skip, + }, + )); + } + } + } + } + } + } + } + } + + if let Some((base, skip)) = extract_base_from_text_start(t) { + if let Some(composed) = compose_accent(accent_char, base) { + return Some(( + composed, + AccentBaseSource::FromNextText { + chars_to_skip: skip, + }, + )); + } + } + + None + } + _ => None, + } +} + +fn build_spell_text(tokens: &[SpannedToken], source: &str) -> (String, Vec<(usize, usize)>) { + let mut out = String::new(); + let mut line_chunks: Vec<(usize, usize)> = Vec::new(); + let mut i = 0; + let mut pending_text_skip: usize = 0; + + while i < tokens.len() { + match &tokens[i].token { + Token::Text(t) => { + let skip = pending_text_skip; + pending_text_skip = 0; + let line = line_of(source, tokens[i].start); + let chunk = strip_empty_groups(&t[skip..]); + if !chunk.is_empty() { + line_chunks.push((out.len(), line)); + out.push_str(&chunk); + } + i += 1; + } + Token::Command { name, args } if is_accent_command(name) => { + match try_resolve_accent(name, args, tokens, i) { + Some((composed, source_kind)) => { + let line = line_of(source, tokens[i].start); + line_chunks.push((out.len(), line)); + out.push(composed); + i += 1; + match source_kind { + AccentBaseSource::FromArgs => {} + AccentBaseSource::FromNextText { chars_to_skip } => { + pending_text_skip = chars_to_skip; + } + AccentBaseSource::FromDotlessIJ { + extra_tokens_to_skip, + chars_to_skip_in_last, + } => { + i += extra_tokens_to_skip - 1; + pending_text_skip = chars_to_skip_in_last; + } + } + } + None => { + out.push(' '); + i += 1; + pending_text_skip = 0; + } + } + } + Token::Command { name, .. } if name == "i" || name == "j" => { + let line = line_of(source, tokens[i].start); + line_chunks.push((out.len(), line)); + out.push(if name == "i" { 'i' } else { 'j' }); + i += 1; + pending_text_skip = 0; + } + _ => { + out.push(' '); + i += 1; + pending_text_skip = 0; + } + } + } + + (out, line_chunks) +} + +fn line_for_offset(line_chunks: &[(usize, usize)], offset: usize) -> usize { + match line_chunks.binary_search_by_key(&offset, |(off, _)| *off) { + Ok(idx) => line_chunks[idx].1, + Err(0) => line_chunks.first().map_or(1, |&(_, l)| l), + Err(idx) => line_chunks[idx - 1].1, + } +} + /// Lint files for spelling mistakes. Returns warnings (never errors). /// If a dictionary cannot be obtained, returns Ok(vec![]) after printing a /// clear message (per spec: don't fail the build for missing dictionaries). @@ -711,31 +1039,21 @@ pub fn lint_files( for (rel, source) in files { let tokenized = tokenize_with_spans(source); - for sp in &tokenized.tokens { - if let Token::Text(text) = &sp.token { - // Empty LaTeX groups ({}) are a ligature workaround that - // produces no glyph (e.g. `workf{}lows` renders as - // `workflows`); strip them before splitting so the joined - // word is checked, not its fragments. - let text = strip_empty_groups(text); - // Extract candidate words by splitting on non-alpha characters - for word in text.split(|c: char| !c.is_alphabetic()) { - let w = word.trim(); - if w.is_empty() { - continue; - } - let wl = w.to_lowercase(); - if wl.len() <= 1 { - // skip short tokens to avoid noisy single-letter misses - continue; - } - if !dict.contains(&wl) && !whitelist.contains(&wl) { - // record first occurrence only - unknowns - .entry(wl) - .or_insert_with(|| (rel.clone(), line_of(source, sp.start))); - } - } + let (spell_text, line_chunks) = build_spell_text(&tokenized.tokens, source); + let spell_base = spell_text.as_ptr() as usize; + for word in spell_text.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.is_empty() { + continue; + } + let wl = w.to_lowercase(); + if wl.len() <= 1 { + continue; + } + if !dict.contains(&wl) && !whitelist.contains(&wl) { + let word_offset = word.as_ptr() as usize - spell_base; + let line = line_for_offset(&line_chunks, word_offset); + unknowns.entry(wl).or_insert_with(|| (rel.clone(), line)); } } } @@ -760,27 +1078,41 @@ pub fn lint_files( #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::TempDir; + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + #[test] fn tokenizer_integration_does_not_flag_commands_or_labels() { - let src = r#"\documentclass{article} -\begin{document} -Hello world. This is some text. \label{sec:intro} More text. -\end{document}"#; - // Create a tiny english dictionary that contains common words - let tmp = TempDir::new().unwrap(); - let dict_dir = tmp.path().join(".texforge").join("dicts"); - fs::create_dir_all(&dict_dir).unwrap(); + let _lock = ENV_MUTEX.lock().unwrap(); + let home = TempDir::new().unwrap(); + let dicts_dir = home.path().join(".texforge").join("dicts"); + fs::create_dir_all(&dicts_dir).unwrap(); fs::write( - dict_dir.join("english.txt"), + dicts_dir.join("english.txt"), "hello\nworld\nthis\nis\nsome\ntext\nmore\n", ) .unwrap(); + let orig_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + + let src = r#"\documentclass{article} +\begin{document} +Hello world. This is some text. \label{sec:intro} More text. +\end{document}"#; + // Run lint_files against a single file let files = vec![("main.tex".to_string(), src.to_string())]; - let findings = lint_files(&files, tmp.path(), Some("english")).unwrap(); + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), Some("english")).unwrap(); + + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + // Should be empty: the words used are in the tiny dictionary and commands/labels not emitted assert!( findings.is_empty(), @@ -863,6 +1195,7 @@ Hello world. This is some text. \label{sec:intro} More text. #[test] fn ensure_dictionary_bails_in_test_harness_environment() { + let _lock = ENV_MUTEX.lock().unwrap(); // Simulate being run under a test harness like nextest by setting a // recognized environment variable. ensure_dictionary must not attempt // network activity in this case and should return an Err. @@ -1124,6 +1457,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// checking Spanish prose against English words. #[test] fn spanish_document_with_only_english_dictionary_emits_no_unknown_word_warnings() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1167,6 +1501,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// proven by a Spanish word passing and an English-only word failing. #[test] fn spanish_document_checks_against_spanish_dictionary_not_english() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1210,6 +1545,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// findings) so the disagreement warning is the only finding produced. #[test] fn disagreement_warning_names_both_languages_and_points_at_declaration() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1257,6 +1593,7 @@ Hello world. This is some text. \label{sec:intro} More text. #[test] fn no_disagreement_warning_when_declared_matches_configured_default() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); @@ -1285,6 +1622,7 @@ Hello world. This is some text. \label{sec:intro} More text. #[test] fn no_disagreement_warning_without_babel_declaration() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1321,6 +1659,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// produce exactly one warning, not one per file. #[test] fn multi_file_project_with_matching_declarations_produces_one_warning() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1424,6 +1763,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// one language, `ensure_dictionary` must choose the Hunspell pair. #[test] fn ensure_dictionary_prefers_hunspell_pair_when_both_present() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1454,6 +1794,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// missing-dictionary path and its skip message), never a panic. #[test] fn lint_files_treats_dic_without_aff_as_no_dictionary_available() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1490,6 +1831,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// genuine misspelling (requirement 10). #[test] fn spanish_document_checks_against_installed_hunspell_pair() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1538,6 +1880,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// workaround. #[test] fn ligature_workaround_empty_groups_are_checked_as_joined_words() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1574,6 +1917,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// since a fragment is not something the author can search for. #[test] fn misspelled_word_with_empty_group_is_reported_as_joined_word() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1618,6 +1962,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// mid-word case. #[test] fn empty_group_at_start_end_and_doubled_behave_sanely() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let dicts_dir = home.path().join(".texforge").join("dicts"); fs::create_dir_all(&dicts_dir).unwrap(); @@ -1650,6 +1995,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// path a user already tried before this feature existed (decision 2). #[test] fn global_whitelist_path_is_home_texforge_spell_words() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let orig_home = std::env::var("HOME").ok(); std::env::set_var("HOME", home.path()); @@ -1678,6 +2024,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// (requirement 6). #[test] fn a_global_only_word_is_accepted_in_a_project_with_no_whitelist_file() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1721,6 +2068,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// is best-effort, same as the project-local files. #[test] fn missing_global_whitelist_yields_no_error_and_no_findings_change() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1762,6 +2110,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// are both accepted together (decision 3). #[test] fn project_and_global_whitelists_union_rather_than_override() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1802,6 +2151,7 @@ Hello world. This is some text. \label{sec:intro} More text. /// `--global` since global became the default (requirement 9). #[test] fn unknown_word_suggestion_names_spell_add_and_local_flag() { + let _lock = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); fs::create_dir_all(home.path().join(".texforge").join("dicts")).unwrap(); fs::write( @@ -1845,4 +2195,269 @@ Hello world. This is some text. \label{sec:intro} More text. suggestion ); } + + // --- TF-spell: accent macro resolution --- + + fn run_with_home(spanish_words: &str, english_words: &str, f: F) -> R + where + F: FnOnce() -> R, + { + let _lock = ENV_MUTEX.lock().unwrap(); + let home = TempDir::new().unwrap(); + let dicts_dir = home.path().join(".texforge").join("dicts"); + fs::create_dir_all(&dicts_dir).unwrap(); + fs::write(dicts_dir.join("spanish.txt"), spanish_words).unwrap(); + fs::write(dicts_dir.join("english.txt"), english_words).unwrap(); + + let orig_home = std::env::var("HOME").ok(); + let orig_nex = std::env::var("NEXTEST_RUN_ID").ok(); + std::env::set_var("HOME", home.path()); + std::env::set_var("NEXTEST_RUN_ID", "tf-spell-accent"); + + let result = f(); + + std::env::remove_var("NEXTEST_RUN_ID"); + if let Some(v) = orig_nex { + std::env::set_var("NEXTEST_RUN_ID", v); + } + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + result + } + + #[test] + fn symbol_form_accents_resolve_brace_and_direct() { + run_with_home("violación\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + violaci\\'{o}n\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "violación (brace form) must not be flagged: {:?}", + unknown + ); + }); + } + + #[test] + fn symbol_form_accents_resolve_space_form() { + run_with_home("café\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + caf\\' e\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "café (symbol-form space variant \\' e) must not be flagged: {:?}", + unknown + ); + }); + } + + #[test] + fn letter_form_accents_resolve_brace_form_through_lint_files() { + run_with_home("français\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + fran\\c{c}ais\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + assert!( + !findings.iter().any(|f| f.message.contains("fran")), + "français (letter-form \\c{{c}}) must not produce 'fran': {:?}", + findings + ); + assert!( + !findings.iter().any(|f| f.message.contains("'ais'")), + "français (letter-form \\c{{c}}) must not eat 'ais': {:?}", + findings + ); + }); + } + + #[test] + fn letter_form_accents_resolve_space_form_through_lint_files() { + run_with_home("č\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + \\v c\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "\\v c (space form) must resolve: {:?}", + unknown + ); + }); + } + + #[test] + fn spanish_document_with_accent_macros_produces_zero_warnings() { + run_with_home( + "universidad\ncoincidencia\ncomparación\nnúmero\nmás\naquí\n", + "hello\nworld\n", + || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + universidad coincidencia comparaci\\'{o}n n\\'umero \ + m\\'as aqu\\'{\\i}\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "Spanish document with accent macros must produce zero unknown-word warnings: {:?}", + unknown + ); + }, + ); + } + + #[test] + fn document_with_letter_form_macro_produces_zero_warnings() { + run_with_home("čeština\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + \\v{c}e\\v{s}tina\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "document with letter-form macros must produce zero warnings: {:?}", + unknown + ); + }); + } + + #[test] + fn misspelling_with_accent_macro_is_reported_as_composed_word() { + run_with_home("hola\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + xyz\\'{a}bc\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + assert!( + findings.iter().any(|f| f.message.contains("xyzábc")), + "misspelling with accent must be reported as composed word 'xyzábc': {:?}", + findings + ); + }); + } + + #[test] + fn fran_c_ais_does_not_eat_following_words() { + run_with_home( + "français\nmás\npalabras\n", + "hello\nworld\nmore\nwords\n", + || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + fran\\c{c}ais m\\'as palabras\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "fran\\c{{c}}ais m\\'as palabras must produce zero unknown-word warnings \ + (must not eat 'ais', 'm\\'as', or 'palabras'): {:?}", + unknown + ); + }, + ); + } + + #[test] + fn unknown_macro_breaks_word_rather_than_absorbing_letters() { + run_with_home("hola\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + abc\\unknownmacro def\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown_words: Vec<_> = findings + .iter() + .filter_map(|f| { + f.message + .strip_prefix("Unknown word: '") + .and_then(|s| s.strip_suffix('\'')) + .map(String::from) + }) + .collect(); + assert!( + unknown_words.contains(&"abc".to_string()), + "unknown macro must break word, leaving 'abc' to be checked: {:?}", + unknown_words + ); + assert!( + unknown_words.contains(&"def".to_string()), + "text after unknown macro must not be swallowed: {:?}", + unknown_words + ); + }); + } + + #[test] + fn dotless_i_with_accent_resolves() { + run_with_home("mercurio\níndice\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + mercurio \\'{\\i}ndice\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + !unknown.iter().any(|f| f.message.contains("'ndice'")), + "\\ '{{\\i}} must resolve to 'í', not leave 'ndice': {:?}", + unknown + ); + }); + } + + #[test] + fn all_accent_forms_resolve_via_helper() { + assert_eq!(compose_accent('\'', 'e'), Some('é')); + assert_eq!(compose_accent('`', 'a'), Some('à')); + assert_eq!(compose_accent('^', 'o'), Some('ô')); + assert_eq!(compose_accent('"', 'u'), Some('ü')); + assert_eq!(compose_accent('~', 'n'), Some('ñ')); + assert_eq!(compose_accent('=', 'a'), Some('ā')); + assert_eq!(compose_accent('.', 'e'), Some('ė')); + assert_eq!(compose_accent('c', 'c'), Some('ç')); + assert_eq!(compose_accent('v', 's'), Some('š')); + assert_eq!(compose_accent('u', 'a'), Some('ă')); + assert_eq!(compose_accent('H', 'u'), Some('ű')); + assert_eq!(compose_accent('r', 'a'), Some('å')); + assert_eq!(compose_accent('k', 'e'), Some('ę')); + } } From fd08c097f85adb54de1a582493b71b5f2da15982 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 25 Aug 2026 20:34:59 -0500 Subject: [PATCH 03/10] fix(install): give the skills wizard a terminal and degrade cleanly without one --- scripts/install.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 37e81d0..e931331 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -130,7 +130,7 @@ SKILLS_REPO="https://github.com/UniverLab/skills" if [ -n "${SKIP_SKILL:-}" ]; then info "skill" "skipped (SKIP_SKILL set)" -elif command -v npx >/dev/null 2>&1; then +elif command -v npx >/dev/null 2>&1 && (exec /dev/null; then printf '\n \033[1;36m?\033[0m Install the \033[1m%s\033[0m agent skill? (teaches AI agents how to use %s) [Y/n] ' "$SKILL" "$SKILL" read -r ANSWER /dev/null 2>&1; then ;; *) info "skill" "adding '$SKILL' (npx skills add)" - if npx -y skills add "$SKILLS_REPO" --skill "$SKILL"; then + if npx -y skills add "$SKILLS_REPO" --skill "$SKILL" /dev/null 2>&1; then ;; esac else - info "skill" "npx not found — add later with: npx skills add $SKILLS_REPO --skill $SKILL" + info "skill" "skipped — add later with: npx skills add $SKILLS_REPO --skill $SKILL" fi # ============================================================ From c579ea80d7acb16c2dbb1f14b2d5777382a8240d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 25 Aug 2026 20:49:14 -0500 Subject: [PATCH 04/10] feat(templates): add TTL-based cache refresh with offline fallback --- docs/cli-reference.md | 2 + src/cli/mod.rs | 6 + src/commands/template.rs | 15 ++ src/templates/mod.rs | 348 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 363 insertions(+), 8 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index bdd2124..5880d0e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -42,6 +42,8 @@ texforge [options] | `texforge template add ` | Download a template from the registry | | `texforge template remove ` | Remove an installed template | | `texforge template validate ` | Verify template compatibility | +| `texforge template refresh` | Refresh all cached templates (bypass TTL) | +| `texforge template refresh ` | Refresh one cached template (bypass TTL) | ## Spell-Check diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 61c3729..20c6a95 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -189,6 +189,11 @@ enum TemplateAction { Remove { name: String }, /// Validate template compatibility Validate { name: String }, + /// Refresh cached templates (bypass TTL; all templates or one by name) + Refresh { + /// Template name to refresh (omit to refresh all cached templates) + name: Option, + }, } impl Cli { @@ -235,6 +240,7 @@ impl Cli { TemplateAction::Add { source } => commands::template::add(&source), TemplateAction::Remove { name } => commands::template::remove(&name), TemplateAction::Validate { name } => commands::template::validate(&name), + TemplateAction::Refresh { name } => commands::template::refresh(name.as_deref()), }, Commands::Spell { action } => { let action = match action { diff --git a/src/commands/template.rs b/src/commands/template.rs index 77c878d..466d7b5 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -71,6 +71,21 @@ pub fn validate(name: &str) -> Result<()> { Ok(()) } +/// Refresh cached templates, bypassing the TTL. +pub fn refresh(name: Option<&str>) -> Result<()> { + match name { + Some(n) => { + println!("Refreshing template '{}'...", n); + templates::refresh(n)?; + println!(" ◇ Template '{}' refreshed", n); + } + None => { + templates::refresh_all()?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/templates/mod.rs b/src/templates/mod.rs index cb98174..cfee5e9 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; @@ -9,6 +10,22 @@ use crate::utils; const REGISTRY_REPO: &str = "UniverLab/texforge-templates"; +/// How long a cached template is considered fresh before `resolve` attempts a +/// background refresh. Twenty-four hours is a defensible default: templates +/// rarely change more than once a day, but when they do (a bug fix, a new +/// section), a user who builds at least once a day picks up the fix within a +/// day without any manual intervention. Shorter would hit the network on +/// almost every build; longer would leave users on a broken template for too +/// long. Stored as seconds since the Unix epoch. +const CACHE_TTL_SECS: u64 = 86_400; + +const CACHE_META_FILE: &str = ".cache_meta.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct CacheMeta { + fetched_at: u64, +} + /// Embedded files for the "general" template (fallback when offline). const GENERAL_TEMPLATE_TOML: &str = include_str!("general/template.toml"); const GENERAL_MAIN_TEX: &str = include_str!("general/main.tex"); @@ -21,11 +38,24 @@ pub struct ResolvedTemplate { pub files: HashMap>, } -/// Resolve a template by name: local cache → download → embedded fallback. +/// Resolve a template by name: fresh cache → refresh stale cache → download → embedded fallback. pub fn resolve(name: &str) -> Result { // 1. Check local cache - if let Ok(t) = load_from_cache(name) { - return Ok(t); + if let Ok((t, fetched_at)) = load_from_cache_with_meta(name) { + if !is_stale(fetched_at) { + return Ok(t); + } + // Stale: attempt a refresh, but fall back to the cached copy on failure. + match download(name) { + Ok(fresh) => return Ok(fresh), + Err(e) => { + eprintln!( + "texforge: could not refresh template '{}' ({}); using cached copy", + name, e + ); + return Ok(t); + } + } } // 2. Try downloading from GitHub @@ -45,6 +75,17 @@ pub fn resolve(name: &str) -> Result { ); } +fn is_stale(fetched_at: Option) -> bool { + let Some(fetched_at) = fetched_at else { + return true; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(fetched_at) > CACHE_TTL_SECS +} + fn embedded_general() -> ResolvedTemplate { let mut files = HashMap::new(); files.insert( @@ -63,12 +104,32 @@ fn embedded_general() -> ResolvedTemplate { ResolvedTemplate { files } } -fn load_from_cache(name: &str) -> Result { +fn load_from_cache_with_meta(name: &str) -> Result<(ResolvedTemplate, Option)> { let dir = utils::templates_dir()?.join(name); if !dir.is_dir() { anyhow::bail!("not cached"); } - load_dir_recursive(&dir) + let t = load_dir_recursive(&dir)?; + let fetched_at = read_cache_meta(&dir); + Ok((t, fetched_at)) +} + +fn read_cache_meta(dir: &Path) -> Option { + let meta_path = dir.join(CACHE_META_FILE); + let contents = std::fs::read_to_string(&meta_path).ok()?; + let meta: CacheMeta = serde_json::from_str(&contents).ok()?; + Some(meta.fetched_at) +} + +fn write_cache_meta(dir: &Path) -> Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let meta = CacheMeta { fetched_at: now }; + let json = serde_json::to_string(&meta)?; + std::fs::write(dir.join(CACHE_META_FILE), json)?; + Ok(()) } fn load_dir_recursive(base: &Path) -> Result { @@ -83,6 +144,9 @@ fn load_dir_recursive(base: &Path) -> Result { .strip_prefix(base)? .to_string_lossy() .to_string(); + if rel == CACHE_META_FILE { + continue; + } let content = std::fs::read(entry.path())?; files.insert(rel, content); } @@ -92,6 +156,25 @@ fn load_dir_recursive(base: &Path) -> Result { /// Download a template tarball from GitHub and cache it locally. pub fn download(name: &str) -> Result { + #[cfg(test)] + { + let override_fn = TEST_DOWNLOAD_OVERRIDE.with(|o| o.borrow().as_ref().map(|f| f(name))); + if let Some(result) = override_fn { + let files = result?; + let cache_dir = utils::templates_dir()?.join(name); + std::fs::create_dir_all(&cache_dir)?; + for (rel, content) in &files { + let dest = cache_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, content)?; + } + write_cache_meta(&cache_dir)?; + return Ok(ResolvedTemplate { files }); + } + } + let url = format!( "https://api.github.com/repos/{}/tarball/main", REGISTRY_REPO @@ -112,6 +195,7 @@ pub fn download(name: &str) -> Result { let mut archive = tar::Archive::new(decoder); let cache_dir = utils::templates_dir()?.join(name); + let cache_existed = cache_dir.is_dir(); let mut files = HashMap::new(); let prefix = format!("{}/", name); @@ -145,14 +229,40 @@ pub fn download(name: &str) -> Result { } if files.is_empty() { - // Clean up empty cache dir - let _ = std::fs::remove_dir_all(&cache_dir); + if !cache_existed { + let _ = std::fs::remove_dir_all(&cache_dir); + } anyhow::bail!("Template '{}' not found in registry", name); } + write_cache_meta(&cache_dir)?; + Ok(ResolvedTemplate { files }) } +#[cfg(test)] +type DownloadOverride = + Box std::result::Result>, anyhow::Error>>; + +#[cfg(test)] +thread_local! { + static TEST_DOWNLOAD_OVERRIDE: std::cell::RefCell> = + std::cell::RefCell::new(None); +} + +#[cfg(test)] +fn set_download_override(f: F) +where + F: Fn(&str) -> std::result::Result>, anyhow::Error> + 'static, +{ + TEST_DOWNLOAD_OVERRIDE.with(|o| *o.borrow_mut() = Some(Box::new(f))); +} + +#[cfg(test)] +fn clear_download_override() { + TEST_DOWNLOAD_OVERRIDE.with(|o| *o.borrow_mut() = None); +} + /// List template names available in the remote registry. pub fn list_remote() -> Result> { let url = format!("https://api.github.com/repos/{}/contents", REGISTRY_REPO); @@ -212,6 +322,44 @@ pub fn remove_cached(name: &str) -> Result { Ok(dir) } +/// Force-refresh a single cached template, bypassing the TTL. +/// If the download fails and a cached copy exists, the cache is kept. +pub fn refresh(name: &str) -> Result<()> { + let dir = utils::templates_dir()?.join(name); + let had_cache = dir.is_dir(); + match download(name) { + Ok(_) => Ok(()), + Err(e) if had_cache => { + eprintln!( + "texforge: could not refresh template '{}' ({}); keeping cached copy", + name, e + ); + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Force-refresh every cached template, bypassing the TTL. +/// Templates that fail to refresh are reported on stderr but do not abort. +pub fn refresh_all() -> Result<()> { + let names = list_cached()?; + if names.is_empty() { + println!("No cached templates to refresh."); + return Ok(()); + } + for name in &names { + print!("Refreshing '{}'... ", name); + match download(name.as_str()) { + Ok(_) => println!("done"), + Err(e) => { + println!("failed ({})", e); + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -362,7 +510,7 @@ mod tests { #[test] fn load_from_cache_nonexistent_errors() { - let result = load_from_cache("no-such-template-xyz-abc"); + let result = load_from_cache_with_meta("no-such-template-xyz-abc"); assert!(result.is_err()); } @@ -382,4 +530,188 @@ mod tests { let result = list_cached(); assert!(result.is_ok()); } + + #[test] + fn is_stale_returns_false_for_fresh_entry() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(!is_stale(Some(now))); + assert!(!is_stale(Some(now - CACHE_TTL_SECS / 2))); + } + + #[test] + fn is_stale_returns_true_for_old_entry() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(is_stale(Some(now - CACHE_TTL_SECS - 1))); + assert!(is_stale(Some(0))); + } + + #[test] + fn is_stale_returns_true_when_no_metadata() { + assert!(is_stale(None)); + } + + #[test] + fn fresh_cache_served_without_network() { + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_name = "__test_fresh_cache__"; + let test_dir = templates_dir.join(test_name); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("main.tex"), "fresh-content").unwrap(); + write_cache_meta(&test_dir).unwrap(); + + let result = resolve(test_name).unwrap(); + assert!(result.files.contains_key("main.tex")); + assert_eq!(result.files.get("main.tex").unwrap(), b"fresh-content"); + + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn stale_cache_falls_back_when_refresh_fails() { + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_name = "__test_stale_fallback__"; + let test_dir = templates_dir.join(test_name); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("main.tex"), "stale-content").unwrap(); + let old_meta = CacheMeta { fetched_at: 0 }; + std::fs::write( + test_dir.join(CACHE_META_FILE), + serde_json::to_string(&old_meta).unwrap(), + ) + .unwrap(); + + ensure_rustls(); + let result = resolve(test_name).unwrap(); + assert!(result.files.contains_key("main.tex")); + assert_eq!(result.files.get("main.tex").unwrap(), b"stale-content"); + + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn cache_without_meta_is_treated_as_stale() { + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_name = "__test_no_meta_stale__"; + let test_dir = templates_dir.join(test_name); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("main.tex"), "no-meta-content").unwrap(); + + let (_, fetched_at) = load_from_cache_with_meta(test_name).unwrap(); + assert!(fetched_at.is_none()); + assert!(is_stale(fetched_at)); + + ensure_rustls(); + let result = resolve(test_name).unwrap(); + assert_eq!(result.files.get("main.tex").unwrap(), b"no-meta-content"); + + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn write_and_read_cache_meta_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + write_cache_meta(tmp.path()).unwrap(); + let fetched_at = read_cache_meta(tmp.path()); + assert!(fetched_at.is_some()); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!((fetched_at.unwrap() as i64 - now as i64).unsigned_abs() < 5); + } + + #[test] + fn load_dir_recursive_skips_cache_meta_file() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("main.tex"), "content").unwrap(); + fs::write(tmp.path().join(CACHE_META_FILE), "{}").unwrap(); + + let result = load_dir_recursive(tmp.path()).unwrap(); + assert!(result.files.contains_key("main.tex")); + assert!(!result.files.contains_key(CACHE_META_FILE)); + assert_eq!(result.files.len(), 1); + } + + #[test] + fn embedded_fallback_works_when_no_cache() { + ensure_rustls(); + let result = resolve("general").unwrap(); + assert!(result.files.contains_key("main.tex")); + assert!(result.files.contains_key("template.toml")); + } + + #[test] + fn stale_cache_refresh_returns_fresh_content() { + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_name = "__test_stale_refresh__"; + let test_dir = templates_dir.join(test_name); + + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("main.tex"), "stale-content").unwrap(); + let old_meta = CacheMeta { fetched_at: 0 }; + std::fs::write( + test_dir.join(CACHE_META_FILE), + serde_json::to_string(&old_meta).unwrap(), + ) + .unwrap(); + + let old_ts = read_cache_meta(&test_dir).unwrap(); + + set_download_override(|_name| { + let mut files = HashMap::new(); + files.insert("main.tex".into(), b"fresh-content".to_vec()); + Ok(files) + }); + + let result = resolve(test_name).unwrap(); + assert_eq!(result.files.get("main.tex").unwrap(), b"fresh-content"); + + let new_ts = read_cache_meta(&test_dir).unwrap(); + assert!(new_ts > old_ts); + + clear_download_override(); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn refresh_bypasses_ttl_on_fresh_entry() { + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_name = "__test_refresh_bypass_ttl__"; + let test_dir = templates_dir.join(test_name); + + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("main.tex"), "original-content").unwrap(); + write_cache_meta(&test_dir).unwrap(); + + let pre_ts = read_cache_meta(&test_dir).unwrap(); + assert!(!is_stale(Some(pre_ts))); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + set_download_override(|_name| { + let mut files = HashMap::new(); + files.insert("main.tex".into(), b"refreshed-content".to_vec()); + Ok(files) + }); + + refresh(test_name).unwrap(); + + let result = load_from_cache_with_meta(test_name).unwrap(); + assert_eq!( + result.0.files.get("main.tex").unwrap(), + b"refreshed-content" + ); + + let post_ts = result.1.unwrap(); + assert!(post_ts > pre_ts); + + clear_download_override(); + std::fs::remove_dir_all(&test_dir).unwrap(); + } } From da4cca498d5fd46635ba0bb2268af32a98376477 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 08:31:41 -0500 Subject: [PATCH 05/10] feat(uninstall): add texforge uninstall command --- docs/cli-reference.md | 11 + docs/installation.md | 16 +- src/cli/mod.rs | 17 ++ src/commands/mod.rs | 1 + src/commands/uninstall.rs | 508 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 src/commands/uninstall.rs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5880d0e..830a2a5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -93,6 +93,17 @@ Default scope is global (`~/.texforge/spell-words`). Both scopes are unioned at |---|---| | `texforge doctor` | Diagnose Tectonic, cache, fonts, dictionaries, and project | +## Uninstall + +| Command | Description | +|---|---| +| `texforge uninstall` | Remove everything texforge manages under `~/.texforge` | +| `texforge uninstall --yes` | Skip the confirmation prompt | +| `texforge uninstall --dry-run` | Print the plan without removing anything | +| `texforge uninstall --include-spell-words` | Also remove the personal spell dictionary (preserved by default) | + +The texforge binary itself is never removed by this command. The personal spell dictionary (`~/.texforge/spell-words`) contains your own writing and is preserved unless `--include-spell-words` is passed. + ## Configuration | Command | Description | diff --git a/docs/installation.md b/docs/installation.md index ffb289a..e5692c5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -90,7 +90,19 @@ x86_64 are published on the ## Uninstall +To remove everything texforge manages (Tectonic engine, template cache, dictionary cache, configuration): + ```bash -rm -f ~/.local/bin/texforge # texforge binary -rm -rf ~/.texforge/ # tectonic engine + cached templates +texforge uninstall ``` + +This shows what it would remove and asks for confirmation. The personal spell dictionary (`~/.texforge/spell-words`) is preserved by default — it contains your own writing. To remove it as well: + +```bash +texforge uninstall --include-spell-words +``` + +The texforge binary itself is not removed by this command. To remove it: + +- **If installed via the quick installer or a direct download:** `rm -f ~/.local/bin/texforge` (or the path shown by `texforge uninstall`). +- **If installed via cargo:** `cargo uninstall texforge`. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 20c6a95..63ad277 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -113,6 +113,18 @@ enum Commands { }, /// Diagnose the managed environment (Tectonic, cache, fonts, dictionaries, project) Doctor, + /// Remove everything texforge manages under ~/.texforge + Uninstall { + /// Skip the confirmation prompt + #[arg(long)] + yes: bool, + /// Print the plan without removing anything + #[arg(long)] + dry_run: bool, + /// Also remove the personal spell dictionary (your own writing) + #[arg(long)] + include_spell_words: bool, + }, /// Manage global configuration Config { /// Key to get/set (name, email, institution, language) @@ -267,6 +279,11 @@ impl Cli { commands::spell::execute(action) } Commands::Doctor => commands::doctor::execute(), + Commands::Uninstall { + yes, + dry_run, + include_spell_words, + } => commands::uninstall::execute(yes, dry_run, include_spell_words), Commands::Config { key, value } => match (key, value) { (None, None) => commands::config::wizard(), (Some(k), None) if k == "list" => commands::config::list(), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2bbca49..f9adf35 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,3 +14,4 @@ pub mod preview; pub mod spell; pub mod stats; pub mod template; +pub mod uninstall; diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs new file mode 100644 index 0000000..3e3300b --- /dev/null +++ b/src/commands/uninstall.rs @@ -0,0 +1,508 @@ +//! `texforge uninstall` command implementation. +//! +//! Removes everything texforge manages under `~/.texforge`: the downloaded +//! Tectonic engine, the template cache, the dictionary cache, configuration, +//! and (optionally) the personal spell dictionary. The texforge binary itself +//! is never touched — the command reports how it was installed so the user +//! can remove it themselves. +//! +//! The personal spell dictionary (`~/.texforge/spell-words`) is the user's +//! own writing, not a cache. It is preserved by default and only removed +//! when `--include-spell-words` is passed. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +#[derive(Debug, Clone)] +struct PlanItem { + label: &'static str, + path: PathBuf, + is_dir: bool, + exists: bool, + size: u64, + is_spell_words: bool, +} + +#[derive(Debug)] +struct Plan { + items: Vec, + binary_note: String, + spell_words_included: bool, +} + +pub fn execute(yes: bool, dry_run: bool, include_spell_words: bool) -> Result<()> { + let Some(data_dir) = texforge_data_dir() else { + println!("Could not determine home directory."); + return Ok(()); + }; + execute_at(&data_dir, yes, dry_run, include_spell_words) +} + +fn execute_at(data_dir: &Path, yes: bool, dry_run: bool, include_spell_words: bool) -> Result<()> { + let plan = build_plan_at(data_dir, include_spell_words)?; + print_plan(&plan); + + if dry_run { + println!(); + println!("Dry run — nothing was removed."); + return Ok(()); + } + + let removable: Vec<&PlanItem> = plan + .items + .iter() + .filter(|item| item.exists && (!item.is_spell_words || plan.spell_words_included)) + .collect(); + + if removable.is_empty() { + println!(); + println!("Nothing to remove."); + return Ok(()); + } + + if !yes { + let confirmed = inquire::Confirm::new(" Proceed with removal?") + .with_default(false) + .prompt() + .context("failed to read confirmation")?; + if !confirmed { + println!("Aborted."); + return Ok(()); + } + } + + execute_plan(&removable)?; + + try_remove_data_dir(data_dir)?; + + println!(); + println!("{}", plan.binary_note); + + Ok(()) +} + +fn build_plan_at(data_dir: &Path, include_spell_words: bool) -> Result { + let mut items = Vec::new(); + + let bin_dir = data_dir.join("bin"); + items.push(PlanItem { + label: "Managed Tectonic engine", + exists: bin_dir.exists(), + size: dir_size(&bin_dir), + path: bin_dir, + is_dir: true, + is_spell_words: false, + }); + + let templates_dir = data_dir.join("templates"); + items.push(PlanItem { + label: "Template cache", + exists: templates_dir.exists(), + size: dir_size(&templates_dir), + path: templates_dir, + is_dir: true, + is_spell_words: false, + }); + + let dicts_dir = data_dir.join("dicts"); + items.push(PlanItem { + label: "Dictionary cache", + exists: dicts_dir.exists(), + size: dir_size(&dicts_dir), + path: dicts_dir, + is_dir: true, + is_spell_words: false, + }); + + let spell_words = data_dir.join("spell-words"); + items.push(PlanItem { + label: "Personal spell dictionary (your own writing)", + exists: spell_words.exists(), + size: file_size(&spell_words), + path: spell_words, + is_dir: false, + is_spell_words: true, + }); + + let other_items = collect_other_items(data_dir, &items); + items.extend(other_items); + + let binary_note = binary_install_note(); + + Ok(Plan { + items, + binary_note, + spell_words_included: include_spell_words, + }) +} + +fn collect_other_items(data_dir: &Path, existing: &[PlanItem]) -> Vec { + let mut others = Vec::new(); + let Ok(entries) = std::fs::read_dir(data_dir) else { + return others; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str == "bin" + || name_str == "templates" + || name_str == "dicts" + || name_str == "spell-words" + { + continue; + } + if existing.iter().any(|item| item.path == path) { + continue; + } + let is_dir = path.is_dir(); + let size = if is_dir { + dir_size(&path) + } else { + file_size(&path) + }; + let label: &'static str = if name_str == "config.toml" { + "Configuration" + } else { + Box::leak(format!("Other: {name_str}").into_boxed_str()) as &str + }; + others.push(PlanItem { + label, + path, + is_dir, + exists: true, + size, + is_spell_words: false, + }); + } + others +} + +fn print_plan(plan: &Plan) { + println!("texforge uninstall"); + println!(); + + let mut total: u64 = 0; + let mut any_exists = false; + + for item in &plan.items { + if item.is_spell_words && !plan.spell_words_included { + println!( + " {} (preserved — pass --include-spell-words to remove)", + item.label + ); + if item.exists { + println!(" path: {}", item.path.display()); + println!(" size: {}", format_size(item.size)); + any_exists = true; + } + continue; + } + if !item.exists { + println!(" {} — not present", item.label); + continue; + } + any_exists = true; + println!(" {} — {}", item.label, format_size(item.size)); + println!(" path: {}", item.path.display()); + total += item.size; + } + + println!(); + if any_exists { + println!(" Total to remove: {}", format_size(total)); + } else { + println!(" Nothing present under ~/.texforge."); + } +} + +fn execute_plan(items: &[&PlanItem]) -> Result<()> { + println!(); + let mut any_failed = false; + for item in items { + print!(" Removing {}... ", item.label); + let result = if item.is_dir { + std::fs::remove_dir_all(&item.path) + } else { + std::fs::remove_file(&item.path) + }; + match result { + Ok(()) => println!("done"), + Err(e) => { + println!("FAILED ({e})"); + any_failed = true; + } + } + } + if any_failed { + eprintln!(); + eprintln!("Some components could not be removed. You may need to delete them manually."); + } + Ok(()) +} + +fn try_remove_data_dir(data_dir: &Path) -> Result<()> { + if !data_dir.exists() { + return Ok(()); + } + let Ok(entries) = std::fs::read_dir(data_dir) else { + return Ok(()); + }; + if entries.filter_map(|e| e.ok()).next().is_some() { + return Ok(()); + } + let _ = std::fs::remove_dir(data_dir); + Ok(()) +} + +fn texforge_data_dir() -> Option { + dirs::home_dir().map(|h| h.join(".texforge")) +} + +fn dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + for entry in walkdir::WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + { + if entry.file_type().is_file() { + if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } + } + total +} + +fn file_size(path: &Path) -> u64 { + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) +} + +fn format_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + if bytes < 1024 { + return format!("{bytes} B"); + } + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + format!("{size:.1} {}", UNITS[unit]) +} + +fn binary_install_note() -> String { + let Ok(current_exe) = std::env::current_exe() else { + return "The texforge binary was not removed. Could not determine its location.".into(); + }; + + let is_cargo = crate::version_checker::current_exe_is_cargo_managed(¤t_exe); + + if is_cargo { + format!( + "The texforge binary was NOT removed.\n\ + It was installed via cargo and is managed by cargo.\n\ + To remove it, run: cargo uninstall texforge\n\ + Binary location: {}", + current_exe.display() + ) + } else { + format!( + "The texforge binary was NOT removed.\n\ + To remove it manually, run: rm -f {}\n\ + (or the equivalent for your install method)", + current_exe.display() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn plan_lists_each_component() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("bin")).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + fs::create_dir_all(dir.join("dicts")).unwrap(); + fs::write(dir.join("spell-words"), "hello\n").unwrap(); + fs::write(dir.join("config.toml"), "[user]\n").unwrap(); + + let plan = build_plan_at(&dir, false).unwrap(); + + let labels: Vec<&str> = plan.items.iter().map(|i| i.label).collect(); + assert!(labels.contains(&"Managed Tectonic engine")); + assert!(labels.contains(&"Template cache")); + assert!(labels.contains(&"Dictionary cache")); + assert!(labels.contains(&"Personal spell dictionary (your own writing)")); + assert!(labels.contains(&"Configuration")); + } + + #[test] + fn dry_run_leaves_filesystem_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + fs::write(dir.join("spell-words"), "myword\n").unwrap(); + + execute_at(&dir, false, true, false).unwrap(); + + assert!(dir.join("templates").exists()); + assert!(dir.join("spell-words").exists()); + } + + #[test] + fn spell_dictionary_survives_default_uninstall() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + fs::write(dir.join("spell-words"), "myword\n").unwrap(); + + execute_at(&dir, true, false, false).unwrap(); + + assert!(dir.join("spell-words").exists()); + assert!(!dir.join("templates").exists()); + } + + #[test] + fn spell_dictionary_removed_when_explicitly_requested() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("spell-words"), "myword\n").unwrap(); + + execute_at(&dir, true, false, true).unwrap(); + + assert!(!dir.join("spell-words").exists()); + } + + #[test] + fn missing_component_is_skipped_not_erroring() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + + let result = execute_at(&dir, true, false, false); + assert!(result.is_ok()); + assert!(!dir.join("templates").exists()); + } + + #[test] + fn missing_spell_words_with_include_flag_is_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + assert!(!dir.join("spell-words").exists()); + + let result = execute_at(&dir, true, false, true); + assert!(result.is_ok()); + assert!(!dir.join("templates").exists()); + } + + #[test] + fn format_size_bytes() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + } + + #[test] + fn format_size_kilobytes() { + assert_eq!(format_size(2048), "2.0 KB"); + } + + #[test] + fn format_size_megabytes() { + assert_eq!(format_size(65 * 1024 * 1024), "65.0 MB"); + } + + #[test] + fn dir_size_missing_dir_is_zero() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("does-not-exist"); + assert_eq!(dir_size(&missing), 0); + } + + #[test] + fn dir_size_counts_nested_files() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("a"), b"1234").unwrap(); + let sub = tmp.path().join("sub"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("b"), b"12345678").unwrap(); + assert_eq!(dir_size(tmp.path()), 12); + } + + #[test] + fn plan_spell_words_marked_correctly() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("spell-words"), "word\n").unwrap(); + + let plan = build_plan_at(&dir, false).unwrap(); + let spell_item = plan.items.iter().find(|i| i.is_spell_words).unwrap(); + assert!(spell_item.exists); + assert_eq!( + spell_item.label, + "Personal spell dictionary (your own writing)" + ); + } + + #[test] + fn plan_with_no_spell_words_flag_still_lists_it() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + + let plan = build_plan_at(&dir, false).unwrap(); + assert!(plan.items.iter().any(|i| i.is_spell_words)); + assert!(!plan.spell_words_included); + } + + #[test] + fn empty_data_dir_reports_nothing_present() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + + let plan = build_plan_at(&dir, false).unwrap(); + let existing_items: Vec<_> = plan.items.iter().filter(|i| i.exists).collect(); + assert!(existing_items.is_empty()); + } + + #[test] + fn data_dir_removed_when_empty_after_uninstall() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + + execute_at(&dir, true, false, false).unwrap(); + + assert!(!dir.exists()); + } + + #[test] + fn data_dir_kept_when_spell_words_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".texforge"); + fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(dir.join("templates")).unwrap(); + fs::write(dir.join("spell-words"), "word\n").unwrap(); + + execute_at(&dir, true, false, false).unwrap(); + + assert!(dir.exists()); + assert!(dir.join("spell-words").exists()); + } +} From b3f0fd2bb913fe784ad713cd634e628b68490ce3 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 08:59:38 -0500 Subject: [PATCH 06/10] feat(pdf): derive page-to-section mapping from PDF outline --- src/commands/pdf.rs | 25 ++- src/pdftext.rs | 359 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 2 deletions(-) diff --git a/src/commands/pdf.rs b/src/commands/pdf.rs index c15fdd9..d1d507f 100644 --- a/src/commands/pdf.rs +++ b/src/commands/pdf.rs @@ -102,6 +102,21 @@ fn print_meta(key: &str, value: Option<&str>) { } fn cmd_pages(project: &Project, pdf_path: &Path) -> Result<()> { + let (breaks, source) = build_page_breaks(project, pdf_path)?; + eprintln!("source: {}", source_label(source)); + println!("{}", pdftext::format_page_breaks(&breaks)); + Ok(()) +} + +fn build_page_breaks( + project: &Project, + pdf_path: &Path, +) -> Result<(Vec, pdftext::PageBreakSource)> { + if let Some((outline_entries, page_count)) = pdftext::read_pdf_outline(pdf_path)? { + let breaks = pdftext::page_breaks_from_outline(&outline_entries, page_count); + return Ok((breaks, pdftext::PageBreakSource::Outline)); + } + let page_texts = pdftext::extract_text_by_pages(pdf_path)?; let outline = outline::build_outline( &project.config.document.title, @@ -114,8 +129,14 @@ fn cmd_pages(project: &Project, pdf_path: &Path) -> Result<()> { .map(|s| (s.number.clone(), s.title.clone())) .collect(); let breaks = pdftext::page_breaks(&page_texts, §ions); - println!("{}", pdftext::format_page_breaks(&breaks)); - Ok(()) + Ok((breaks, pdftext::PageBreakSource::TextMatch)) +} + +fn source_label(source: pdftext::PageBreakSource) -> &'static str { + match source { + pdftext::PageBreakSource::Outline => "pdf-outline", + pdftext::PageBreakSource::TextMatch => "text-match", + } } fn cmd_check(project: &Project, pdf_path: &Path) -> Result<()> { diff --git a/src/pdftext.rs b/src/pdftext.rs index d3a75b9..6416d2d 100644 --- a/src/pdftext.rs +++ b/src/pdftext.rs @@ -76,6 +76,26 @@ pub struct PdfPageBreak { pub title: Option, } +/// One entry in the PDF outline (bookmark tree). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PdfOutlineEntry { + /// Section title from the outline. + pub title: String, + /// 1-based destination page number. + pub page: usize, + /// Nesting level (0 = top-level). + pub level: usize, +} + +/// Which path was used to derive the section-to-page mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageBreakSource { + /// PDF outline (bookmark tree). + Outline, + /// Text matching against LaTeX-derived sections. + TextMatch, +} + /// A distinct source word missing from the extracted PDF text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MissingWord { @@ -800,6 +820,264 @@ fn section_title_in_page(page_text: &str, title: &str) -> bool { .any(|line| line_is_section_heading(line.trim(), needle)) } +/// Read the PDF outline (bookmark tree) from a PDF file. +/// +/// Returns `Ok(Some(entries))` when the PDF has an outline with at least one +/// entry that resolves to a page, `Ok(None)` when the PDF has no outline or +/// the outline is empty, and `Err` when the PDF cannot be parsed. +/// +/// The outline lives inside compressed object streams in modern PDFs, so a +/// raw byte search will miss it; use the parser. +pub fn read_pdf_outline(path: &Path) -> Result, usize)>> { + let doc = + Document::load(path).with_context(|| format!("failed to open PDF {}", path.display()))?; + read_pdf_outline_from_doc(&doc) +} + +/// Read the PDF outline from an already-loaded document. +#[allow(dead_code)] +pub fn read_pdf_outline_from_bytes(data: &[u8]) -> Result, usize)>> { + let doc = Document::load_mem(data).context("failed to parse PDF bytes")?; + read_pdf_outline_from_doc(&doc) +} + +fn read_pdf_outline_from_doc(doc: &Document) -> Result, usize)>> { + let Ok(catalog) = doc.catalog() else { + return Ok(None); + }; + + let Ok(outlines_obj) = catalog.get(b"Outlines") else { + return Ok(None); + }; + + let Some(outlines_dict) = resolve_dict(doc, Some(outlines_obj)) else { + return Ok(None); + }; + + let pages_map = doc.get_pages(); + let page_count = pages_map.len(); + let mut entries = Vec::new(); + let mut level_counters: Vec = vec![0; 1]; + + if let Ok(first) = outlines_dict.get(b"First") { + walk_outline_items(doc, first, 0, &pages_map, &mut entries, &mut level_counters); + } + + if entries.is_empty() { + Ok(None) + } else { + Ok(Some((entries, page_count))) + } +} + +fn walk_outline_items( + doc: &Document, + first_obj: &Object, + level: usize, + pages_map: &std::collections::BTreeMap, + entries: &mut Vec, + level_counters: &mut Vec, +) { + while level_counters.len() <= level { + level_counters.push(0); + } + level_counters[level] += 1; + + let Some(item_dict) = resolve_dict(doc, Some(first_obj)) else { + return; + }; + + let title = dict_text(item_dict, b"Title").unwrap_or_default(); + let page = resolve_outline_page(doc, item_dict, pages_map); + + if let Some(page_num) = page { + entries.push(PdfOutlineEntry { + title, + page: page_num, + level, + }); + } + + if let Ok(first_child) = item_dict.get(b"First") { + walk_outline_items( + doc, + first_child, + level + 1, + pages_map, + entries, + level_counters, + ); + } + + if let Ok(next) = item_dict.get(b"Next") { + walk_outline_items(doc, next, level, pages_map, entries, level_counters); + } +} + +fn resolve_outline_page( + doc: &Document, + item: &Dictionary, + pages_map: &std::collections::BTreeMap, +) -> Option { + if let Ok(dest) = item.get(b"Dest") { + if let Some(page_id) = extract_page_ref_from_dest(doc, dest) { + return pages_map + .iter() + .find(|(_, &id)| id == page_id) + .map(|(&n, _)| n as usize); + } + } + + if let Ok(action) = item.get(b"A") { + if let Some(action_dict) = resolve_dict(doc, Some(action)) { + if let Ok(dest) = action_dict.get(b"D") { + if let Some(page_id) = extract_page_ref_from_dest(doc, dest) { + return pages_map + .iter() + .find(|(_, &id)| id == page_id) + .map(|(&n, _)| n as usize); + } + } + } + } + + None +} + +fn extract_page_ref_from_dest(doc: &Document, dest: &Object) -> Option { + match dest { + Object::Reference(id) => Some(*id), + Object::Array(arr) => { + if let Some(Object::Reference(page_id)) = arr.first() { + Some(*page_id) + } else { + None + } + } + Object::String(name, _) => { + let name_str = String::from_utf8_lossy(name); + resolve_named_dest(doc, &name_str) + } + _ => None, + } +} + +fn resolve_named_dest(doc: &Document, name: &str) -> Option { + let catalog = doc.catalog().ok()?; + let names_obj = catalog.get(b"Names").ok()?; + let names_dict = resolve_dict(doc, Some(names_obj))?; + let dests_obj = names_dict.get(b"Dests").ok()?; + let dests_dict = resolve_dict(doc, Some(dests_obj))?; + + walk_named_dest_tree(doc, dests_dict, name) +} + +fn walk_named_dest_tree(doc: &Document, dict: &Dictionary, name: &str) -> Option { + if let Ok(names_arr) = dict.get(b"Names") { + if let Some(entries) = resolve_array(doc, Some(names_arr)) { + for chunk in entries.chunks(2) { + if chunk.len() == 2 { + if let Object::String(entry_name, _) = chunk[0] { + if String::from_utf8_lossy(entry_name) == name { + // The value can be either a dict with /D or a direct dest array + if let Some(dest_dict) = resolve_dict(doc, Some(chunk[1])) { + if let Ok(dest_arr) = dest_dict.get(b"D") { + if let Some(dest_items) = resolve_array(doc, Some(dest_arr)) { + if let Some(Object::Reference(page_id)) = dest_items.first() + { + return Some(*page_id); + } + } + } + } else if let Some(dest_items) = resolve_array(doc, Some(chunk[1])) { + // Direct destination array [page_ref /XYZ ...] + if let Some(Object::Reference(page_id)) = dest_items.first() { + return Some(*page_id); + } + } + } + } + } + } + } + } + + if let Ok(kids_arr) = dict.get(b"Kids") { + if let Some(kids) = resolve_array(doc, Some(kids_arr)) { + for kid in kids { + if let Some(kid_dict) = resolve_dict(doc, Some(kid)) { + if let Some(result) = walk_named_dest_tree(doc, kid_dict, name) { + return Some(result); + } + } + } + } + } + + None +} + +fn resolve_array<'a>(doc: &'a Document, obj: Option<&'a Object>) -> Option> { + match obj? { + Object::Array(a) => Some(a.iter().collect()), + Object::Reference(id) => match doc.get_object(*id).ok()? { + Object::Array(a) => Some(a.iter().collect()), + _ => None, + }, + _ => None, + } +} + +/// Build page breaks from a PDF outline. +/// +/// Computes section numbers from the outline's nesting levels and maps each +/// page to the section that opens it. +pub fn page_breaks_from_outline( + entries: &[PdfOutlineEntry], + num_pages: usize, +) -> Vec { + let numbered = compute_section_numbers(entries); + let mut out = Vec::with_capacity(num_pages); + let mut current: Option<(String, String)> = None; + let mut entry_idx = 0; + + for page_num in 1..=num_pages { + while entry_idx < numbered.len() && numbered[entry_idx].0 == page_num { + let (_, num, title) = &numbered[entry_idx]; + current = Some((num.clone(), title.clone())); + entry_idx += 1; + } + out.push(PdfPageBreak { + page: page_num, + section: current.as_ref().map(|(n, _)| n.clone()), + title: current.as_ref().map(|(_, t)| t.clone()), + }); + } + out +} + +fn compute_section_numbers(entries: &[PdfOutlineEntry]) -> Vec<(usize, String, String)> { + let mut counters: Vec = Vec::new(); + let mut result = Vec::with_capacity(entries.len()); + + for entry in entries { + let level = entry.level; + while counters.len() <= level { + counters.push(0); + } + counters[level] += 1; + counters.truncate(level + 1); + + let number: String = counters + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("."); + result.push((entry.page, number, entry.title.clone())); + } + result +} + /// Format page breaks for diff-friendly output: one line per page. pub fn format_page_breaks(breaks: &[PdfPageBreak]) -> String { let mut lines = Vec::with_capacity(breaks.len()); @@ -1540,4 +1818,85 @@ mod tests { let missing = fidelity_missing_words(&source, &normalize_pdf_text(&raw)); assert!(missing.is_empty(), "unexpected missing: {missing:?}"); } + + const CAPABILITIES_PDF: &[u8] = + include_bytes!("../examples/texforge-capabilites/texforge-capabilites.pdf"); + + #[test] + fn capabilities_pdf_has_outline() { + let outline = read_pdf_outline_from_bytes(CAPABILITIES_PDF).unwrap(); + assert!(outline.is_some(), "capabilities PDF must have an outline"); + let (entries, page_count) = outline.unwrap(); + assert!(!entries.is_empty(), "outline must have entries"); + assert!(page_count > 0, "page count must be positive"); + assert!( + entries.iter().any(|e| e.page > 0), + "outline entries must resolve to pages" + ); + } + + #[test] + fn pages_ligatures_fixture_has_no_outline() { + let outline = read_pdf_outline_from_bytes(PAGES_PDF).unwrap(); + assert!( + outline.is_none(), + "pages-ligatures fixture should not have an outline" + ); + } + + #[test] + fn page_breaks_from_outline_computes_section_numbers() { + let entries = vec![ + PdfOutlineEntry { + title: "Introduction".into(), + page: 1, + level: 0, + }, + PdfOutlineEntry { + title: "Background".into(), + page: 2, + level: 1, + }, + PdfOutlineEntry { + title: "Methods".into(), + page: 3, + level: 0, + }, + ]; + let breaks = page_breaks_from_outline(&entries, 4); + assert_eq!(breaks.len(), 4); + assert_eq!(breaks[0].section.as_deref(), Some("1")); + assert_eq!(breaks[0].title.as_deref(), Some("Introduction")); + assert_eq!(breaks[1].section.as_deref(), Some("1.1")); + assert_eq!(breaks[1].title.as_deref(), Some("Background")); + assert_eq!(breaks[2].section.as_deref(), Some("2")); + assert_eq!(breaks[2].title.as_deref(), Some("Methods")); + assert_eq!(breaks[3].section.as_deref(), Some("2")); + } + + #[test] + fn page_breaks_from_outline_handles_multiple_entries_on_same_page() { + let entries = vec![ + PdfOutlineEntry { + title: "First".into(), + page: 1, + level: 0, + }, + PdfOutlineEntry { + title: "Second".into(), + page: 1, + level: 1, + }, + PdfOutlineEntry { + title: "Third".into(), + page: 2, + level: 0, + }, + ]; + let breaks = page_breaks_from_outline(&entries, 2); + assert_eq!(breaks[0].section.as_deref(), Some("1.1")); + assert_eq!(breaks[0].title.as_deref(), Some("Second")); + assert_eq!(breaks[1].section.as_deref(), Some("2")); + assert_eq!(breaks[1].title.as_deref(), Some("Third")); + } } From df6df8edf0f62e22b8488904cd187fbb069d9a01 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 09:15:37 -0500 Subject: [PATCH 07/10] test(pdf-pages): document and cover the outline-path carry-forward rule --- src/pdftext.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/src/pdftext.rs b/src/pdftext.rs index 6416d2d..8f8def7 100644 --- a/src/pdftext.rs +++ b/src/pdftext.rs @@ -1031,7 +1031,9 @@ fn resolve_array<'a>(doc: &'a Document, obj: Option<&'a Object>) -> Option Date: Wed, 26 Aug 2026 13:58:42 -0500 Subject: [PATCH 08/10] chore: bump version to 0.9.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f72a5cd..7ad3bb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,7 +2904,7 @@ dependencies = [ [[package]] name = "texforge" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7702e62..be0445e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "texforge" -version = "0.8.0" +version = "0.9.0" edition = "2021" # Raised from 1.75: `hayro` (the pure-Rust PDF rasterizer) requires 1.92. rust-version = "1.92" From 4b608350f91bdb69362ceaf59e82ec38be617f08 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 14:03:26 -0500 Subject: [PATCH 09/10] fix(spell): treat discretionary hyphens and italic corrections as transparent --- src/linter/spell.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/linter/spell.rs b/src/linter/spell.rs index 45a54d4..bf258d8 100644 --- a/src/linter/spell.rs +++ b/src/linter/spell.rs @@ -643,6 +643,17 @@ const ACCENT_COMMANDS: &[&str] = &[ "'", "`", "^", "\"", "~", "=", ".", "c", "v", "u", "H", "r", "k", ]; +/// Commands that render no character at all and must therefore be *skipped* +/// rather than treated as a word break. `\-` is a discretionary hyphen — a +/// hint about where a line may break — and `\/` is an italic correction; +/// both appear inside words. Measured on a real document: `impor\-tancia` +/// was being reported as the two unknown words `impor` and `tancia`. +const TRANSPARENT_COMMANDS: &[&str] = &["-", "/"]; + +fn is_transparent_command(name: &str) -> bool { + TRANSPARENT_COMMANDS.contains(&name) +} + fn is_accent_command(name: &str) -> bool { ACCENT_COMMANDS.contains(&name) } @@ -941,6 +952,12 @@ fn build_spell_text(tokens: &[SpannedToken], source: &str) -> (String, Vec<(usiz } } } + Token::Command { name, .. } if is_transparent_command(name) => { + // Emit nothing and do NOT push a separator: the characters on + // either side belong to the same word. + i += 1; + pending_text_skip = 0; + } Token::Command { name, .. } if name == "i" || name == "j" => { let line = line_of(source, tokens[i].start); line_chunks.push((out.len(), line)); @@ -2333,6 +2350,29 @@ Hello world. This is some text. \label{sec:intro} More text. ); } + #[test] + fn discretionary_hyphen_does_not_split_a_word() { + // Measured on a real document: `impor\-tancia` was reported as the + // two unknown words `impor` and `tancia`. `\-` marks where a line + // MAY break; it renders nothing and must not break the word here. + run_with_home("importancia\n", "hello\nworld\n", || { + let src = "\\usepackage[spanish]{babel}\n\\begin{document}\n\ + impor\\-tancia\n\\end{document}"; + let files = vec![("main.tex".to_string(), src.to_string())]; + let project_root = TempDir::new().unwrap(); + let findings = lint_files(&files, project_root.path(), None).unwrap(); + let unknown: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("Unknown word")) + .collect(); + assert!( + unknown.is_empty(), + "a discretionary hyphen must not split a word: {:?}", + unknown + ); + }); + } + #[test] fn document_with_letter_form_macro_produces_zero_warnings() { run_with_home("čeština\n", "hello\nworld\n", || { From 6baeaf21efa39d9c174f2530a34dfd84da620fb0 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 14:07:29 -0500 Subject: [PATCH 10/10] fix(pdf-pages): attribute a page to the first section that opens it, not the last --- src/pdftext.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/src/pdftext.rs b/src/pdftext.rs index 8f8def7..c3fb35a 100644 --- a/src/pdftext.rs +++ b/src/pdftext.rs @@ -1044,9 +1044,19 @@ pub fn page_breaks_from_outline( let mut entry_idx = 0; for page_num in 1..=num_pages { + // A page is attributed to the FIRST section that opens it, never the + // last — the rule `4947308` established for the text-matching path and + // that this one must honour too. Several sections routinely start on + // one page: in the capabilities example, page 2 opens sections 1, 1.1, + // 1.2, 2, 2.1, 2.2 and 2.3, and the answer is 1. Later entries on the + // same page are still consumed, just not reported. + let mut opened_here = false; while entry_idx < numbered.len() && numbered[entry_idx].0 == page_num { - let (_, num, title) = &numbered[entry_idx]; - current = Some((num.clone(), title.clone())); + if !opened_here { + let (_, num, title) = &numbered[entry_idx]; + current = Some((num.clone(), title.clone())); + opened_here = true; + } entry_idx += 1; } out.push(PdfPageBreak { @@ -1846,6 +1856,39 @@ mod tests { ); } + #[test] + fn page_breaks_from_outline_reports_the_first_section_on_a_page() { + // Ground truth read from the capabilities PDF's own table of contents: + // page 2 opens 1, 1.1, 1.2, 2, 2.1, 2.2 and 2.3. The answer is 1. + // Before this was fixed the outline path reported 2.3 — the last + // entry on the page — contradicting the rule the text-matching path + // has followed since `4947308`. + let e = |title: &str, page: usize, level: usize| PdfOutlineEntry { + title: title.into(), + page, + level, + }; + let entries = vec![ + e("Introduccion", 2, 0), + e("Antecedentes", 2, 1), + e("Objetivos", 2, 1), + e("Diagramas", 2, 0), + e("Mermaid", 2, 1), + e("Graphviz", 2, 1), + e("D2", 2, 1), + e("Estilos", 5, 1), + ]; + let breaks = page_breaks_from_outline(&entries, 5); + + assert_eq!(breaks[0].section, None, "page 1 is front matter"); + assert_eq!(breaks[1].section.as_deref(), Some("1")); + assert_eq!(breaks[1].title.as_deref(), Some("Introduccion")); + // Pages 3 and 4 open nothing: they carry the last opened section. + assert_eq!(breaks[2].section.as_deref(), Some("1")); + assert_eq!(breaks[3].section.as_deref(), Some("1")); + assert_eq!(breaks[4].section.as_deref(), Some("2.4")); + } + #[test] fn page_breaks_from_outline_computes_section_numbers() { let entries = vec![ @@ -1896,8 +1939,12 @@ mod tests { }, ]; let breaks = page_breaks_from_outline(&entries, 2); - assert_eq!(breaks[0].section.as_deref(), Some("1.1")); - assert_eq!(breaks[0].title.as_deref(), Some("Second")); + // The page is attributed to the FIRST section that opens it. This + // asserted "1.1" — the last entry on the page — which described what + // the code did rather than what the command promises ("which section + // opens each page"), and contradicted the text-matching path. + assert_eq!(breaks[0].section.as_deref(), Some("1")); + assert_eq!(breaks[0].title.as_deref(), Some("First")); assert_eq!(breaks[1].section.as_deref(), Some("2")); assert_eq!(breaks[1].title.as_deref(), Some("Third")); }