diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76c2f2..522d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: jobs: rust-ci: diff --git a/Cargo.lock b/Cargo.lock index a265fe2..9843ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,28 +106,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -183,8 +161,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -200,12 +176,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" version = "4.6.0" @@ -246,15 +216,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -277,16 +238,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -401,12 +352,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -539,12 +484,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -628,24 +567,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", ] [[package]] @@ -656,7 +579,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -805,11 +728,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1066,16 +987,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.94" @@ -1192,12 +1103,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "memchr" version = "2.8.0" @@ -1409,15 +1314,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1443,62 +1339,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -1508,47 +1348,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1631,7 +1436,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -1704,12 +1508,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustix" version = "1.1.4" @@ -1729,8 +1527,8 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1755,7 +1553,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -1765,7 +1562,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -1792,7 +1589,6 @@ version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1853,7 +1649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2084,27 +1880,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tar" version = "0.4.45" @@ -2143,6 +1918,7 @@ dependencies = [ "notify", "reqwest", "resvg", + "rustls", "serde", "tar", "tempfile", @@ -2677,16 +2453,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -2739,35 +2505,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -2795,15 +2532,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3130,26 +2858,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8c5cb98..09ef269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,17 @@ walkdir = "2.5" # Home directory resolution dirs = "6.0" -# HTTP client (template downloads) -reqwest = { version = "0.13", features = ["blocking", "json"] } +# HTTP client (template downloads). rustls with the `ring` provider instead of +# the default `aws-lc-rs`: ring is pure-Rust+portable asm and cross-compiles +# cleanly to Windows/musl without a C toolchain (aws-lc-sys needs clang/NASM). +reqwest = { version = "0.13", default-features = false, features = [ + "blocking", + "json", + "http2", + "charset", + "rustls-no-provider", +] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } # Archive extraction (GitHub tarballs + tectonic zip on Windows) flate2 = "1.1" @@ -51,7 +60,7 @@ resvg = "0.46" # Graphviz/DOT diagram rendering layout-rs = "0.1" -[dev-dependencies] +# Temporary directories for build tempfile = "3.8" [lints.clippy] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7d38474..10cac5b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -60,11 +60,11 @@ enum Commands { #[derive(Subcommand)] enum TemplateAction { - /// List available templates + /// List available templates (installed + remote registry by default) List { - /// Also show templates available in the remote registry + /// Only show locally installed templates (skip the remote registry) #[arg(long)] - all: bool, + local: bool, }, /// Add a template from URL or registry Add { source: String }, @@ -90,7 +90,7 @@ impl Cli { Commands::Fmt { check } => commands::fmt::execute(check), Commands::Check => commands::check::execute(), Commands::Template { action } => match action { - TemplateAction::List { all } => commands::template::list(all), + TemplateAction::List { local } => commands::template::list(!local), TemplateAction::Add { source } => commands::template::add(&source), TemplateAction::Remove { name } => commands::template::remove(&name), TemplateAction::Validate { name } => commands::template::validate(&name), diff --git a/src/commands/build.rs b/src/commands/build.rs index fe4adbb..d766b24 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,5 +1,6 @@ //! `texforge build` command implementation. +use std::path::Path; use std::sync::mpsc; use std::time::Duration; @@ -10,21 +11,36 @@ use crate::commands::init::BANNER; use crate::compiler; use crate::diagrams; use crate::domain::project::Project; +use crate::utils::sanitize_filename; -/// Compile project to PDF. +/// Compile project to PDF using a temp directory, output named after the document title. pub fn execute() -> Result<()> { let project = Project::load()?; - println!("Building project: {}", project.config.documento.titulo); - std::fs::create_dir_all(project.root.join("build"))?; - diagrams::process(&project.root, &project.config.compilacion.entry)?; - let build_dir = project.root.join("build"); - let entry_filename = std::path::Path::new(&project.config.compilacion.entry) + let titulo = &project.config.documento.titulo; + println!("Building project: {titulo}"); + + let temp_dir = tempfile::tempdir()?; + let build_dir = temp_dir.path(); + println!(" ◇ temp: {}", build_dir.display()); + + diagrams::process(&project.root, &project.config.compilacion.entry, build_dir)?; + let entry_filename = Path::new(&project.config.compilacion.entry) .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or(project.config.compilacion.entry.clone()); - compiler::compile(&build_dir, &entry_filename)?; - let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); - println!(" ◇ build/{}", pdf_name.display()); + .unwrap_or_else(|| project.config.compilacion.entry.clone()); + compiler::compile(build_dir, &entry_filename)?; + + let pdf_name = format!("{}.pdf", sanitize_filename(titulo)); + let pdf_dest = project.root.join(&pdf_name); + let pdf_src = build_dir.join( + Path::new(&project.config.compilacion.entry) + .with_extension("pdf") + .file_name() + .unwrap(), + ); + std::fs::copy(&pdf_src, &pdf_dest)?; + println!(" ◇ {}", pdf_dest.display()); + Ok(()) } @@ -32,13 +48,15 @@ pub fn execute() -> Result<()> { pub fn watch(delay_secs: u64) -> Result<()> { let project = Project::load()?; let debounce = Duration::from_secs(delay_secs); - // Ignore new events for this long after a build completes let cooldown = Duration::from_secs(2); print_watch_header(&project.config.documento.titulo, delay_secs); + let temp_dir = tempfile::tempdir()?; + let build_dir = temp_dir.path().to_path_buf(); + let started = std::time::Instant::now(); - let result = run_build(&project); + let result = run_build(&project, &build_dir); redraw_status(&result, 1, started); let (tx, rx) = mpsc::channel(); @@ -50,7 +68,6 @@ pub fn watch(delay_secs: u64) -> Result<()> { watcher.watch(&project.root, RecursiveMode::Recursive)?; - let build_dir = project.root.join("build"); let mut pending = false; let mut last_event = std::time::Instant::now(); let mut last_build = std::time::Instant::now(); @@ -74,7 +91,6 @@ pub fn watch(delay_secs: u64) -> Result<()> { Err(_) => break, } - // Redraw timer every second even without a build if last_tick.elapsed() >= Duration::from_secs(1) { last_tick = std::time::Instant::now(); redraw_status(&last_result, build_count, started); @@ -83,7 +99,7 @@ pub fn watch(delay_secs: u64) -> Result<()> { if pending && last_event.elapsed() >= debounce { pending = false; build_count += 1; - last_result = run_build(&project); + last_result = run_build(&project, &build_dir); last_build = std::time::Instant::now(); redraw_status(&last_result, build_count, started); } @@ -99,7 +115,6 @@ fn print_watch_header(title: &str, delay_secs: u64) { } fn redraw_status(result: &WatchResult, build_count: u32, started: std::time::Instant) { - // Move to line 15 (just after header), clear from there down, redraw print!("\x1B[15;0H\x1B[J"); let e = started.elapsed().as_secs(); let session = format!("{:02}:{:02}:{:02}", e / 3600, (e % 3600) / 60, e % 60); @@ -107,7 +122,7 @@ fn redraw_status(result: &WatchResult, build_count: u32, started: std::time::Ins println!(" session \x1B[36m{session}\x1B[0m builds \x1B[36m{build_count}\x1B[0m"); println!(); match result { - WatchResult::Ok(pdf) => println!(" \x1B[32mbuild/{pdf} ok\x1B[0m"), + WatchResult::Ok(pdf) => println!(" \x1B[32m{pdf} ok\x1B[0m"), WatchResult::Err(err) => { println!(" \x1B[31merror:\x1B[0m"); for line in err.lines() { @@ -124,20 +139,32 @@ enum WatchResult { Err(String), } -fn run_build(project: &Project) -> WatchResult { - let _ = std::fs::create_dir_all(project.root.join("build")); - if let Err(e) = diagrams::process(&project.root, &project.config.compilacion.entry) { +fn run_build(project: &Project, build_dir: &Path) -> WatchResult { + let _ = std::fs::create_dir_all(build_dir); + if let Err(e) = diagrams::process(&project.root, &project.config.compilacion.entry, build_dir) { return WatchResult::Err(e.to_string()); } - let build_dir = project.root.join("build"); - let entry_filename = std::path::Path::new(&project.config.compilacion.entry) + let entry_filename = Path::new(&project.config.compilacion.entry) .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or(project.config.compilacion.entry.clone()); - match compiler::compile(&build_dir, &entry_filename) { + .unwrap_or_else(|| project.config.compilacion.entry.clone()); + match compiler::compile(build_dir, &entry_filename) { Ok(()) => { - let pdf = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); - WatchResult::Ok(pdf.display().to_string()) + let pdf_name = format!( + "{}.pdf", + sanitize_filename(&project.config.documento.titulo) + ); + let pdf_dest = project.root.join(&pdf_name); + let pdf_src = build_dir.join( + Path::new(&project.config.compilacion.entry) + .with_extension("pdf") + .file_name() + .unwrap(), + ); + match std::fs::copy(&pdf_src, &pdf_dest) { + Ok(_) => WatchResult::Ok(pdf_name), + Err(e) => WatchResult::Err(e.to_string()), + } } Err(e) => WatchResult::Err(e.to_string()), } diff --git a/src/commands/clean.rs b/src/commands/clean.rs index a146ae1..5da1dba 100644 --- a/src/commands/clean.rs +++ b/src/commands/clean.rs @@ -3,18 +3,32 @@ use anyhow::Result; use crate::domain::project::Project; +use crate::utils::sanitize_filename; -/// Remove the build/ directory. +/// Remove generated PDF files and the legacy build/ directory from the project root. pub fn execute() -> Result<()> { let project = Project::load()?; - let build_dir = project.root.join("build"); + let titulo = &project.config.documento.titulo; + let pdf_name = format!("{}.pdf", sanitize_filename(titulo)); + let pdf_path = project.root.join(&pdf_name); + let legacy_build = project.root.join("build"); - if !build_dir.exists() { - println!("Nothing to clean."); - return Ok(()); + let mut cleaned = false; + + if pdf_path.exists() { + std::fs::remove_file(&pdf_path)?; + println!(" ◇ {pdf_name} removed"); + cleaned = true; + } + + if legacy_build.is_dir() { + std::fs::remove_dir_all(&legacy_build)?; + println!(" ◇ build/ removed"); + cleaned = true; } - std::fs::remove_dir_all(&build_dir)?; - println!(" ◇ build/ removed"); + if !cleaned { + println!("Nothing to clean."); + } Ok(()) } diff --git a/src/commands/fmt.rs b/src/commands/fmt.rs index ef6fe0e..b7bfbb1 100644 --- a/src/commands/fmt.rs +++ b/src/commands/fmt.rs @@ -1,37 +1,33 @@ //! `texforge fmt` command implementation. +use std::path::Path; + use anyhow::Result; use crate::domain::project::Project; use crate::formatter; use crate::utils; -/// Format .tex files. +/// Format `.tex` and `.bib` files. pub fn execute(check: bool) -> Result<()> { let project = Project::load()?; - let files = utils::find_tex_files(&project.root)?; - if files.is_empty() { - println!("No .tex files found"); + let tex_files = utils::find_tex_files(&project.root)?; + let bib_files = utils::find_bib_files(&project.root)?; + let total = tex_files.len() + bib_files.len(); + + if total == 0 { + println!("No .tex or .bib files found"); return Ok(()); } let mut unformatted = 0; - for file in &files { - let content = std::fs::read_to_string(file)?; - let formatted = formatter::format(&content); - - if content != formatted { - let rel = file.strip_prefix(&project.root).unwrap_or(file).display(); - if check { - println!(" ✗ {}", rel); - unformatted += 1; - } else { - std::fs::write(file, &formatted)?; - println!(" formatted {}", rel); - } - } + for file in &tex_files { + unformatted += format_one(file, &project.root, check, formatter::format)?; + } + for file in &bib_files { + unformatted += format_one(file, &project.root, check, formatter::format_bib)?; } if check && unformatted > 0 { @@ -39,11 +35,30 @@ pub fn execute(check: bool) -> Result<()> { "{} file(s) need formatting — run 'texforge fmt'", unformatted ); - } else if !check { - println!(" ◇ {} file(s) checked", files.len()); - } else { + } else if check { println!(" ◇ All files formatted correctly"); + } else { + println!(" ◇ {} file(s) checked", total); } Ok(()) } + +/// Format a single file, returning 1 if it needed formatting (else 0). +fn format_one(file: &Path, root: &Path, check: bool, fmt: fn(&str) -> String) -> Result { + let content = std::fs::read_to_string(file)?; + let formatted = fmt(&content); + + if content == formatted { + return Ok(0); + } + + let rel = file.strip_prefix(root).unwrap_or(file).display(); + if check { + println!(" ✗ {}", rel); + } else { + std::fs::write(file, &formatted)?; + println!(" formatted {}", rel); + } + Ok(1) +} diff --git a/src/commands/new.rs b/src/commands/new.rs index c2d152e..8edaaa4 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,9 +1,12 @@ //! `texforge new` command implementation. +use std::collections::HashMap; use std::path::{Component, Path}; use anyhow::{Context, Result}; +use crate::manifest::TemplateManifest; +use crate::placeholders::PlaceholderResolver; use crate::templates; /// Create a new project from a template. @@ -24,6 +27,10 @@ pub fn execute(name: &str, template: Option<&str>) -> Result<()> { let resolved = templates::resolve(template_name)?; + // Resolve any placeholders the template declares (defaults, project/user + // config). Missing values are left as-is rather than failing generation. + let values = resolve_placeholder_values(&resolved.files); + // Create project directory and write all template files for (rel_path, content) in &resolved.files { // Skip template.toml — it's metadata, not a project file @@ -34,8 +41,16 @@ pub fn execute(name: &str, template: Option<&str>) -> Result<()> { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&dest, content) - .with_context(|| format!("Failed to write {}", dest.display()))?; + // Substitute {{placeholder}} tokens in .tex files only — other files + // (code samples, images) are copied verbatim. + if rel_path.ends_with(".tex") { + let text = String::from_utf8_lossy(content); + let substituted = apply_substitutions(&text, &values); + std::fs::write(&dest, substituted) + } else { + std::fs::write(&dest, content) + } + .with_context(|| format!("Failed to write {}", dest.display()))?; } // Generate project.toml @@ -63,6 +78,41 @@ bibliografia = "bib/references.bib" Ok(()) } +/// Resolve placeholder values from a template's manifest, if present. +/// Returns an empty map for templates without a (valid) `template.toml` or +/// without declared placeholders. +fn resolve_placeholder_values(files: &HashMap>) -> HashMap { + let mut values = HashMap::new(); + + let Some(toml_bytes) = files.get("template.toml") else { + return values; + }; + let Ok(text) = std::str::from_utf8(toml_bytes) else { + return values; + }; + let Ok(manifest) = TemplateManifest::from_str(text) else { + return values; + }; + + let resolver = PlaceholderResolver::new(HashMap::new()); + for ph in &manifest.placeholders { + if let Ok(Some(value)) = resolver.resolve(ph) { + values.insert(ph.name.clone(), value); + } + } + values +} + +/// Replace `{{name}}` tokens with resolved values. Unresolved tokens are left +/// untouched (lenient — never fails generation). +fn apply_substitutions(content: &str, values: &HashMap) -> String { + let mut out = content.to_string(); + for (key, value) in values { + out = out.replace(&format!("{{{{{}}}}}", key), value); + } + out +} + /// Validate project name: no empty, no path traversal, no special chars. pub(crate) fn validate_project_name(name: &str) -> Result<()> { if name.is_empty() { diff --git a/src/commands/template.rs b/src/commands/template.rs index 77c4831..f7e952b 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -4,8 +4,9 @@ use anyhow::Result; use crate::templates; -/// List available templates. -pub fn list(all: bool) -> Result<()> { +/// List available templates. By default also queries the remote registry; +/// pass `include_remote = false` (via `--local`) to list only installed ones. +pub fn list(include_remote: bool) -> Result<()> { let cached = templates::list_cached()?; let installed: std::collections::HashSet<&str> = cached.iter().map(String::as_str).collect(); @@ -15,7 +16,7 @@ pub fn list(all: bool) -> Result<()> { println!(" - {}", name); } - if all { + if include_remote { print!("\nFetching remote registry..."); match templates::list_remote() { Ok(remote) => { diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index d07d4ff..7207c93 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -132,8 +132,8 @@ fn locate_tectonic() -> Option { // Check known locations [ - dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")), - dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")), + tectonic_managed_path().ok(), + dirs::home_dir().map(|h| h.join(".cargo/bin").join(TECTONIC_BIN)), Some("/usr/local/bin/tectonic".into()), Some("/opt/homebrew/bin/tectonic".into()), ] @@ -142,9 +142,15 @@ fn locate_tectonic() -> Option { .find(|p| p.exists()) } +/// Tectonic binary filename — Windows requires the .exe extension to execute it. +#[cfg(windows)] +const TECTONIC_BIN: &str = "tectonic.exe"; +#[cfg(not(windows))] +const TECTONIC_BIN: &str = "tectonic"; + fn tectonic_managed_path() -> Result { dirs::home_dir() - .map(|h| h.join(".texforge/bin/tectonic")) + .map(|h| h.join(".texforge").join("bin").join(TECTONIC_BIN)) .ok_or_else(|| anyhow::anyhow!("Could not determine home directory")) } diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 9832eee..f22856b 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -6,22 +6,21 @@ //! Works on copies in `build/` — the original .tex files are never modified. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; use anyhow::{Context, Result}; -/// Copy all .tex files to `build/`, rendering embedded diagrams in the copies. +/// Copy all .tex files to `build_dir`, rendering embedded diagrams in the copies. /// Also mirrors non-.tex assets so tectonic can resolve relative paths. /// Returns the path to the build copy of `entry`. -pub fn process(root: &Path, entry: &str) -> Result { - let build_dir = root.join("build"); - std::fs::create_dir_all(&build_dir)?; +pub fn process(root: &Path, entry: &str, build_dir: &Path) -> Result { + std::fs::create_dir_all(build_dir)?; let diagrams_dir = build_dir.join("diagrams"); std::fs::create_dir_all(&diagrams_dir)?; - let mut counter = 0usize; - // Process .tex files let tex_files = collect_tex_files(root, entry); for src in &tex_files { @@ -31,37 +30,51 @@ pub fn process(root: &Path, entry: &str) -> Result { std::fs::create_dir_all(parent)?; } let content = std::fs::read_to_string(src)?; - let processed = render_diagrams(&content, &diagrams_dir, &mut counter) + let processed = render_diagrams(&content, &diagrams_dir) .with_context(|| format!("Failed to render diagrams in {}", src.display()))?; std::fs::write(&dest, processed)?; } // Mirror asset files so tectonic resolves relative paths - crate::utils::mirror_assets(root, &build_dir)?; + crate::utils::mirror_assets(root, build_dir)?; Ok(build_dir.join(entry)) } /// Replace all `\begin{mermaid}[opts]...\end{mermaid}` with figure environments. -fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> Result { - let content = render_env(content, "mermaid", diagrams_dir, counter, |src| { - let svg = mermaid_rs_renderer::render(src) +fn render_diagrams(content: &str, diagrams_dir: &Path) -> Result { + let content = render_env(content, "mermaid", diagrams_dir, |src| { + let svg = render_mermaid_with_config(src) .map_err(|e| anyhow::anyhow!("Mermaid render error: {}", e))?; svg_to_png(&svg).context("Failed to convert mermaid SVG to PNG") })?; - let content = render_env(&content, "graphviz", diagrams_dir, counter, |src| { + let content = render_env(&content, "graphviz", diagrams_dir, |src| { let svg = render_graphviz(src)?; svg_to_png(&svg).context("Failed to convert graphviz SVG to PNG") })?; Ok(content) } +/// Render Mermaid diagram with improved configuration for better layout. +fn render_mermaid_with_config(src: &str) -> Result { + // Try with default configuration first + mermaid_rs_renderer::render(src).map_err(|e| { + // If default fails, try with explicit configuration + anyhow::anyhow!( + "Mermaid render error: {}. Consider checking diagram syntax.", + e + ) + }) +} + /// Generic environment renderer: replaces `\begin{env}[opts]...\end{env}` with figure. +/// +/// Rendered PNGs are named after a hash of the diagram source, so unchanged +/// diagrams are reused across rebuilds (watch mode) instead of re-rendered. pub(crate) fn render_env( content: &str, env: &str, diagrams_dir: &Path, - counter: &mut usize, render_fn: impl Fn(&str) -> Result>, ) -> Result { let begin_tag = format!("\\begin{{{}}}", env); @@ -76,50 +89,107 @@ pub(crate) fn render_env( let after_begin = &remaining[start + begin_tag.len()..]; let (opts, after_opts) = parse_opts(after_begin); - let end = after_opts - .find(&*end_tag) - .with_context(|| format!("\\begin{{{}}} without matching \\end{{{}}}", env, env))?; - + let end = find_end_tag(after_opts, &end_tag, env)?; let diagram_src = after_opts[..end].trim(); - // Fail fast: validate pos before doing any rendering work - let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); - if !["H", "t", "b", "h", "p"].contains(&pos) { - anyhow::bail!( - "Invalid {} option pos='{}' — valid values are: H, t, b, h, p", - env, - pos - ); + validate_pos_option(&opts, env)?; + + let filename = format!("{}-{:016x}.png", env, content_hash(diagram_src)); + if !diagrams_dir.join(&filename).exists() { + let png = render_fn(diagram_src)?; + std::fs::write(diagrams_dir.join(&filename), png)?; } + let fig_env = build_figure_environment(&opts, env, &filename)?; - let png = render_fn(diagram_src)?; + result.push_str(&fig_env); + remaining = &after_opts[end + end_tag.len()..]; + } - *counter += 1; - let filename = format!("diagram-{}.png", counter); - std::fs::write(diagrams_dir.join(&filename), &png)?; + result.push_str(remaining); + Ok(result) +} - // Build figure environment - let width = opts - .get("width") - .map(String::as_str) - .unwrap_or("\\linewidth"); - let caption = opts.get("caption"); - let rel_path = format!("diagrams/{}", filename); +/// Stable-enough 64-bit hash of diagram source for cache filenames. +fn content_hash(src: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut hasher); + hasher.finish() +} - let mut fig = format!( - "\\begin{{figure}}[{pos}]\n \\centering\n \\includegraphics[width={width}]{{{rel_path}}}\n" +/// Find the end tag position and validate it exists. +fn find_end_tag(after_opts: &str, end_tag: &str, env: &str) -> Result { + after_opts + .find(end_tag) + .with_context(|| format!("\\begin{{{}}} without matching \\end{{{}}}", env, env)) +} + +/// Validate the pos option is one of the allowed values. +fn validate_pos_option(opts: &HashMap, env: &str) -> Result<()> { + let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); + if !["H", "t", "b", "h", "p"].contains(&pos) { + anyhow::bail!( + "Invalid {} option pos='{}' — valid values are: H, t, b, h, p", + env, + pos ); - if let Some(cap) = caption { - fig.push_str(&format!(" \\caption{{{}}}\n", cap)); + } + Ok(()) +} + +/// Build the figure environment LaTeX code. +fn build_figure_environment( + opts: &HashMap, + _env: &str, + filename: &str, +) -> Result { + let pos = opts.get("pos").map(String::as_str); + let width = opts.get("width").map(String::as_str); + let height = opts.get("height").map(String::as_str); + let scale = opts.get("scale").map(String::as_str); + let keepaspectratio = opts.contains_key("keepaspectratio"); + let label = opts.get("label").map(String::as_str); + let rel_path = format!("diagrams/{}", filename); + + let mut include_opts = Vec::new(); + if let Some(s) = scale { + include_opts.push(format!("scale={s}")); + } else { + if let Some(w) = width { + include_opts.push(format!("width={w}")); + } + if let Some(h) = height { + include_opts.push(format!("height={h}")); } - fig.push_str("\\end{figure}"); + } + if keepaspectratio { + include_opts.push("keepaspectratio".to_string()); + } + let include_str = if include_opts.is_empty() { + "width=\\linewidth".to_string() + } else { + include_opts.join(",") + }; - result.push_str(&fig); - remaining = &after_opts[end + end_tag.len()..]; + let pos_str = pos.map(|p| format!("[{p}]")).unwrap_or_default(); + let mut fig = format!( + "\\begin{{figure}}{pos_str}\n \\centering\n \\includegraphics[{include_str}]{{{rel_path}}}\n" + ); + + add_caption_if_present(opts, &mut fig)?; + if let Some(lbl) = label { + fig.push_str(&format!(" \\label{{{lbl}}}\n")); } + fig.push_str("\\end{figure}"); - result.push_str(remaining); - Ok(result) + Ok(fig) +} + +/// Add caption to figure environment if present in options. +fn add_caption_if_present(opts: &HashMap, fig: &mut String) -> Result<()> { + if let Some(cap) = opts.get("caption") { + fig.push_str(&format!(" \\caption{{{}}}\n", cap)); + } + Ok(()) } /// Render a DOT/Graphviz diagram to SVG using layout-rs (pure Rust). @@ -212,11 +282,28 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { } } +/// Shared font database — building it scans system font directories (very slow +/// on WSL, where /mnt/c/Windows/Fonts goes through the 9P filesystem), so it is +/// built once and reused for every diagram. +fn shared_fontdb() -> Arc { + static FONTDB: OnceLock> = OnceLock::new(); + FONTDB.get_or_init(|| Arc::new(build_fontdb())).clone() +} + /// Build a font database with system fonts and platform-specific fallbacks. fn build_fontdb() -> resvg::usvg::fontdb::Database { use resvg::usvg::fontdb::Database; let mut db = Database::new(); + load_system_and_platform_fonts(&mut db); + load_fallback_font_directories(&mut db); + configure_font_families(&mut db); + + db +} + +/// Load system fonts and platform-specific fonts (Windows/WSL). +fn load_system_and_platform_fonts(db: &mut resvg::usvg::fontdb::Database) { db.load_system_fonts(); // On WSL / Windows, also load the Windows font directory @@ -224,7 +311,10 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if win_fonts.is_dir() { db.load_fonts_dir(win_fonts); } +} +/// Load fallback font directories if no fonts were found. +fn load_fallback_font_directories(db: &mut resvg::usvg::fontdb::Database) { // If the DB still has no fonts at all, try common directories explicitly. if db.is_empty() { for dir in ["/usr/share/fonts", "/usr/local/share/fonts"] { @@ -234,7 +324,10 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { } } } +} +/// Configure font families based on available fonts. +fn configure_font_families(db: &mut resvg::usvg::fontdb::Database) { // Collect the set of available family names once (avoids borrow conflicts). let available: std::collections::HashSet = db .faces() @@ -242,10 +335,27 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { .collect(); // Map generic CSS families to the first concrete font we find in the DB. + configure_sans_serif_family(db, &available); + configure_serif_family(db, &available); + configure_monospace_family(db, &available); +} + +/// Configure sans-serif font family. +fn configure_sans_serif_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let sans = ["Arial", "DejaVu Sans", "Liberation Sans", "Noto Sans"]; if let Some(f) = sans.iter().find(|n| available.contains(**n)) { db.set_sans_serif_family(*f); } +} + +/// Configure serif font family. +fn configure_serif_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let serif = [ "Times New Roman", "DejaVu Serif", @@ -255,6 +365,13 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if let Some(f) = serif.iter().find(|n| available.contains(**n)) { db.set_serif_family(*f); } +} + +/// Configure monospace font family. +fn configure_monospace_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let mono = [ "Courier New", "DejaVu Sans Mono", @@ -264,33 +381,36 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if let Some(f) = mono.iter().find(|n| available.contains(**n)) { db.set_monospace_family(*f); } - - db } -/// Convert SVG string to PNG bytes at 2x scale for print quality. -fn svg_to_png(svg: &str) -> Result> { - let fontdb = build_fontdb(); +/// Rasterization scale for SVG → PNG. Mermaid SVGs are sized in CSS pixels +/// (~96 dpi); 3x yields ~300 dpi when the figure is included at \linewidth, +/// which is print quality. +const RASTER_SCALE: f32 = 3.0; +/// Convert SVG string to PNG bytes at print resolution. +fn svg_to_png(svg: &str) -> Result> { let options = resvg::usvg::Options { - fontdb: std::sync::Arc::new(fontdb), + fontdb: shared_fontdb(), + shape_rendering: resvg::usvg::ShapeRendering::GeometricPrecision, + text_rendering: resvg::usvg::TextRendering::OptimizeLegibility, ..Default::default() }; let tree = resvg::usvg::Tree::from_str(svg, &options).context("Failed to parse SVG")?; - let scale = 2.0_f32; - let width = (tree.size().width() * scale) as u32; - let height = (tree.size().height() * scale) as u32; + let original_size = tree.size(); + let padding = 10.0; // padding (in SVG units) so strokes at the edge aren't clipped + let width = ((original_size.width() + padding * 2.0) * RASTER_SCALE) as u32; + let height = ((original_size.height() + padding * 2.0) * RASTER_SCALE) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).context("Failed to create pixmap")?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); + let transform = resvg::tiny_skia::Transform::from_scale(RASTER_SCALE, RASTER_SCALE) + .post_translate(padding * RASTER_SCALE, padding * RASTER_SCALE); + + resvg::render(&tree, transform, &mut pixmap.as_mut()); pixmap.encode_png().context("Failed to encode PNG") } @@ -319,6 +439,69 @@ mod tests { assert_eq!(map.get("caption").map(String::as_str), Some("My diagram")); } + #[test] + fn parse_opts_label_and_height() { + let (map, _) = parse_opts("[label=fig:my-diagram, height=5cm]"); + assert_eq!(map.get("label").map(String::as_str), Some("fig:my-diagram")); + assert_eq!(map.get("height").map(String::as_str), Some("5cm")); + } + + #[test] + fn build_figure_with_label() { + let mut opts = HashMap::new(); + opts.insert("caption".to_string(), "Test".to_string()); + opts.insert("label".to_string(), "fig:test".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\label{fig:test}")); + assert!(fig.contains("\\caption{Test}")); + assert!(fig.contains("\\begin{figure}")); + } + + #[test] + fn build_figure_with_height() { + let mut opts = HashMap::new(); + opts.insert("height".to_string(), "5cm".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("height=5cm")); + } + + #[test] + fn build_figure_with_width_and_height() { + let mut opts = HashMap::new(); + opts.insert("width".to_string(), "0.5\\linewidth".to_string()); + opts.insert("height".to_string(), "4cm".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("width=0.5\\linewidth")); + assert!(fig.contains("height=4cm")); + } + + #[test] + fn build_figure_with_scale() { + let mut opts = HashMap::new(); + opts.insert("scale".to_string(), "0.8".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("scale=0.8")); + assert!(!fig.contains("width=")); + } + + #[test] + fn build_figure_with_keepaspectratio() { + let mut opts = HashMap::new(); + opts.insert("width".to_string(), "10cm".to_string()); + opts.insert("height".to_string(), "8cm".to_string()); + opts.insert("keepaspectratio".to_string(), "true".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("keepaspectratio")); + } + + #[test] + fn build_figure_default_no_pos() { + let opts = HashMap::new(); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\begin{figure}\n")); + assert!(!fig.contains("[H]")); + } + #[test] fn render_graphviz_produces_svg() { let dot = "digraph G { A -> B }"; @@ -334,24 +517,48 @@ mod tests { fn render_env_no_blocks_unchanged() { let content = "hello world"; let dir = tempfile::tempdir().unwrap(); - let mut counter = 0; - let result = render_env(content, "graphviz", dir.path(), &mut counter, |_| { - Ok(vec![]) - }) - .unwrap(); + let result = render_env(content, "graphviz", dir.path(), |_| Ok(vec![])).unwrap(); assert_eq!(result, content); - assert_eq!(counter, 0); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn render_env_invalid_pos_returns_error() { let content = "\\begin{graphviz}[pos=Z]\ndigraph G{}\n\\end{graphviz}"; let dir = tempfile::tempdir().unwrap(); - let mut counter = 0; - let err = render_env(content, "graphviz", dir.path(), &mut counter, |_| { - Ok(vec![1, 2, 3]) - }) - .unwrap_err(); + let err = render_env(content, "graphviz", dir.path(), |_| Ok(vec![1, 2, 3])).unwrap_err(); assert!(err.to_string().contains("pos='Z'")); } + + #[test] + fn render_env_reuses_cached_diagram() { + let content = "\\begin{graphviz}\ndigraph G{ A -> B }\n\\end{graphviz}"; + let dir = tempfile::tempdir().unwrap(); + let calls = std::cell::Cell::new(0u32); + // render twice into the same dir — second pass must hit the cache + for _ in 0..2 { + render_env(content, "graphviz", dir.path(), |_| { + calls.set(calls.get() + 1); + Ok(vec![1, 2, 3]) + }) + .unwrap(); + } + assert_eq!(calls.get(), 1); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn build_figure_default_uses_linewidth() { + let opts = HashMap::new(); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\includegraphics[width=\\linewidth]")); + } + + #[test] + fn build_figure_with_pos_t() { + let mut opts = HashMap::new(); + opts.insert("pos".to_string(), "t".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\begin{figure}[t]")); + } } diff --git a/src/formatter/mod.rs b/src/formatter/mod.rs index a9ea0c4..f706d46 100644 --- a/src/formatter/mod.rs +++ b/src/formatter/mod.rs @@ -1,37 +1,40 @@ -//! LaTeX code formatter. +//! LaTeX + BibTeX code formatter. //! -//! Opinionated formatter inspired by `rustfmt` — one canonical output -//! regardless of input style. +//! Opinionated, deterministic formatter inspired by `rustfmt` — one canonical +//! output regardless of input style. It re-indents by nesting level +//! (environments *and* unbalanced braces), trims trailing whitespace, and +//! collapses runs of blank lines. It never reflows paragraph text or touches +//! the contents of verbatim-like environments. const INDENT: &str = " "; -/// Environments whose content must not be modified. +/// Environments whose content must be passed through untouched. const VERBATIM_ENVS: &[&str] = &["verbatim", "lstlisting", "minted", "Verbatim"]; /// Format LaTeX source code with consistent style. pub fn format(source: &str) -> String { - let mut output = Vec::new(); + let mut output: Vec = Vec::new(); let mut depth: usize = 0; let mut prev_blank = false; let mut verbatim: Option = None; for line in source.lines() { - // Inside verbatim: pass through untouched until matching \end + // Inside a verbatim environment: emit raw until the matching \end. if let Some(ref env) = verbatim { let end_tag = format!("\\end{{{}}}", env); - if line.trim().starts_with(&end_tag) { - verbatim = None; + if line.trim_start().starts_with(&end_tag) { depth = depth.saturating_sub(1); - output.push(line.trim().to_string()); + output.push(indent_line(depth, line.trim())); + verbatim = None; } else { - output.push(line.to_string()); + output.push(line.trim_end().to_string()); } continue; } let trimmed = line.trim(); - // Collapse multiple blank lines into one + // Collapse runs of blank lines into a single blank line. if trimmed.is_empty() { if !prev_blank && !output.is_empty() { output.push(String::new()); @@ -41,74 +44,83 @@ pub fn format(source: &str) -> String { } prev_blank = false; - // Dedent for \end{...} - if trimmed.starts_with("\\end{") { - depth = depth.saturating_sub(1); - } - - let indented = if depth > 0 - && !trimmed.starts_with("\\begin{") - && !trimmed.starts_with("\\end{") - && !is_structural_command(trimmed) - { - format!("{}{}", INDENT.repeat(depth), trimmed) - } else { - trimmed.to_string() - }; - - output.push(indented); + // Lines that *open* with a closer dedent before being placed, so the + // closer aligns with whatever opened it. + let display_depth = depth.saturating_sub(leading_dedent(trimmed)); + output.push(indent_line(display_depth, trimmed)); - // Indent after \begin{...} and check for verbatim - if trimmed.starts_with("\\begin{") { - if let Some(env) = extract_env_name(trimmed) { - if VERBATIM_ENVS.contains(&env.as_str()) { - verbatim = Some(env); - } - } + // A verbatim opener freezes formatting until its \end. + if let Some(env) = verbatim_opener(trimmed) { depth += 1; + verbatim = Some(env); + continue; } + + // Apply this line's net nesting change to the running depth. + let delta = nesting_delta(trimmed); + depth = (depth as i32 + delta).max(0) as usize; } - // Remove trailing blank lines + // Drop trailing blank lines, guarantee a single final newline. while output.last().is_some_and(|l| l.is_empty()) { output.pop(); } - let mut result = output.join("\n"); result.push('\n'); result } -/// Commands that should not be indented even inside environments. -fn is_structural_command(line: &str) -> bool { - const STRUCTURAL: &[&str] = &[ - "\\begin{", - "\\end{", - "\\documentclass", - "\\usepackage", - "\\section", - "\\subsection", - "\\chapter", - "\\title", - "\\author", - "\\date", - "\\maketitle", - "\\tableofcontents", - "\\input", - "\\bibliography", - "\\bibliographystyle", - "\\newcommand", - "\\renewcommand", - "\\pagestyle", - "\\geometry", - "\\hypersetup", - "\\numberwithin", - "\\titleformat", - "\\titlespacing", - "\\fancyhf", - "\\cfoot", - ]; - STRUCTURAL.iter().any(|cmd| line.starts_with(cmd)) +fn indent_line(depth: usize, content: &str) -> String { + if content.is_empty() { + String::new() + } else { + format!("{}{}", INDENT.repeat(depth), content) + } +} + +/// How many levels a line should dedent *before* being placed, based on the +/// closers it opens with (`\end{...}` or a run of leading `}` / `]`). +fn leading_dedent(line: &str) -> usize { + if line.starts_with("\\end{") { + return 1; + } + line.chars().take_while(|&c| c == '}' || c == ']').count() +} + +/// Net nesting change a line contributes: `\begin`/`\end` plus the balance of +/// unescaped braces and brackets (comments and escaped delimiters ignored). +fn nesting_delta(line: &str) -> i32 { + let mut delta = count_occurrences(line, "\\begin{") as i32; + delta -= count_occurrences(line, "\\end{") as i32; + + let mut escaped = false; + for ch in line.chars() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '%' => break, // rest of the line is a comment + '{' | '[' => delta += 1, + '}' | ']' => delta -= 1, + _ => {} + } + } + delta +} + +/// If the line opens a verbatim-like environment, return its name. +fn verbatim_opener(line: &str) -> Option { + if !line.starts_with("\\begin{") { + return None; + } + let env = extract_env_name(line)?; + VERBATIM_ENVS.contains(&env.as_str()).then_some(env) +} + +fn count_occurrences(haystack: &str, needle: &str) -> usize { + haystack.matches(needle).count() } /// Extract environment name from `\begin{envname}`. @@ -118,6 +130,240 @@ fn extract_env_name(line: &str) -> Option { Some(line[start..start + end].to_string()) } +// --------------------------------------------------------------------------- +// BibTeX formatter +// --------------------------------------------------------------------------- + +/// Format a `.bib` file: one field per line, two-space indent, aligned `=`, +/// lowercased entry types and field names, trailing comma after every field. +/// +/// Conservative by design — if the input can't be parsed cleanly (junk outside +/// entries, unbalanced delimiters, …) the original source is returned untouched +/// rather than risk corrupting references. +pub fn format_bib(source: &str) -> String { + match try_format_bib(source) { + Some(formatted) => formatted, + None => source.to_string(), + } +} + +struct BibEntry { + kind: String, + key: Option, + fields: Vec<(String, String)>, +} + +fn try_format_bib(source: &str) -> Option { + let chars: Vec = source.chars().collect(); + let n = chars.len(); + let mut i = 0; + let mut entries = Vec::new(); + + while i < n { + while i < n && chars[i].is_whitespace() { + i += 1; + } + if i >= n { + break; + } + // Any non-whitespace outside an entry is content we don't understand. + if chars[i] != '@' { + return None; + } + i += 1; + + let kind_start = i; + while i < n && chars[i].is_alphanumeric() { + i += 1; + } + let kind: String = chars[kind_start..i].iter().collect(); + if kind.is_empty() { + return None; + } + + while i < n && chars[i].is_whitespace() { + i += 1; + } + if i >= n { + return None; + } + let open = match chars[i] { + '{' => '{', + '(' => '(', + _ => return None, + }; + i += 1; + + let body_start = i; + let mut brace_depth = 0i32; + let mut in_quote = false; + loop { + if i >= n { + return None; // unterminated entry + } + let c = chars[i]; + if in_quote { + if c == '"' { + in_quote = false; + } + } else { + match c { + '"' => in_quote = true, + '{' => brace_depth += 1, + '}' => { + if open == '{' && brace_depth == 0 { + break; + } + brace_depth -= 1; + } + ')' if open == '(' && brace_depth == 0 => break, + _ => {} + } + } + i += 1; + } + let body: String = chars[body_start..i].iter().collect(); + i += 1; // consume closing delimiter + + entries.push(parse_bib_body(&kind, &body)?); + } + + if entries.is_empty() { + return None; + } + + let mut blocks: Vec = Vec::new(); + for entry in &entries { + blocks.push(render_bib_entry(entry)); + } + let mut out = blocks.join("\n\n"); + out.push('\n'); + Some(out) +} + +fn parse_bib_body(kind: &str, body: &str) -> Option { + let segments = split_top_level(body); + let kind_l = kind.to_lowercase(); + + // @string / @preamble have no citation key; everything is a field/value. + let key_less = matches!(kind_l.as_str(), "string" | "preamble"); + + let mut iter = segments.into_iter(); + let mut key = None; + if !key_less { + let first = iter.next()?.trim().to_string(); + if first.is_empty() || first.contains('=') { + return None; + } + key = Some(first); + } + + let mut fields = Vec::new(); + for seg in iter { + let seg = seg.trim(); + if seg.is_empty() { + continue; // tolerate trailing comma + } + let eq = top_level_eq(seg)?; + let name = seg[..eq].trim().to_lowercase(); + let value = collapse_ws(seg[eq + 1..].trim()); + if name.is_empty() { + return None; + } + fields.push((name, value)); + } + + Some(BibEntry { + kind: kind_l, + key, + fields, + }) +} + +fn render_bib_entry(entry: &BibEntry) -> String { + let mut s = String::new(); + s.push('@'); + s.push_str(&entry.kind); + s.push('{'); + + let width = entry.fields.iter().map(|(n, _)| n.len()).max().unwrap_or(0); + + match &entry.key { + Some(key) => s.push_str(key), + None => { + // key-less (@string/@preamble): keep the opening brace tight. + if let Some((name, value)) = entry.fields.first() { + s.push_str(&format!("{} = {}", name, value)); + } + s.push('}'); + return s; + } + } + + for (name, value) in &entry.fields { + s.push_str(",\n"); + s.push_str(INDENT); + s.push_str(&format!("{:width$} = {}", name, value, width = width)); + } + if !entry.fields.is_empty() { + s.push(','); + } + s.push('\n'); + s.push('}'); + s +} + +/// Split on top-level commas (ignoring commas inside braces or quotes). +fn split_top_level(body: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut depth = 0i32; + let mut in_quote = false; + for c in body.chars() { + match c { + '"' if depth == 0 => { + in_quote = !in_quote; + cur.push(c); + } + '{' if !in_quote => { + depth += 1; + cur.push(c); + } + '}' if !in_quote => { + depth -= 1; + cur.push(c); + } + ',' if depth == 0 && !in_quote => { + out.push(std::mem::take(&mut cur)); + } + _ => cur.push(c), + } + } + out.push(cur); + out +} + +/// Index of the first top-level `=` in a `name = value` segment. +fn top_level_eq(seg: &str) -> Option { + let mut depth = 0i32; + let mut in_quote = false; + for (idx, c) in seg.char_indices() { + match c { + '"' if depth == 0 => in_quote = !in_quote, + '{' if !in_quote => depth += 1, + '}' if !in_quote => depth -= 1, + '=' if depth == 0 && !in_quote => return Some(idx), + _ => {} + } + } + None +} + +/// Collapse internal whitespace runs (including newlines) into single spaces. +fn collapse_ws(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + #[cfg(test)] mod tests { use super::*; @@ -137,31 +383,87 @@ mod tests { fn indentation_inside_environment() { let src = "\\begin{document}\nhello\n\\end{document}"; let out = format(src); - assert!( - out.contains(" hello"), - "expected indented 'hello', got:\n{}", - out + assert_eq!(out, "\\begin{document}\n hello\n\\end{document}\n"); + } + + #[test] + fn nested_environments_indent_begin_and_end() { + let src = "\\begin{a}\n\\begin{b}\nx\n\\end{b}\n\\end{a}"; + let out = format(src); + assert_eq!( + out, + "\\begin{a}\n \\begin{b}\n x\n \\end{b}\n\\end{a}\n" ); } #[test] - fn multiple_blank_lines_collapsed() { - let src = "a\n\n\n\nb"; + fn multiline_brace_argument_indents() { + let src = "\\hypersetup{\npdftitle={X},\ncolorlinks\n}"; let out = format(src); - assert_eq!(out, "a\n\nb\n"); + assert_eq!(out, "\\hypersetup{\n pdftitle={X},\n colorlinks\n}\n"); + } + + #[test] + fn escaped_braces_do_not_affect_depth() { + let src = "\\begin{document}\na \\{ b \\} c\nd\n\\end{document}"; + let out = format(src); + assert_eq!( + out, + "\\begin{document}\n a \\{ b \\} c\n d\n\\end{document}\n" + ); } #[test] - fn structural_commands_not_indented() { - let src = "\\begin{document}\n\\section{Intro}\n\\end{document}"; + fn comment_braces_ignored() { + let src = "\\begin{document}\nx % this { is not counted\ny\n\\end{document}"; let out = format(src); - assert!(out.contains("\n\\section{Intro}\n"), "got:\n{}", out); + assert_eq!( + out, + "\\begin{document}\n x % this { is not counted\n y\n\\end{document}\n" + ); + } + + #[test] + fn multiple_blank_lines_collapsed() { + let src = "a\n\n\n\nb"; + let out = format(src); + assert_eq!(out, "a\n\nb\n"); } #[test] fn verbatim_content_preserved() { let src = "\\begin{verbatim}\n raw content\n\\end{verbatim}"; let out = format(src); - assert!(out.contains(" raw content"), "got:\n{}", out); + assert_eq!(out, "\\begin{verbatim}\n raw content\n\\end{verbatim}\n"); + } + + #[test] + fn bib_entry_formatted_and_aligned() { + let src = "@Article{key, author={A. B.},title = {T},year=2020}"; + let out = format_bib(src); + assert_eq!( + out, + "@article{key,\n author = {A. B.},\n title = {T},\n year = 2020,\n}\n" + ); + } + + #[test] + fn bib_multiline_value_collapsed() { + let src = "@book{k,\n title = {A\n long title},\n}"; + let out = format_bib(src); + assert_eq!(out, "@book{k,\n title = {A long title},\n}\n"); + } + + #[test] + fn bib_invalid_left_untouched() { + let src = "not a bib file at all"; + assert_eq!(format_bib(src), src); + } + + #[test] + fn bib_braced_comma_not_split() { + let src = "@misc{k, title={Hello, World}}"; + let out = format_bib(src); + assert_eq!(out, "@misc{k,\n title = {Hello, World},\n}\n"); } } diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 35839ad..f02a268 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -101,77 +101,141 @@ fn check_references( let line_num = i + 1; let line = strip_comment(line); - for arg in extract_commands(&line, "input") { - let input_path = resolve_tex_path(root, arg); - if !input_path.exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\input{{{}}} — file not found", arg), - suggestion: Some(format!("Create {}", input_path.display())), - }); - } - } + check_input_references(root, rel, line_num, &line, errors); + check_includegraphics_references(root, rel, line_num, &line, errors); + check_cite_references(rel, line_num, &line, bib_file, bib_keys, errors); + check_ref_references(rel, line_num, &line, all_labels, errors); + check_lstinputlisting_references(root, rel, line_num, &line, errors); + check_inputminted_references(root, rel, line_num, &line, errors); + } +} - for arg in extract_commands(&line, "includegraphics") { - let img_path = root.join(arg); - if !img_path.exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\includegraphics{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \input references for file existence. +fn check_input_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "input") { + let input_path = resolve_tex_path(root, arg); + if !input_path.exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\input{{{}}} — file not found", arg), + suggestion: Some(format!("Create {}", input_path.display())), + }); } + } +} - if bib_file.is_some() { - for arg in extract_commands(&line, "cite") { - for key in arg.split(',') { - let key = key.trim(); - if !key.is_empty() && !bib_keys.contains(key) { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\cite{{{}}} — key not found in .bib", key), - suggestion: None, - }); - } - } - } +/// Check \includegraphics references for file existence. +fn check_includegraphics_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "includegraphics") { + let img_path = root.join(arg); + if !img_path.exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\includegraphics{{{}}} — file not found", arg), + suggestion: None, + }); } + } +} - for arg in extract_commands(&line, "ref") { - if !all_labels.contains(arg) { +/// Check \cite references against bibliography keys. +fn check_cite_references( + rel: &str, + line_num: usize, + line: &str, + bib_file: Option<&str>, + bib_keys: &HashSet, + errors: &mut Vec, +) { + if bib_file.is_none() { + return; + } + + for arg in extract_commands(line, "cite") { + for key in arg.split(',') { + let key = key.trim(); + if !key.is_empty() && !bib_keys.contains(key) { errors.push(LintError { file: rel.to_string(), line: line_num, - message: format!("\\ref{{{}}} — no matching \\label found", arg), + message: format!("\\cite{{{}}} — key not found in .bib", key), suggestion: None, }); } } + } +} - for arg in extract_commands(&line, "lstinputlisting") { - if !root.join(arg).exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\lstinputlisting{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \ref references against defined labels. +fn check_ref_references( + rel: &str, + line_num: usize, + line: &str, + all_labels: &HashSet, + errors: &mut Vec, +) { + for arg in extract_commands(line, "ref") { + if !all_labels.contains(arg) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\ref{{{}}} — no matching \\label found", arg), + suggestion: None, + }); } + } +} - for arg in extract_inputminted_files(&line) { - if !root.join(arg).exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\inputminted{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \lstinputlisting references for file existence. +fn check_lstinputlisting_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "lstinputlisting") { + if !root.join(arg).exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\lstinputlisting{{{}}} — file not found", arg), + suggestion: None, + }); + } + } +} + +/// Check \inputminted references for file existence. +fn check_inputminted_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_inputminted_files(line) { + if !root.join(arg).exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\inputminted{{{}}} — file not found", arg), + suggestion: None, + }); } } } @@ -321,8 +385,6 @@ fn strip_comment(line: &str) -> String { /// Check mermaid/graphviz blocks: unclosed and invalid pos option. fn check_diagram_blocks(rel: &str, content: &str, env: &str, errors: &mut Vec) { - const VALID_POS: &[&str] = &["H", "t", "b", "h", "p"]; - for (i, line) in content.lines().enumerate() { let line_num = i + 1; let trimmed = line.trim(); @@ -331,38 +393,63 @@ fn check_diagram_blocks(rel: &str, content: &str, env: &str, errors: &mut Vec
  • ()..]; - if !rest.contains(&*end_tag) { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\begin{{{}}} without matching \\end{{{}}}", env, env), - suggestion: Some(format!("Add \\end{{{}}}", env)), - }); - continue; - } + check_unclosed_diagram_block(rel, content, env, line_num, i, errors); + check_diagram_pos_option(rel, trimmed, env, line_num, errors); + } +} + +/// Check if a diagram block is properly closed. +fn check_unclosed_diagram_block( + rel: &str, + content: &str, + env: &str, + line_num: usize, + line_index: usize, + errors: &mut Vec, +) { + let end_tag = format!("\\end{{{}}}", env); + let rest = &content[content + .lines() + .take(line_index) + .map(|l| l.len() + 1) + .sum::()..]; + if !rest.contains(&*end_tag) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\begin{{{}}} without matching \\end{{{}}}", env, env), + suggestion: Some(format!("Add \\end{{{}}}", env)), + }); + } +} + +/// Check if the pos option in diagram block is valid. +fn check_diagram_pos_option( + rel: &str, + line: &str, + env: &str, + line_num: usize, + errors: &mut Vec, +) { + const VALID_POS: &[&str] = &["H", "t", "b", "h", "p"]; - // Check pos option if present - if let Some(opts_start) = trimmed.find('[') { - if let Some(opts_end) = trimmed.find(']') { - let opts = &trimmed[opts_start + 1..opts_end]; - for part in opts.split(',') { - if let Some((k, v)) = part.split_once('=') { - if k.trim() == "pos" { - let pos = v.trim(); - if !VALID_POS.contains(&pos) { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!( - "\\begin{{{}}} invalid pos='{}' — valid values: H, t, b, h, p", - env, pos - ), - suggestion: Some("Use pos=H, pos=t, pos=b, pos=h, or pos=p".into()), - }); - } + if let Some(opts_start) = line.find('[') { + if let Some(opts_end) = line.find(']') { + let opts = &line[opts_start + 1..opts_end]; + for part in opts.split(',') { + if let Some((k, v)) = part.split_once('=') { + if k.trim() == "pos" { + let pos = v.trim(); + if !VALID_POS.contains(&pos) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!( + "\\begin{{{}}} invalid pos='{}' — valid values: H, t, b, h, p", + env, pos + ), + suggestion: Some("Use pos=H, pos=t, pos=b, pos=h, or pos=p".into()), + }); } } } diff --git a/src/main.rs b/src/main.rs index a52ea00..8f4bf9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,10 @@ use clap::Parser; use cli::Cli; fn main() -> Result<()> { + // reqwest is built with `rustls-no-provider`, so install the ring crypto + // provider as the process default before any HTTPS request is made. + let _ = rustls::crypto::ring::default_provider().install_default(); + let cli = Cli::parse(); cli.execute() } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 2511485..b18ae9e 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use crate::utils; -const REGISTRY_REPO: &str = "JheisonMB/texforge-templates"; +const REGISTRY_REPO: &str = "UniverLab/texforge-templates"; /// Embedded files for the "general" template (fallback when offline). const GENERAL_TEMPLATE_TOML: &str = include_str!("general/template.toml"); @@ -119,7 +119,7 @@ pub fn download(name: &str) -> Result { let mut entry = entry?; let path = entry.path()?.to_string_lossy().to_string(); - // GitHub tarballs have a root dir like "JheisonMB-texforge-templates-abc1234/" + // GitHub tarballs have a root dir like "UniverLab-texforge-templates-abc1234/" // We need to find entries under "//..." let Some(after_root) = path.split_once('/').map(|x| x.1) else { continue; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ac5df65..386fcfd 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -20,7 +20,7 @@ pub fn templates_dir() -> anyhow::Result { Ok(dir) } -/// Find all .tex files in a directory, excluding build/ +/// Find all .tex files in a directory, excluding build/. pub fn find_tex_files(root: &Path) -> anyhow::Result> { let mut files = Vec::new(); let build_dir = root.join("build"); @@ -46,62 +46,149 @@ pub fn find_tex_files(root: &Path) -> anyhow::Result> { Ok(files) } -/// Mirror asset directories into build/ using symlinks (Unix) or file copy (Windows). -/// Skips .tex files (handled by the diagram pre-processor) and build/ itself. -pub fn mirror_assets(root: &Path, build_dir: &Path) -> anyhow::Result<()> { - for entry in std::fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - let name = entry.file_name(); - let name_str = name.to_string_lossy(); +/// Find all `.bib` files under `root` (excluding the legacy `build/` dir). +pub fn find_bib_files(root: &Path) -> anyhow::Result> { + let mut files = Vec::new(); + let build_dir = root.join("build"); - if name_str.starts_with('.') || path == build_dir { + for entry in walkdir::WalkDir::new(root) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if path.starts_with(&build_dir) { continue; } + if entry.file_type().is_file() && path.extension().is_some_and(|ext| ext == "bib") { + files.push(path.to_path_buf()); + } + } - let dest = build_dir.join(&name); + Ok(files) +} - if path.is_dir() { - if dest.exists() || dest.symlink_metadata().is_ok() { - continue; - } - link_or_copy_dir(&path, &dest)?; - } else if path.is_file() { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - if ext != "tex" && !dest.exists() { - std::fs::copy(&path, &dest)?; - } +/// Sanitize a document title into a valid filename (lowercase, alphanumeric + hyphens). +pub fn sanitize_filename(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Mirror asset files into the build dir so tectonic can resolve relative paths. +/// Skips .tex files (the diagram pre-processor writes processed copies), hidden +/// entries, and a legacy top-level `build/` directory. On Unix each file is +/// symlinked with an absolute target (the build dir may live in the system temp +/// dir, so relative links would dangle); on Windows files are copied. +pub fn mirror_assets(root: &Path, build_dir: &Path) -> anyhow::Result<()> { + let root = root + .canonicalize() + .with_context(|| format!("Failed to resolve {}", root.display()))?; + + let walker = walkdir::WalkDir::new(&root) + .min_depth(1) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + let is_legacy_build = e.depth() == 1 && name == "build"; + !name.starts_with('.') && !is_legacy_build && e.path() != build_dir + }); + + for entry in walker.filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; } + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("tex") { + continue; + } + let rel = path.strip_prefix(&root).unwrap(); + let dest = build_dir.join(rel); + if dest.symlink_metadata().is_ok() { + continue; + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + link_or_copy_file(path, &dest)?; } Ok(()) } #[cfg(unix)] -fn link_or_copy_dir(src: &Path, dest: &Path) -> anyhow::Result<()> { - let target = std::path::Path::new("..").join(src.file_name().unwrap()); - std::os::unix::fs::symlink(&target, dest).with_context(|| { - format!( - "Failed to symlink {} -> {}", - dest.display(), - target.display() - ) - }) +fn link_or_copy_file(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::os::unix::fs::symlink(src, dest) + .with_context(|| format!("Failed to symlink {} -> {}", dest.display(), src.display())) } #[cfg(not(unix))] -fn link_or_copy_dir(src: &Path, dest: &Path) -> anyhow::Result<()> { - std::fs::create_dir_all(dest)?; - for entry in walkdir::WalkDir::new(src) - .into_iter() - .filter_map(|e| e.ok()) - { - let rel = entry.path().strip_prefix(src).unwrap(); - let target = dest.join(rel); - if entry.file_type().is_dir() { - std::fs::create_dir_all(&target)?; - } else { - std::fs::copy(entry.path(), &target)?; - } - } +fn link_or_copy_file(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::fs::copy(src, dest) + .with_context(|| format!("Failed to copy {} -> {}", src.display(), dest.display()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_basic_title() { + assert_eq!( + sanitize_filename("My Thesis: Final Version"), + "my-thesis-final-version" + ); + } + + #[test] + fn sanitize_collapses_separators() { + assert_eq!(sanitize_filename("a -- b"), "a-b"); + } + + #[test] + fn sanitize_keeps_unicode_alphanumerics() { + assert_eq!(sanitize_filename("Análisis Numérico"), "análisis-numérico"); + } + + #[test] + fn mirror_assets_links_nested_assets_in_tex_dirs() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + let chapters = src.path().join("chapters"); + std::fs::create_dir_all(&chapters).unwrap(); + std::fs::write(chapters.join("ch1.tex"), "x").unwrap(); + std::fs::write(chapters.join("img.png"), [1u8, 2]).unwrap(); + std::fs::write(src.path().join("refs.bib"), "@misc{a}").unwrap(); + + mirror_assets(src.path(), build.path()).unwrap(); + + let img = build.path().join("chapters/img.png"); + assert!(img.exists(), "nested asset should be mirrored"); + assert_eq!(std::fs::read(&img).unwrap(), vec![1u8, 2]); + assert!(build.path().join("refs.bib").exists()); + assert!( + !build.path().join("chapters/ch1.tex").exists(), + ".tex files must not be mirrored" + ); + } + + #[test] + fn mirror_assets_skips_hidden_and_legacy_build() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(src.path().join("build")).unwrap(); + std::fs::write(src.path().join("build/old.pdf"), "x").unwrap(); + std::fs::write(src.path().join(".hidden"), "x").unwrap(); + + mirror_assets(src.path(), build.path()).unwrap(); + + assert!(!build.path().join("build").exists()); + assert!(!build.path().join(".hidden").exists()); + } +}