From 20488dd271b950709749b4289c88b87e7652fdd6 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 17:10:13 -0500 Subject: [PATCH 01/17] fix: normalize ci.yml line endings to LF --- .github/workflows/ci.yml | 216 +++++++++++++++++++-------------------- 1 file changed, 108 insertions(+), 108 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17bd0a..e37e204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,108 +1,108 @@ -name: CI - -on: - pull_request: - branches: - - develop - - main - -permissions: - contents: read - pull-requests: write - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - name: Check, Test & Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Cargo fmt - run: cargo fmt --check - - - name: Cargo build - run: cargo build --release - - - name: Cargo test - run: cargo test - - - name: Cargo clippy - run: cargo clippy --all-targets -- -D warnings - - publish-check: - name: Publish Check (dry-run) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cargo publish dry-run - run: cargo publish --dry-run - - # Validaciones adicionales para PRs a main - main-pr-checks: - name: Main PR Requirements - if: github.base_ref == 'main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Check version bump - run: | - # Obtener la versión actual - CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "Versión en Cargo.toml: $CURRENT_VERSION" - - # Obtener el último tag - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$LAST_TAG" ]; then - echo "✅ Primer release (no hay tags previos)" - else - LAST_VERSION=${LAST_TAG#v} - echo "Última versión en tag: $LAST_VERSION" - if [ "$CURRENT_VERSION" = "$LAST_VERSION" ]; then - echo "❌ Error: La versión en Cargo.toml debe bumpearse en PR a main" - echo "Cambio requerido para PRs a main:" - echo "- develop → main: version DEBE incrementarse" - exit 1 - fi - echo "✅ Versión bumpeada correctamente" - fi +name: CI + +on: + pull_request: + branches: + - develop + - main + +permissions: + contents: read + pull-requests: write + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Check, Test & Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Cargo fmt + run: cargo fmt --check + + - name: Cargo build + run: cargo build --release + + - name: Cargo test + run: cargo test + + - name: Cargo clippy + run: cargo clippy --all-targets -- -D warnings + + publish-check: + name: Publish Check (dry-run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Cargo publish dry-run + run: cargo publish --dry-run + + # Validaciones adicionales para PRs a main + main-pr-checks: + name: Main PR Requirements + if: github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check version bump + run: | + # Obtener la versión actual + CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Versión en Cargo.toml: $CURRENT_VERSION" + + # Obtener el último tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + echo "✅ Primer release (no hay tags previos)" + else + LAST_VERSION=${LAST_TAG#v} + echo "Última versión en tag: $LAST_VERSION" + if [ "$CURRENT_VERSION" = "$LAST_VERSION" ]; then + echo "❌ Error: La versión en Cargo.toml debe bumpearse en PR a main" + echo "Cambio requerido para PRs a main:" + echo "- develop → main: version DEBE incrementarse" + exit 1 + fi + echo "✅ Versión bumpeada correctamente" + fi From 3ba0e1d519503e732170381fce4429addfdf6433 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 17:10:24 -0500 Subject: [PATCH 02/17] fix: replace task-trigger-mcp references with texforge in release workflow --- .github/workflows/release.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd394a3..0dcc79c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: if: matrix.archive == 'tar.gz' run: | cd target/${{ matrix.target }}/release - tar czf ../../../task-trigger-mcp-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.tar.gz task-trigger-mcp + tar czf ../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.tar.gz texforge cd ../../.. - name: Package (windows) @@ -115,14 +115,14 @@ jobs: shell: pwsh run: | cd target/${{ matrix.target }}/release - Compress-Archive -Path task-trigger-mcp.exe -DestinationPath ../../../task-trigger-mcp-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.zip + Compress-Archive -Path texforge.exe -DestinationPath ../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.zip cd ../../.. - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: task-trigger-mcp-${{ matrix.target }} - path: task-trigger-mcp-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.* + name: texforge-${{ matrix.target }} + path: texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.* github-release: name: Create GitHub Release @@ -168,4 +168,3 @@ jobs: - name: Cargo publish run: cargo publish --token ${{ secrets.CARGO_TOKEN }} - From 30c9988acb27da3dc8f7a9fbbf3297f62ee770ae Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 17:12:53 -0500 Subject: [PATCH 03/17] chore: add .gitattributes to enforce LF line endings --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf From afd04daf3d190c5c2eb53b54a70e0e178e6368ec Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 17:14:38 -0500 Subject: [PATCH 04/17] feat: add install.sh for binary installation from GitHub Releases --- .gitattributes | 1 + install.sh | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 install.sh diff --git a/.gitattributes b/.gitattributes index 6313b56..a8a7839 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +install.sh text eol=lf diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ed05f9b --- /dev/null +++ b/install.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# install.sh — download and install texforge from GitHub Releases +# Usage: curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh +set -eu + +REPO="JheisonMB/texforge" +BINARY="texforge" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" + +info() { printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2"; } +error() { printf ' \033[1;31merror:\033[0m %s\n' "$1" >&2; exit 1; } + +# --- detect OS --- +OS="$(uname -s)" +case "$OS" in + Linux*) OS_TARGET="unknown-linux-musl" ;; + Darwin*) OS_TARGET="apple-darwin" ;; + *) error "Unsupported OS: $OS (only Linux and macOS are supported)" ;; +esac + +# --- detect arch --- +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH_TARGET="x86_64" ;; + arm64|aarch64) + if [ "$OS" = "Darwin" ]; then + ARCH_TARGET="aarch64" + else + error "aarch64 Linux builds are not available yet" + fi + ;; + *) error "Unsupported architecture: $ARCH" ;; +esac + +TARGET="${ARCH_TARGET}-${OS_TARGET}" +info "platform" "$TARGET" + +# --- resolve latest version --- +if [ -n "${VERSION:-}" ]; then + TAG="v$VERSION" + info "version" "$TAG (pinned)" +else + TAG=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | rev | cut -d'/' -f1 | rev) + [ -z "$TAG" ] && error "Could not resolve latest release tag" + info "version" "$TAG (latest)" +fi + +# --- download --- +ARCHIVE="${BINARY}-${TAG}-${TARGET}.tar.gz" +URL="https://github.com/$REPO/releases/download/${TAG}/${ARCHIVE}" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +info "download" "$URL" +HTTP_CODE=$(curl -fSL -w '%{http_code}' -o "$TMPDIR/$ARCHIVE" "$URL" 2>/dev/null) || true +[ "$HTTP_CODE" = "200" ] || error "Download failed (HTTP $HTTP_CODE). Check that $TAG exists for $TARGET at:\n $URL" + +# --- extract --- +tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" +[ -f "$TMPDIR/$BINARY" ] || error "Binary not found in archive" + +# --- install --- +mkdir -p "$INSTALL_DIR" +mv "$TMPDIR/$BINARY" "$INSTALL_DIR/$BINARY" +chmod +x "$INSTALL_DIR/$BINARY" +info "installed" "$INSTALL_DIR/$BINARY" + +# --- verify PATH --- +case ":$PATH:" in + *":$INSTALL_DIR:"*) + PATH_OK=true + ;; + *) + PATH_OK=false + ;; +esac + +# --- add to PATH if needed --- +if [ "$PATH_OK" = "false" ]; then + export PATH="$INSTALL_DIR:$PATH" + + # Try to update shell profile files + for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$profile" ]; then + if ! grep -q "export PATH=\"$INSTALL_DIR:\$PATH\"" "$profile" 2>/dev/null; then + printf '\n# Added by texforge installer\nexport PATH="%s:$PATH"\n' "$INSTALL_DIR" >> "$profile" + info "updated" "$profile" + fi + fi + done + + info "note" "$INSTALL_DIR added to PATH for this session" +fi + +info "done" "$($INSTALL_DIR/$BINARY --version 2>/dev/null || echo "$BINARY installed")" From 85c298303cb67e7d9545293d2196757f00742d54 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 17:44:50 -0500 Subject: [PATCH 05/17] feat: implement texforge new with template download and embedded fallback --- Cargo.toml | 7 + src/commands/build.rs | 6 +- src/commands/check.rs | 6 +- src/commands/fmt.rs | 6 +- src/commands/new.rs | 70 +++++++-- src/commands/template.rs | 41 +++-- src/domain/project.rs | 12 +- src/error/mod.rs | 6 +- src/main.rs | 3 +- src/templates/general/bib/references.bib | 26 +++ src/templates/general/main.tex | 41 +++++ src/templates/general/sections/body.tex | 1 + src/templates/general/template.toml | 14 ++ src/templates/mod.rs | 192 +++++++++++++++++++++++ src/utils/mod.rs | 10 +- 15 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 src/templates/general/bib/references.bib create mode 100644 src/templates/general/main.tex create mode 100644 src/templates/general/sections/body.tex create mode 100644 src/templates/general/template.toml create mode 100644 src/templates/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 8258f6c..c9af913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,13 @@ walkdir = "2.5" # Home directory resolution dirs = "6.0" +# HTTP client (template downloads) +reqwest = { version = "0.12", features = ["blocking"] } + +# Archive extraction (GitHub tarballs) +flate2 = "1.1" +tar = "0.4" + [dev-dependencies] tempfile = "3.8" diff --git a/src/commands/build.rs b/src/commands/build.rs index 68aea0c..a845cf0 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -7,17 +7,17 @@ use crate::domain::project::Project; /// Compile project to PDF pub fn execute() -> Result<()> { let project = Project::load()?; - + println!("Building project: {}", project.config.documento.titulo); println!("Entry point: {}", project.entry_path().display()); println!("TODO: Implement compilation"); - + // TODO: // 1. Load template // 2. Assemble document (preamble + body) // 3. Render embedded diagrams (Mermaid, Graphviz) // 4. Compile with internal engine // 5. Report clean errors on failure - + Ok(()) } diff --git a/src/commands/check.rs b/src/commands/check.rs index 7480d0f..e98e5d9 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -7,10 +7,10 @@ use crate::domain::project::Project; /// Lint project without compiling pub fn execute() -> Result<()> { let project = Project::load()?; - + println!("Checking project: {}", project.config.documento.titulo); println!("TODO: Implement linter"); - + // TODO: // 1. Check \cite{} references exist in .bib // 2. Check \includegraphics files exist @@ -18,6 +18,6 @@ pub fn execute() -> Result<()> { // 4. Check \label{} / \ref{} consistency // 5. Validate project.toml variables // 6. Check diagram blocks are well-formed - + Ok(()) } diff --git a/src/commands/fmt.rs b/src/commands/fmt.rs index 889b0d3..9e91b58 100644 --- a/src/commands/fmt.rs +++ b/src/commands/fmt.rs @@ -9,15 +9,15 @@ pub fn execute(check: bool) -> Result<()> { } else { println!("Formatting .tex files..."); } - + println!("TODO: Implement formatter"); - + // TODO: // 1. Find all .tex files in project // 2. Parse and normalize formatting // 3. Apply consistent indentation // 4. Align \begin{} / \end{} blocks // 5. Write back (or check-only mode) - + Ok(()) } diff --git a/src/commands/new.rs b/src/commands/new.rs index 513a711..1960d87 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,20 +1,62 @@ //! `texforge new` command implementation. -use anyhow::Result; +use std::path::Path; -/// Create a new project from a template +use anyhow::{Context, Result}; + +use crate::templates; + +/// Create a new project from a template. pub fn execute(name: &str, template: Option<&str>) -> Result<()> { - let template_name = template.unwrap_or("basic"); - - println!("Creating project '{}' with template '{}'", name, template_name); - println!("TODO: Implement project creation"); - - // TODO: - // 1. Validate template exists - // 2. Create project directory - // 3. Copy template structure - // 4. Generate project.toml - // 5. Create main.tex entry point - + let template_name = template.unwrap_or("general"); + let project_dir = Path::new(name); + + if project_dir.exists() { + anyhow::bail!("Directory '{}' already exists", name); + } + + println!( + "Creating project '{}' with template '{}'...", + name, template_name + ); + + let resolved = templates::resolve(template_name)?; + + // 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 + if rel_path == "template.toml" { + continue; + } + let dest = project_dir.join(rel_path); + 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()))?; + } + + // Generate project.toml + let project_toml = format!( + r#"[documento] +titulo = "{name}" +autor = "Author" +template = "{template_name}" + +[compilacion] +entry = "main.tex" +bibliografia = "bib/references.bib" +"# + ); + std::fs::write(project_dir.join("project.toml"), project_toml)?; + + // Ensure assets/images directory exists + std::fs::create_dir_all(project_dir.join("assets/images"))?; + + println!("✅ Project '{}' created successfully", name); + println!(); + println!(" cd {}", name); + println!(" texforge build"); + Ok(()) } diff --git a/src/commands/template.rs b/src/commands/template.rs index b74703b..ee0ed6f 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -2,30 +2,45 @@ use anyhow::Result; -/// List available templates +use crate::templates; + +/// List available templates. pub fn list() -> Result<()> { - println!("Available templates:"); - println!("TODO: List templates from local cache"); + let cached = templates::list_cached()?; + if cached.is_empty() { + println!("No templates installed locally."); + println!("The 'general' template is always available (built-in)."); + } else { + println!("Installed templates:"); + for name in &cached { + println!(" - {}", name); + } + } Ok(()) } -/// Add a template from URL or registry -pub fn add(source: &str) -> Result<()> { - println!("Adding template from: {}", source); - println!("TODO: Download and validate template"); +/// Add a template from the registry. +pub fn add(name: &str) -> Result<()> { + println!("Downloading template '{}'...", name); + templates::download(name)?; + println!("✅ Template '{}' installed", name); Ok(()) } -/// Remove a template +/// Remove a template from local cache. pub fn remove(name: &str) -> Result<()> { - println!("Removing template: {}", name); - println!("TODO: Remove template from local cache"); + let path = templates::remove_cached(name)?; + println!("✅ Removed template '{}' ({})", name, path.display()); Ok(()) } -/// Validate template compatibility +/// Validate template compatibility. pub fn validate(name: &str) -> Result<()> { - println!("Validating template: {}", name); - println!("TODO: Check template compatibility with internal engine"); + let resolved = templates::resolve(name)?; + if resolved.files.contains_key("template.toml") { + println!("✅ Template '{}' is valid", name); + } else { + anyhow::bail!("Template '{}' is missing template.toml", name); + } Ok(()) } diff --git a/src/domain/project.rs b/src/domain/project.rs index c0878e3..45274a4 100644 --- a/src/domain/project.rs +++ b/src/domain/project.rs @@ -24,7 +24,7 @@ pub struct CompilacionConfig { pub bibliografia: Option, } -/// Represents a TexForge project +/// Represents a `TexForge` project #[derive(Debug)] pub struct Project { pub root: PathBuf, @@ -36,22 +36,22 @@ impl Project { pub fn load() -> anyhow::Result { let root = std::env::current_dir()?; let config_path = root.join("project.toml"); - + if !config_path.exists() { anyhow::bail!("No project.toml found in current directory"); } - + let content = std::fs::read_to_string(&config_path)?; let config: ProjectConfig = toml::from_str(&content)?; - + Ok(Self { root, config }) } - + /// Get the entry point file path pub fn entry_path(&self) -> PathBuf { self.root.join(&self.config.compilacion.entry) } - + /// Get the bibliography file path if configured pub fn bib_path(&self) -> Option { self.config diff --git a/src/error/mod.rs b/src/error/mod.rs index 767bbfa..425bcec 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -6,13 +6,13 @@ use thiserror::Error; pub enum TexForgeError { #[error("Project not found: {0}")] ProjectNotFound(String), - + #[error("Template not found: {0}")] TemplateNotFound(String), - + #[error("Compilation failed: {0}")] CompilationFailed(String), - + #[error("Invalid configuration: {0}")] InvalidConfig(String), } diff --git a/src/main.rs b/src/main.rs index 71befac..ecfe688 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ //! texforge — Self-contained LaTeX to PDF compiler CLI. //! //! A command-line tool that compiles LaTeX documents to PDF without requiring -//! TeX Live, MiKTeX, or any external LaTeX distribution. +//! TeX Live, `MiKTeX`, or any external LaTeX distribution. mod cli; mod commands; @@ -10,6 +10,7 @@ mod domain; mod error; mod formatter; mod linter; +mod templates; mod utils; use anyhow::Result; diff --git a/src/templates/general/bib/references.bib b/src/templates/general/bib/references.bib new file mode 100644 index 0000000..1e0efa1 --- /dev/null +++ b/src/templates/general/bib/references.bib @@ -0,0 +1,26 @@ +@article{example, + author = {John Doe}, + title = {Título del Artículo de Ejemplo}, + journal = {Nombre de la Revista}, + year = {2023}, + volume = {1}, + number = {1}, + pages = {1--10}, + doi = {10.1000/j.abc} +} + +@book{example2, + author = {Jane Smith}, + title = {Título del Libro de Ejemplo}, + publisher = {Editorial de Ejemplo}, + year = {2022}, + isbn = {978-3-16-148410-0} +} + +@misc{example3, + author = {Blog Author}, + title = {Título del Artículo de Blog}, + howpublished = {\url{https://www.example.com/blog/article}}, + year = {2023}, + note = {Accessed: 2023-01-01} +} \ No newline at end of file diff --git a/src/templates/general/main.tex b/src/templates/general/main.tex new file mode 100644 index 0000000..9487ed7 --- /dev/null +++ b/src/templates/general/main.tex @@ -0,0 +1,41 @@ +%% main.tex - Plantilla General +%% Plantilla genérica para documentos LaTeX + +\documentclass[a4paper,12pt]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage[spanish]{babel} +\usepackage{amsmath} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{geometry} +\usepackage{fancyhdr} + +\geometry{margin=2.5cm} + +\pagestyle{fancy} +\fancyhf{} +\cfoot{\thepage} + +\title{Título del Documento} +\author{Autor} +\date{\today} + +\begin{document} + +\maketitle + +\begin{abstract} +Resumen del documento. +\end{abstract} + +\section{Sección 1} +Contenido de la sección. + +\subsection{Subsección} +Más contenido. + +\section{Sección 2} +Contenido adicional. + +\end{document} diff --git a/src/templates/general/sections/body.tex b/src/templates/general/sections/body.tex new file mode 100644 index 0000000..4e56db7 --- /dev/null +++ b/src/templates/general/sections/body.tex @@ -0,0 +1 @@ +% TODO: Contenido del documento diff --git a/src/templates/general/template.toml b/src/templates/general/template.toml new file mode 100644 index 0000000..7df4656 --- /dev/null +++ b/src/templates/general/template.toml @@ -0,0 +1,14 @@ +[metadata] +nombre = "general" +descripcion = "Documento genérico — artículo con estructura mínima" +idioma = "es" +tipo = "article" + +[variables] +requeridas = ["titulo", "autor"] +opcionales = [] + +[compatibilidad] +mermaid = false +graphviz = false +bibliografia = "bibtex" diff --git a/src/templates/mod.rs b/src/templates/mod.rs new file mode 100644 index 0000000..29bf248 --- /dev/null +++ b/src/templates/mod.rs @@ -0,0 +1,192 @@ +//! Embedded templates and template resolution. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::utils; + +const REGISTRY_REPO: &str = "JheisonMB/texforge-templates"; + +/// 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"); +const GENERAL_BODY_TEX: &str = include_str!("general/sections/body.tex"); +const GENERAL_REFERENCES_BIB: &str = include_str!("general/bib/references.bib"); + +/// A resolved template ready to scaffold a project. +pub struct ResolvedTemplate { + pub name: String, + /// Map of relative path -> file contents. + pub files: HashMap>, +} + +/// Resolve a template by name: local 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); + } + + // 2. Try downloading from GitHub + if let Ok(t) = download(name) { + return Ok(t); + } + + // 3. Fallback to embedded (only "general") + if name == "general" { + return Ok(embedded_general()); + } + + anyhow::bail!( + "Template '{}' not found. Run 'texforge template add {}' first.", + name, + name + ); +} + +fn embedded_general() -> ResolvedTemplate { + let mut files = HashMap::new(); + files.insert( + "template.toml".into(), + GENERAL_TEMPLATE_TOML.as_bytes().to_vec(), + ); + files.insert("main.tex".into(), GENERAL_MAIN_TEX.as_bytes().to_vec()); + files.insert( + "sections/body.tex".into(), + GENERAL_BODY_TEX.as_bytes().to_vec(), + ); + files.insert( + "bib/references.bib".into(), + GENERAL_REFERENCES_BIB.as_bytes().to_vec(), + ); + ResolvedTemplate { + name: "general".into(), + files, + } +} + +fn load_from_cache(name: &str) -> Result { + let dir = utils::templates_dir()?.join(name); + if !dir.is_dir() { + anyhow::bail!("not cached"); + } + load_dir_recursive(&dir, name) +} + +fn load_dir_recursive(base: &Path, name: &str) -> Result { + let mut files = HashMap::new(); + for entry in walkdir::WalkDir::new(base) + .into_iter() + .filter_map(|e| e.ok()) + { + if entry.file_type().is_file() { + let rel = entry + .path() + .strip_prefix(base)? + .to_string_lossy() + .to_string(); + let content = std::fs::read(entry.path())?; + files.insert(rel, content); + } + } + Ok(ResolvedTemplate { + name: name.into(), + files, + }) +} + +/// Download a template tarball from GitHub and cache it locally. +pub fn download(name: &str) -> Result { + let url = format!( + "https://api.github.com/repos/{}/tarball/main", + REGISTRY_REPO + ); + + let response = reqwest::blocking::Client::new() + .get(&url) + .header("User-Agent", "texforge") + .send() + .context("Failed to connect to template registry")?; + + if !response.status().is_success() { + anyhow::bail!("Registry returned HTTP {}", response.status()); + } + + let bytes = response.bytes()?; + let decoder = flate2::read::GzDecoder::new(&bytes[..]); + let mut archive = tar::Archive::new(decoder); + + let cache_dir = utils::templates_dir()?.join(name); + let mut files = HashMap::new(); + let prefix = format!("{}/", name); + + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?.to_string_lossy().to_string(); + + // GitHub tarballs have a root dir like "JheisonMB-texforge-templates-abc1234/" + // We need to find entries under "//..." + let Some(after_root) = path.split_once('/').map(|x| x.1) else { + continue; + }; + let Some(rel) = after_root.strip_prefix(&prefix) else { + continue; + }; + if rel.is_empty() || entry.header().entry_type().is_dir() { + continue; + } + + let mut content = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut content)?; + + // Cache to disk + let dest = cache_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, &content)?; + + files.insert(rel.to_string(), content); + } + + if files.is_empty() { + // Clean up empty cache dir + let _ = std::fs::remove_dir_all(&cache_dir); + anyhow::bail!("Template '{}' not found in registry", name); + } + + Ok(ResolvedTemplate { + name: name.into(), + files, + }) +} + +/// List template names available in local cache. +pub fn list_cached() -> Result> { + let dir = utils::templates_dir()?; + let mut names = Vec::new(); + if dir.is_dir() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + names.push(name.to_string()); + } + } + } + } + names.sort(); + Ok(names) +} + +/// Remove a template from local cache. +pub fn remove_cached(name: &str) -> Result { + let dir = utils::templates_dir()?.join(name); + if !dir.is_dir() { + anyhow::bail!("Template '{}' is not installed", name); + } + std::fs::remove_dir_all(&dir)?; + Ok(dir) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 899987e..1057cfe 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,10 +2,10 @@ use std::path::Path; -/// Get the TexForge data directory (~/.texforge) +/// Get the `TexForge` data directory (~/.texforge) pub fn data_dir() -> anyhow::Result { - let home = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + let home = + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; let data_dir = home.join(".texforge"); std::fs::create_dir_all(&data_dir)?; Ok(data_dir) @@ -21,7 +21,7 @@ pub fn templates_dir() -> anyhow::Result { /// Find all .tex files in a directory pub fn find_tex_files(root: &Path) -> anyhow::Result> { let mut files = Vec::new(); - + for entry in walkdir::WalkDir::new(root) .follow_links(true) .into_iter() @@ -35,6 +35,6 @@ pub fn find_tex_files(root: &Path) -> anyhow::Result> { } } } - + Ok(files) } From 5cbdb1955555a67be0d05c269c3cc7dee8bb5891 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:15:41 -0500 Subject: [PATCH 06/17] feat: implement texforge check with static lint rules --- src/commands/check.rs | 31 ++++-- src/linter/mod.rs | 213 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 225 insertions(+), 19 deletions(-) diff --git a/src/commands/check.rs b/src/commands/check.rs index e98e5d9..e6ebe45 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -3,21 +3,34 @@ use anyhow::Result; use crate::domain::project::Project; +use crate::linter; -/// Lint project without compiling +/// Lint project without compiling. pub fn execute() -> Result<()> { let project = Project::load()?; println!("Checking project: {}", project.config.documento.titulo); - println!("TODO: Implement linter"); - // TODO: - // 1. Check \cite{} references exist in .bib - // 2. Check \includegraphics files exist - // 3. Check \input files exist - // 4. Check \label{} / \ref{} consistency - // 5. Validate project.toml variables - // 6. Check diagram blocks are well-formed + let errors = linter::lint( + &project.root, + &project.config.compilacion.entry, + project.config.compilacion.bibliografia.as_deref(), + )?; + + if errors.is_empty() { + println!("✅ No issues found"); + } else { + println!(); + for e in &errors { + println!("ERROR [{}:{}]", e.file, e.line); + println!(" {}", e.message); + if let Some(ref s) = e.suggestion { + println!(" suggestion: {}", s); + } + println!(); + } + anyhow::bail!("{} issue(s) found", errors.len()); + } Ok(()) } diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 5100702..78a1e3b 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -1,17 +1,11 @@ //! Static linting rules. -/// Linter for LaTeX projects -pub struct Linter; +use std::collections::HashSet; +use std::path::Path; -impl Linter { - /// Run all linting rules on project - pub fn lint(_project_root: &std::path::Path) -> anyhow::Result> { - // TODO: Implement linting rules - Ok(Vec::new()) - } -} +use anyhow::Result; -/// A linting error with location and suggestion +/// A linting error with location and suggestion. #[derive(Debug)] pub struct LintError { pub file: String, @@ -19,3 +13,202 @@ pub struct LintError { pub message: String, pub suggestion: Option, } + +impl std::fmt::Display for LintError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, " {}:{} — {}", self.file, self.line, self.message)?; + if let Some(ref s) = self.suggestion { + write!(f, "\n suggestion: {}", s)?; + } + Ok(()) + } +} + +/// Run all lint rules on a project directory. +pub fn lint(root: &Path, entry: &str, bib_file: Option<&str>) -> Result> { + let mut errors = Vec::new(); + + let entry_path = root.join(entry); + if !entry_path.exists() { + errors.push(LintError { + file: entry.to_string(), + line: 0, + message: "Entry point file does not exist".into(), + suggestion: Some(format!("Create {}", entry)), + }); + return Ok(errors); + } + + // Collect all .tex files reachable from entry + let mut tex_files = Vec::new(); + collect_tex_files(root, entry, &mut tex_files); + + // Parse .bib keys if bibliography exists + let bib_keys = match bib_file { + Some(bib) => parse_bib_keys(&root.join(bib)), + None => HashSet::new(), + }; + + // Collect all labels defined across files + let mut all_labels = HashSet::new(); + for file in &tex_files { + let rel = file.strip_prefix(root).unwrap_or(file); + let content = std::fs::read_to_string(file)?; + for (i, line) in content.lines().enumerate() { + for label in extract_commands(line, "label") { + all_labels.insert(label.to_string()); + } + let _ = (i, rel); // used below + } + } + + // Run checks on each file + for file in &tex_files { + let rel = file + .strip_prefix(root) + .unwrap_or(file) + .to_string_lossy() + .to_string(); + let content = std::fs::read_to_string(file)?; + + for (i, line) in content.lines().enumerate() { + let line_num = i + 1; + + // Check \input{} files exist + for arg in extract_commands(line, "input") { + let input_path = resolve_tex_path(root, arg); + if !input_path.exists() { + errors.push(LintError { + file: rel.clone(), + line: line_num, + message: format!("\\input{{{}}} — file not found", arg), + suggestion: Some(format!("Create {}", input_path.display())), + }); + } + } + + // Check \includegraphics{} files exist + for arg in extract_commands(line, "includegraphics") { + let img_path = root.join(arg); + if !img_path.exists() { + errors.push(LintError { + file: rel.clone(), + line: line_num, + message: format!("\\includegraphics{{{}}} — file not found", arg), + suggestion: None, + }); + } + } + + // Check \cite{} keys exist in .bib + 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.clone(), + line: line_num, + message: format!("\\cite{{{}}} — key not found in .bib", key), + suggestion: None, + }); + } + } + } + } + + // Check \ref{} has matching \label{} + for arg in extract_commands(line, "ref") { + if !all_labels.contains(arg) { + errors.push(LintError { + file: rel.clone(), + line: line_num, + message: format!("\\ref{{{}}} — no matching \\label found", arg), + suggestion: None, + }); + } + } + } + } + + Ok(errors) +} + +/// Extract arguments from `\command{arg}` and `\command[opts]{arg}` occurrences in a line. +fn extract_commands<'a>(line: &'a str, cmd: &str) -> Vec<&'a str> { + let mut results = Vec::new(); + let pattern = format!("\\{}", cmd); + let mut search = line; + + while let Some(pos) = search.find(&pattern) { + let after = &search[pos + pattern.len()..]; + // Skip optional args [...] + let after = if after.starts_with('[') { + match after.find(']') { + Some(end) => &after[end + 1..], + None => break, + } + } else { + after + }; + if after.starts_with('{') { + if let Some(end) = after.find('}') { + results.push(&after[1..end]); + search = &after[end + 1..]; + continue; + } + } + search = after; + } + + results +} + +/// Resolve a tex input path, adding .tex extension if missing. +fn resolve_tex_path(root: &Path, input: &str) -> std::path::PathBuf { + let p = root.join(input); + if p.extension().is_some() { + p + } else { + p.with_extension("tex") + } +} + +/// Recursively collect .tex files referenced by \input{}. +fn collect_tex_files(root: &Path, entry: &str, files: &mut Vec) { + let path = resolve_tex_path(root, entry); + if !path.exists() || files.contains(&path) { + return; + } + files.push(path.clone()); + + if let Ok(content) = std::fs::read_to_string(&path) { + for line in content.lines() { + for input in extract_commands(line, "input") { + collect_tex_files(root, input, files); + } + } + } +} + +/// Parse @type{key, ...} entries from a .bib file. +fn parse_bib_keys(path: &Path) -> HashSet { + let mut keys = HashSet::new(); + let Ok(content) = std::fs::read_to_string(path) else { + return keys; + }; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('@') && !trimmed.starts_with("@comment") { + if let Some(start) = trimmed.find('{') { + if let Some(end) = trimmed[start..].find(',') { + let key = trimmed[start + 1..start + end].trim(); + if !key.is_empty() { + keys.insert(key.to_string()); + } + } + } + } + } + keys +} From a6acbcc34c8e5371f179f2ccd5702133b9559da7 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:15:52 -0500 Subject: [PATCH 07/17] feat: implement texforge fmt with opinionated LaTeX formatting --- src/commands/fmt.rs | 50 ++++++++++++++++++------ src/formatter/mod.rs | 91 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/src/commands/fmt.rs b/src/commands/fmt.rs index 9e91b58..13b83c7 100644 --- a/src/commands/fmt.rs +++ b/src/commands/fmt.rs @@ -2,22 +2,48 @@ use anyhow::Result; -/// Format .tex files +use crate::domain::project::Project; +use crate::formatter; +use crate::utils; + +/// Format .tex files. pub fn execute(check: bool) -> Result<()> { - if check { - println!("Checking formatting..."); - } else { - println!("Formatting .tex files..."); + let project = Project::load()?; + let files = utils::find_tex_files(&project.root)?; + + if files.is_empty() { + println!("No .tex files found"); + return Ok(()); } - println!("TODO: Implement formatter"); + let mut unformatted = 0; + + for file in &files { + let content = std::fs::read_to_string(file)?; + let formatted = formatter::format(&content); - // TODO: - // 1. Find all .tex files in project - // 2. Parse and normalize formatting - // 3. Apply consistent indentation - // 4. Align \begin{} / \end{} blocks - // 5. Write back (or check-only mode) + 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); + } + } + } + + if check && unformatted > 0 { + anyhow::bail!( + "{} file(s) need formatting — run 'texforge fmt'", + unformatted + ); + } else if !check { + println!("✅ {} file(s) checked", files.len()); + } else { + println!("✅ All files formatted correctly"); + } Ok(()) } diff --git a/src/formatter/mod.rs b/src/formatter/mod.rs index 77a137d..db2782d 100644 --- a/src/formatter/mod.rs +++ b/src/formatter/mod.rs @@ -1,15 +1,84 @@ //! LaTeX code formatter. +//! +//! Opinionated formatter inspired by `rustfmt` — one canonical output +//! regardless of input style. -/// Formatter for .tex files -pub struct Formatter; - -impl Formatter { - /// Format LaTeX source code - pub fn format(_source: &str) -> String { - // TODO: Implement opinionated formatting - // - Consistent indentation - // - Aligned \begin{} / \end{} blocks - // - Normalized spacing - String::new() +const INDENT: &str = " "; + +/// Format LaTeX source code with consistent style. +pub fn format(source: &str) -> String { + let mut output = Vec::new(); + let mut depth: usize = 0; + let mut prev_blank = false; + + for line in source.lines() { + let trimmed = line.trim(); + + // Collapse multiple blank lines into one + if trimmed.is_empty() { + if !prev_blank && !output.is_empty() { + output.push(String::new()); + } + prev_blank = true; + continue; + } + prev_blank = false; + + // Dedent for \end{...} + if trimmed.starts_with("\\end{") { + depth = depth.saturating_sub(1); + } + + let indented = if depth > 0 && !trimmed.starts_with('%') && !trimmed.starts_with('\\') { + // Content lines inside environments get indented + format!("{}{}", INDENT.repeat(depth), trimmed) + } else if depth > 0 + && trimmed.starts_with('\\') + && !trimmed.starts_with("\\begin{") + && !trimmed.starts_with("\\end{") + && !trimmed.starts_with("\\documentclass") + && !trimmed.starts_with("\\usepackage") + && !trimmed.starts_with("\\section") + && !trimmed.starts_with("\\subsection") + && !trimmed.starts_with("\\chapter") + && !trimmed.starts_with("\\title") + && !trimmed.starts_with("\\author") + && !trimmed.starts_with("\\date") + && !trimmed.starts_with("\\maketitle") + && !trimmed.starts_with("\\tableofcontents") + && !trimmed.starts_with("\\input") + && !trimmed.starts_with("\\bibliography") + && !trimmed.starts_with("\\bibliographystyle") + && !trimmed.starts_with("\\newcommand") + && !trimmed.starts_with("\\renewcommand") + && !trimmed.starts_with("\\pagestyle") + && !trimmed.starts_with("\\geometry") + && !trimmed.starts_with("\\hypersetup") + && !trimmed.starts_with("\\numberwithin") + && !trimmed.starts_with("\\titleformat") + && !trimmed.starts_with("\\titlespacing") + && !trimmed.starts_with("\\fancyhf") + && !trimmed.starts_with("\\cfoot") + { + format!("{}{}", INDENT.repeat(depth), trimmed) + } else { + trimmed.to_string() + }; + + output.push(indented); + + // Indent after \begin{...} + if trimmed.starts_with("\\begin{") { + depth += 1; + } } + + // Remove trailing blank lines + while output.last().is_some_and(|l| l.is_empty()) { + output.pop(); + } + + let mut result = output.join("\n"); + result.push('\n'); + result } From d7f67484bc35fa6184e552d8bd09bb487233c99b Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:16:08 -0500 Subject: [PATCH 08/17] feat: implement texforge build wrapping tectonic with clean error output --- src/commands/build.rs | 20 +++--- src/compiler/mod.rs | 139 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/commands/build.rs b/src/commands/build.rs index a845cf0..f8ac2ab 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -2,22 +2,22 @@ use anyhow::Result; +use crate::compiler; use crate::domain::project::Project; -/// Compile project to PDF +/// Compile project to PDF. pub fn execute() -> Result<()> { let project = Project::load()?; println!("Building project: {}", project.config.documento.titulo); - println!("Entry point: {}", project.entry_path().display()); - println!("TODO: Implement compilation"); - - // TODO: - // 1. Load template - // 2. Assemble document (preamble + body) - // 3. Render embedded diagrams (Mermaid, Graphviz) - // 4. Compile with internal engine - // 5. Report clean errors on failure + + // Ensure build directory exists + std::fs::create_dir_all(project.root.join("build"))?; + + compiler::compile(&project.root, &project.config.compilacion.entry)?; + + let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); + println!("✅ build/{}", pdf_name.display()); Ok(()) } diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index fcb9467..c384a0a 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -1,13 +1,134 @@ -//! LaTeX compilation engine. +//! LaTeX compilation engine — wraps Tectonic. -/// LaTeX compilation engine -pub struct Engine; +use std::path::Path; +use std::process::Command; -impl Engine { - /// Compile LaTeX source to PDF - pub fn compile(_source: &str) -> anyhow::Result> { - // TODO: Implement LaTeX compilation - // This is the core engine that will replace TeX Live - anyhow::bail!("Compilation not yet implemented") +use anyhow::{Context, Result}; + +/// Compile a LaTeX project to PDF using Tectonic. +pub fn compile(root: &Path, entry: &str) -> Result<()> { + let tectonic = find_tectonic()?; + let entry_path = root.join(entry); + + let output = Command::new(&tectonic) + .arg(&entry_path) + .arg("--outdir") + .arg(root.join("build")) + .arg("--keep-logs") + .current_dir(root) + .output() + .with_context(|| format!("Failed to run tectonic at {}", tectonic.display()))?; + + if output.status.success() { + return Ok(()); + } + + // Parse and clean up error output + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = format!("{}{}", stdout, stderr); + + let errors = parse_errors(&raw); + if errors.is_empty() { + anyhow::bail!("Compilation failed:\n{}", raw.trim()); } + + let mut msg = String::from("Compilation failed:\n\n"); + for e in &errors { + msg.push_str(&format!( + "ERROR [{}:{}]\n {}\n\n", + e.file, e.line, e.message + )); + } + anyhow::bail!("{}", msg.trim()); +} + +struct CompileError { + file: String, + line: usize, + message: String, +} + +/// Parse tectonic/TeX error output into structured errors. +fn parse_errors(raw: &str) -> Vec { + let mut errors = Vec::new(); + + for line in raw.lines() { + let trimmed = line.trim(); + + // Tectonic format: "error: :: " + if let Some(rest) = trimmed.strip_prefix("error:") { + let rest = rest.trim(); + if let Some((loc, msg)) = rest.split_once(": ") { + if let Some((file, line_str)) = loc.rsplit_once(':') { + if let Ok(line_num) = line_str.parse::() { + errors.push(CompileError { + file: file.trim().to_string(), + line: line_num, + message: msg.trim().to_string(), + }); + continue; + } + } + } + // Fallback: error without parseable location + errors.push(CompileError { + file: String::new(), + line: 0, + message: rest.to_string(), + }); + } + + // TeX format: "! " followed by "l." + if let Some(msg) = trimmed.strip_prefix("! ") { + errors.push(CompileError { + file: String::new(), + line: 0, + message: msg.to_string(), + }); + } + if trimmed.starts_with("l.") { + if let Some(num_str) = trimmed.strip_prefix("l.") { + let num_part: String = num_str.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num_part.parse::() { + if let Some(last) = errors.last_mut() { + last.line = n; + } + } + } + } + } + + errors +} + +/// Find the tectonic binary. +fn find_tectonic() -> Result { + // Check PATH + if let Ok(output) = Command::new("which").arg("tectonic").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(path.into()); + } + } + + // Common install locations + for candidate in [ + dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")), + Some("/usr/local/bin/tectonic".into()), + Some("/opt/homebrew/bin/tectonic".into()), + ] + .into_iter() + .flatten() + { + if candidate.exists() { + return Ok(candidate); + } + } + + anyhow::bail!( + "Tectonic not found. Install it with:\n\ + \n cargo install tectonic\n\ + \nor visit https://tectonic-typesetting.github.io/" + ); } From 67952606def1e77bb87af70acb689840b61ca262 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:45:42 -0500 Subject: [PATCH 09/17] refactor: remove unused stubs, drop thiserror, upgrade reqwest to 0.13 --- Cargo.toml | 3 +-- src/domain/document.rs | 15 --------------- src/domain/mod.rs | 2 -- src/domain/project.rs | 26 +++++++------------------- src/domain/template.rs | 42 ------------------------------------------ src/error/mod.rs | 18 ------------------ src/main.rs | 1 - src/templates/mod.rs | 20 +++++--------------- 8 files changed, 13 insertions(+), 114 deletions(-) delete mode 100644 src/domain/document.rs delete mode 100644 src/domain/template.rs delete mode 100644 src/error/mod.rs diff --git a/Cargo.toml b/Cargo.toml index c9af913..8b1e61a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ clap = { version = "4.6", features = ["derive"] } # Error handling anyhow = "1.0" -thiserror = "2.0" # Serialization (for project.toml, template.toml) serde = { version = "1.0", features = ["derive"] } @@ -32,7 +31,7 @@ walkdir = "2.5" dirs = "6.0" # HTTP client (template downloads) -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.13", features = ["blocking"] } # Archive extraction (GitHub tarballs) flate2 = "1.1" diff --git a/src/domain/document.rs b/src/domain/document.rs deleted file mode 100644 index 0bd374f..0000000 --- a/src/domain/document.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Document assembly logic. - -/// Assembles the final LaTeX document from template and body -pub struct DocumentAssembler; - -impl DocumentAssembler { - /// Assemble document from template preamble and body content - pub fn assemble(_preambulo: &str, _body: &str) -> String { - // TODO: Implement document assembly - // - Inject variables into preamble - // - Combine preamble + portada + body - // - Handle \input directives - String::new() - } -} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index d04dc64..0e2e63f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -3,6 +3,4 @@ //! This layer contains the core types and logic for projects, templates, //! and document assembly. No dependencies on infrastructure or frameworks. -pub mod document; pub mod project; -pub mod template; diff --git a/src/domain/project.rs b/src/domain/project.rs index 45274a4..d5efabd 100644 --- a/src/domain/project.rs +++ b/src/domain/project.rs @@ -1,9 +1,11 @@ //! Project configuration and metadata. -use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Project configuration from project.toml +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Project configuration from project.toml. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectConfig { pub documento: DocumentoConfig, @@ -24,7 +26,7 @@ pub struct CompilacionConfig { pub bibliografia: Option, } -/// Represents a `TexForge` project +/// Represents a `TexForge` project. #[derive(Debug)] pub struct Project { pub root: PathBuf, @@ -32,8 +34,8 @@ pub struct Project { } impl Project { - /// Load project from current directory - pub fn load() -> anyhow::Result { + /// Load project from current directory. + pub fn load() -> Result { let root = std::env::current_dir()?; let config_path = root.join("project.toml"); @@ -46,18 +48,4 @@ impl Project { Ok(Self { root, config }) } - - /// Get the entry point file path - pub fn entry_path(&self) -> PathBuf { - self.root.join(&self.config.compilacion.entry) - } - - /// Get the bibliography file path if configured - pub fn bib_path(&self) -> Option { - self.config - .compilacion - .bibliografia - .as_ref() - .map(|bib| self.root.join(bib)) - } } diff --git a/src/domain/template.rs b/src/domain/template.rs deleted file mode 100644 index e43ceea..0000000 --- a/src/domain/template.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Template metadata and management. - -use serde::{Deserialize, Serialize}; - -/// Template metadata from template.toml -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TemplateMetadata { - pub metadata: MetadataSection, - pub variables: VariablesSection, - pub compatibilidad: CompatibilidadSection, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MetadataSection { - pub nombre: String, - pub descripcion: String, - pub idioma: String, - pub tipo: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VariablesSection { - pub requeridas: Vec, - #[serde(default)] - pub opcionales: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompatibilidadSection { - pub mermaid: bool, - pub graphviz: bool, - pub bibliografia: String, -} - -/// Represents a LaTeX template -#[derive(Debug)] -pub struct Template { - pub name: String, - pub metadata: TemplateMetadata, - pub preambulo: String, - pub portada: Option, -} diff --git a/src/error/mod.rs b/src/error/mod.rs deleted file mode 100644 index 425bcec..0000000 --- a/src/error/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Custom error types. - -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum TexForgeError { - #[error("Project not found: {0}")] - ProjectNotFound(String), - - #[error("Template not found: {0}")] - TemplateNotFound(String), - - #[error("Compilation failed: {0}")] - CompilationFailed(String), - - #[error("Invalid configuration: {0}")] - InvalidConfig(String), -} diff --git a/src/main.rs b/src/main.rs index ecfe688..a78c83c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,6 @@ mod cli; mod commands; mod compiler; mod domain; -mod error; mod formatter; mod linter; mod templates; diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 29bf248..409fbc1 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -17,7 +17,6 @@ const GENERAL_REFERENCES_BIB: &str = include_str!("general/bib/references.bib"); /// A resolved template ready to scaffold a project. pub struct ResolvedTemplate { - pub name: String, /// Map of relative path -> file contents. pub files: HashMap>, } @@ -61,10 +60,7 @@ fn embedded_general() -> ResolvedTemplate { "bib/references.bib".into(), GENERAL_REFERENCES_BIB.as_bytes().to_vec(), ); - ResolvedTemplate { - name: "general".into(), - files, - } + ResolvedTemplate { files } } fn load_from_cache(name: &str) -> Result { @@ -72,10 +68,10 @@ fn load_from_cache(name: &str) -> Result { if !dir.is_dir() { anyhow::bail!("not cached"); } - load_dir_recursive(&dir, name) + load_dir_recursive(&dir) } -fn load_dir_recursive(base: &Path, name: &str) -> Result { +fn load_dir_recursive(base: &Path) -> Result { let mut files = HashMap::new(); for entry in walkdir::WalkDir::new(base) .into_iter() @@ -91,10 +87,7 @@ fn load_dir_recursive(base: &Path, name: &str) -> Result { files.insert(rel, content); } } - Ok(ResolvedTemplate { - name: name.into(), - files, - }) + Ok(ResolvedTemplate { files }) } /// Download a template tarball from GitHub and cache it locally. @@ -157,10 +150,7 @@ pub fn download(name: &str) -> Result { anyhow::bail!("Template '{}' not found in registry", name); } - Ok(ResolvedTemplate { - name: name.into(), - files, - }) + Ok(ResolvedTemplate { files }) } /// List template names available in local cache. From 49a96bef5e7d6d95b2c2d7f41f07baa6bbe11859 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:47:59 -0500 Subject: [PATCH 10/17] fix: validate project name to prevent path traversal and empty names --- src/commands/new.rs | 43 +++++++++++- src/linter/mod.rs | 166 +++++++++++++++++++++++++++++++------------- 2 files changed, 159 insertions(+), 50 deletions(-) diff --git a/src/commands/new.rs b/src/commands/new.rs index 1960d87..41fc156 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,6 +1,6 @@ //! `texforge new` command implementation. -use std::path::Path; +use std::path::{Component, Path}; use anyhow::{Context, Result}; @@ -8,6 +8,8 @@ use crate::templates; /// Create a new project from a template. pub fn execute(name: &str, template: Option<&str>) -> Result<()> { + validate_project_name(name)?; + let template_name = template.unwrap_or("general"); let project_dir = Path::new(name); @@ -60,3 +62,42 @@ bibliografia = "bib/references.bib" Ok(()) } + +/// Validate project name: no empty, no path traversal, no special chars. +fn validate_project_name(name: &str) -> Result<()> { + if name.is_empty() { + anyhow::bail!("Project name cannot be empty"); + } + + // Reject path traversal + let path = Path::new(name); + for component in path.components() { + match component { + Component::ParentDir => { + anyhow::bail!("Project name cannot contain '..' (path traversal)"); + } + Component::RootDir | Component::Prefix(_) => { + anyhow::bail!("Project name cannot be an absolute path"); + } + _ => {} + } + } + + // Reject names with slashes (implicit subdirectories) + if name.contains('/') || name.contains('\\') { + anyhow::bail!("Project name cannot contain path separators"); + } + + // Reject problematic characters + let invalid_chars = ['@', '#', '$', '!', '&', '|', ';', '`', '"', '\'', '*', '?']; + if let Some(c) = name.chars().find(|c| invalid_chars.contains(c)) { + anyhow::bail!("Project name contains invalid character: '{}'", c); + } + + // Reject names that are only whitespace + if name.trim().is_empty() { + anyhow::bail!("Project name cannot be only whitespace"); + } + + Ok(()) +} diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 78a1e3b..501c10c 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -52,13 +52,11 @@ pub fn lint(root: &Path, entry: &str, bib_file: Option<&str>) -> Result) -> Result, + bib_keys: &HashSet, + all_labels: &HashSet, + errors: &mut Vec, +) { + for (i, line) in content.lines().enumerate() { + let line_num = i + 1; + + 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 \includegraphics{} files exist - for arg in extract_commands(line, "includegraphics") { - let img_path = root.join(arg); - if !img_path.exists() { - errors.push(LintError { - file: rel.clone(), - line: line_num, - message: format!("\\includegraphics{{{}}} — file not found", arg), - suggestion: None, - }); - } + 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 \cite{} keys exist in .bib - 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.clone(), - line: line_num, - message: format!("\\cite{{{}}} — key not found in .bib", key), - suggestion: None, - }); - } + 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 \ref{} has matching \label{} - for arg in extract_commands(line, "ref") { - if !all_labels.contains(arg) { + 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, + }); + } + } + } +} + +/// Check for unclosed \begin{env} environments. +fn check_environments(rel: &str, content: &str, errors: &mut Vec) { + // Stack of (env_name, line_number) + let mut stack: Vec<(&str, usize)> = Vec::new(); + + for (i, line) in content.lines().enumerate() { + let line_num = i + 1; + let trimmed = line.trim(); + + // Skip comments + if trimmed.starts_with('%') { + continue; + } + + for env in extract_commands(trimmed, "begin") { + stack.push((env, line_num)); + } + + for env in extract_commands(trimmed, "end") { + if let Some((open_env, _)) = stack.last() { + if *open_env == env { + stack.pop(); + } else { errors.push(LintError { - file: rel.clone(), + file: rel.to_string(), line: line_num, - message: format!("\\ref{{{}}} — no matching \\label found", arg), - suggestion: None, + message: format!("\\end{{{}}} does not match \\begin{{{}}}", env, open_env), + suggestion: Some(format!("Expected \\end{{{}}}", open_env)), }); } + } else { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\end{{{}}} without matching \\begin", env), + suggestion: None, + }); } } } - Ok(errors) + // Report unclosed environments + for (env, line_num) in stack { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\begin{{{}}} never closed", env), + suggestion: Some(format!("Add \\end{{{}}}", env)), + }); + } } /// Extract arguments from `\command{arg}` and `\command[opts]{arg}` occurrences in a line. @@ -174,7 +242,7 @@ fn resolve_tex_path(root: &Path, input: &str) -> std::path::PathBuf { } } -/// Recursively collect .tex files referenced by \input{}. +/// Recursively collect .tex files referenced by `\input{}`. fn collect_tex_files(root: &Path, entry: &str, files: &mut Vec) { let path = resolve_tex_path(root, entry); if !path.exists() || files.contains(&path) { @@ -191,7 +259,7 @@ fn collect_tex_files(root: &Path, entry: &str, files: &mut Vec HashSet { let mut keys = HashSet::new(); let Ok(content) = std::fs::read_to_string(path) else { From 86cf83f86342f34e2bf1000d47eeb0daa7c89691 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 30 Mar 2026 18:58:43 -0500 Subject: [PATCH 11/17] feat: bundle tectonic install in install.sh for zero-friction setup --- README.md | 49 +++++++++++++++-------- install.sh | 97 +++++++++++++++++++++++++++++++-------------- src/compiler/mod.rs | 27 +++++-------- 3 files changed, 112 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 64b36da..f689624 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,17 @@ # TexForge -> Self-contained LaTeX to PDF compiler — zero external dependencies +> Self-contained LaTeX to PDF compiler — one curl, zero friction -TexForge is a command-line tool that compiles LaTeX documents to PDF without requiring TeX Live, MiKTeX, or any external LaTeX distribution. - -## Features - -- **Self-contained**: Single binary, no external dependencies -- **Clean errors**: Actionable error messages with line numbers and context -- **Template system**: Managed preambles, you only write document content -- **Built-in tools**: Formatter, linter, and build system in one CLI +TexForge is a command-line tool that compiles LaTeX documents to PDF without requiring TeX Live, MiKTeX, or any external LaTeX distribution. A single install script sets up everything you need. ## Installation ```bash -cargo install texforge +curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh ``` +This installs both `texforge` and `tectonic` (the LaTeX engine). Nothing else needed. + ## Quick Start ```bash @@ -33,26 +28,48 @@ texforge fmt texforge build ``` +## Features + +- **One-command install**: Single curl sets up the full LaTeX toolchain +- **Clean errors**: Actionable error messages with line numbers and context +- **Template system**: Managed preambles, you only write document content +- **Built-in tools**: Formatter, linter, and build system in one CLI + ## Project Structure ``` mi-tesis/ ├── project.toml # Project configuration ├── main.tex # Entry point -├── capitulos/ # Chapters -│ ├── 01-intro.tex -│ └── 02-metodologia.tex -├── refs.bib # Bibliography -└── assets/ # Images and resources +├── sections/ # Document sections +│ └── body.tex +├── bib/ +│ └── references.bib # Bibliography +└── assets/ + └── images/ # Images and resources ``` ## Commands - `texforge new ` — Create new project from template +- `texforge new -t