diff --git a/acdc-cli/src/subcommands/convert.rs b/acdc-cli/src/subcommands/convert.rs index 553e93c..b176bdd 100644 --- a/acdc-cli/src/subcommands/convert.rs +++ b/acdc-cli/src/subcommands/convert.rs @@ -1,24 +1,14 @@ use std::path::PathBuf; -use acdc_converters_core::{Doctype, GeneratorMetadata, Options, Processable}; +use acdc_converters_core::{ + Backend, Converter, Doctype, GeneratorMetadata, Options, OutputDestination, +}; use acdc_parser::{AttributeValue, DocumentAttributes, SafeMode}; -use clap::{ArgAction, Args as ClapArgs, ValueEnum}; +use clap::{ArgAction, Args as ClapArgs}; use rayon::prelude::*; use crate::error; -#[derive(Debug, ValueEnum, Clone)] -pub enum Backend { - #[cfg(feature = "html")] - Html, - - #[cfg(feature = "terminal")] - Terminal, - - #[cfg(feature = "manpage")] - Manpage, -} - /// Convert `AsciiDoc` documents to various output formats #[derive(ClapArgs, Debug)] #[allow(clippy::struct_excessive_bools)] // CLI flags are naturally booleans @@ -27,12 +17,20 @@ pub struct Args { #[arg(long, conflicts_with = "files")] pub stdin: bool, + /// Output file (default: based on path of input file); use - to output to STDOUT + /// + /// When specified, output is written to this file instead of deriving + /// the output path from the input file. If multiple input files are + /// provided with this option, only the first file is processed. + #[arg(short = 'o', long = "out-file", value_name = "FILE")] + pub out_file: Option, + /// List of files to convert #[arg(conflicts_with = "stdin")] pub files: Vec, /// Backend output format - #[arg(long, value_enum, default_value_t = Backend::Html)] + #[arg(long, value_parser = clap::value_parser!(Backend), default_value_t = Backend::Html)] pub backend: Backend, /// Document type to use when converting document @@ -127,6 +125,18 @@ pub fn run(args: &Args) -> miette::Result<()> { } }; + // Parse output destination from --out-file argument + let output_destination = args + .out_file + .as_ref() + .map_or(OutputDestination::Derived, |s| { + if s == "-" { + OutputDestination::Stdout + } else { + OutputDestination::File(PathBuf::from(s)) + } + }); + let options = Options::builder() .generator_metadata(GeneratorMetadata::new( env!("CARGO_BIN_NAME"), @@ -136,6 +146,7 @@ pub fn run(args: &Args) -> miette::Result<()> { .safe_mode(safe_mode) .timings(args.timings) .embedded(args.embedded) + .output_destination(output_destination) .build(); match args.backend { @@ -180,8 +191,8 @@ fn run_processor

( parallelize: bool, ) -> Result<(), P::Error> where - P: Processable, - P::Error: Send + std::error::Error + 'static + From, + P: Converter, + P::Error: Send + 'static + From, { if !args.stdin && args.files.is_empty() { tracing::error!("You must pass at least one file to this processor"); @@ -192,36 +203,48 @@ where if args.stdin { let processor = P::new(base_options.clone(), document_attributes.clone()); let parser_options = - build_parser_options(args, &base_options, processor.document_attributes()); + build_parser_options(args, &base_options, processor.document_attributes().clone()); let stdin = std::io::stdin(); let mut reader = std::io::BufReader::new(stdin.lock()); let doc = acdc_parser::parse_from_reader(&mut reader, &parser_options)?; return processor.convert(&doc, None); } - // PHASE 1: Parse all files in parallel (always - parsing is the expensive part) - let parse_results: Vec<(PathBuf, Result)> = args - .files - .par_iter() - .map(|file| { - let parser_options = - build_parser_options(args, &base_options, document_attributes.clone()); + // When --out-file is specified with multiple files, only process the first file + // (matches asciidoctor behavior) + let files_to_process: &[PathBuf] = match (args.out_file.as_ref(), args.files.as_slice()) { + (Some(_), [first, _, ..]) => { + eprintln!( + "Warning: --out-file specified with multiple input files; only processing first file" + ); + std::slice::from_ref(first) + } + _ => &args.files, + }; - if base_options.timings() { - let now = std::time::Instant::now(); - let result = acdc_parser::parse_file(file, &parser_options); - let elapsed = now.elapsed(); - if result.is_ok() { - use acdc_converters_core::PrettyDuration; - eprintln!(" Parsed {} in {}", file.display(), elapsed.pretty_print()); + // PHASE 1: Parse all files in parallel (always - parsing is the expensive part) + let parse_results: Vec<(PathBuf, Result)> = + files_to_process + .par_iter() + .map(|file| { + let parser_options = + build_parser_options(args, &base_options, document_attributes.clone()); + + if base_options.timings() { + let now = std::time::Instant::now(); + let result = acdc_parser::parse_file(file, &parser_options); + let elapsed = now.elapsed(); + if result.is_ok() { + use acdc_converters_core::PrettyDuration; + eprintln!(" Parsed {} in {}", file.display(), elapsed.pretty_print()); + } + (file.clone(), result) + } else { + let result = acdc_parser::parse_file(file, &parser_options); + (file.clone(), result) } - (file.clone(), result) - } else { - let result = acdc_parser::parse_file(file, &parser_options); - (file.clone(), result) - } - }) - .collect(); + }) + .collect(); // PHASE 2: Convert documents - either in parallel or sequentially let results: Vec<(PathBuf, Result<(), P::Error>)> = if parallelize { @@ -354,20 +377,29 @@ fn run_terminal_with_pager( std::process::exit(1); } + // Check if --out-file specifies a file (not stdout) + // If so, write directly to file without pager + let output_to_file = args.out_file.as_ref().is_some_and(|s| s != "-"); + // Handle stdin separately if args.stdin { let processor = Processor::new(base_options.clone(), document_attributes.clone()); let parser_options = - build_parser_options(args, &base_options, processor.document_attributes()); + build_parser_options(args, &base_options, processor.document_attributes().clone()); let stdin = std::io::stdin(); let mut reader = std::io::BufReader::new(stdin.lock()); let doc = acdc_parser::parse_from_reader(&mut reader, &parser_options)?; + // If writing to file, use the processor's convert method (respects output_path) + if output_to_file { + return processor.convert(&doc, None); + } + // Try pager for stdin too if let Some(mut pager) = spawn_pager(args.no_pager) { if let Some(pager_stdin) = pager.stdin.take() { let writer = BufWriter::new(pager_stdin); - processor.convert_to_writer(&doc, writer)?; + processor.write_to(&doc, writer, None)?; } let _ = pager.wait()?; return Ok(()); @@ -375,12 +407,22 @@ fn run_terminal_with_pager( return processor.convert(&doc, None); } + // When --out-file is specified with multiple files, only process the first file + let files_to_process: &[PathBuf] = match (args.out_file.as_ref(), args.files.as_slice()) { + (Some(_), [first, _, ..]) => { + eprintln!( + "Warning: --out-file specified with multiple input files; only processing first file" + ); + std::slice::from_ref(first) + } + _ => &args.files, + }; + // Parse all files in parallel let parse_results: Vec<( std::path::PathBuf, Result, - )> = args - .files + )> = files_to_process .par_iter() .map(|file| { let parser_options = @@ -404,13 +446,24 @@ fn run_terminal_with_pager( let processor = Processor::new(base_options, document_attributes); + // If writing to file, use the processor's convert method (respects output_path) + if output_to_file { + for (file, parse_result) in parse_results { + match parse_result { + Ok(doc) => processor.convert(&doc, Some(&file))?, + Err(e) => return Err(e.into()), + } + } + return Ok(()); + } + // Try to spawn pager if let Some(mut pager) = spawn_pager(args.no_pager) { if let Some(pager_stdin) = pager.stdin.take() { let mut writer = BufWriter::new(pager_stdin); for (_file, parse_result) in parse_results { match parse_result { - Ok(doc) => processor.convert_to_writer(&doc, &mut writer)?, + Ok(doc) => processor.write_to(&doc, &mut writer, None)?, Err(e) => return Err(e.into()), } } diff --git a/converters/core/src/backend.rs b/converters/core/src/backend.rs new file mode 100644 index 0000000..3a61be5 --- /dev/null +++ b/converters/core/src/backend.rs @@ -0,0 +1,71 @@ +//! Output format backend types. +//! +//! Defines the available converter backends (HTML, manpage, terminal). + +use std::str::FromStr; + +/// Output format backend type. +/// +/// Used by converters to identify themselves and by the CLI for backend selection. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Backend { + /// HTML output format. + #[default] + Html, + /// Unix manpage (roff/troff) output format. + Manpage, + /// Terminal/console output with ANSI formatting. + Terminal, +} + +impl FromStr for Backend { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "html" => Ok(Self::Html), + "manpage" => Ok(Self::Manpage), + "terminal" => Ok(Self::Terminal), + _ => Err(format!( + "invalid backend: '{s}', expected: html, manpage, terminal" + )), + } + } +} + +impl std::fmt::Display for Backend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Html => write!(f, "html"), + Self::Manpage => write!(f, "manpage"), + Self::Terminal => write!(f, "terminal"), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_from_str() { + assert_eq!(Backend::from_str("html").unwrap(), Backend::Html); + assert_eq!(Backend::from_str("HTML").unwrap(), Backend::Html); + assert_eq!(Backend::from_str("manpage").unwrap(), Backend::Manpage); + assert_eq!(Backend::from_str("terminal").unwrap(), Backend::Terminal); + assert!(Backend::from_str("invalid").is_err()); + } + + #[test] + fn test_display() { + assert_eq!(Backend::Html.to_string(), "html"); + assert_eq!(Backend::Manpage.to_string(), "manpage"); + assert_eq!(Backend::Terminal.to_string(), "terminal"); + } + + #[test] + fn test_default() { + assert_eq!(Backend::default(), Backend::Html); + } +} diff --git a/converters/core/src/lib.rs b/converters/core/src/lib.rs index c159909..784ef32 100644 --- a/converters/core/src/lib.rs +++ b/converters/core/src/lib.rs @@ -3,7 +3,7 @@ //! This crate provides the shared infrastructure used by all acdc converters //! (HTML, terminal, manpage, etc.): //! -//! - [`Processable`] - trait that all converters implement +//! - [`Converter`] - trait that all converters implement //! - [`Visitor`](visitor::Visitor) - visitor pattern for AST traversal //! - [`Options`] - configuration for conversion //! - [`default_rendering_attributes`] - default document attributes for rendering @@ -11,7 +11,7 @@ //! # Example //! //! ```ignore -//! use acdc_converters_core::{Options, Processable, Doctype}; +//! use acdc_converters_core::{Options, Converter, Doctype}; //! //! let options = Options::builder() //! .doctype(Doctype::Article) @@ -29,8 +29,11 @@ //! - [`video`] - Video URL generation for `YouTube`, `Vimeo`, etc. //! - [`visitor`] - Visitor pattern infrastructure for AST traversal +use std::path::PathBuf; + use acdc_parser::{AttributeValue, DocumentAttributes, SafeMode}; +mod backend; /// Source code syntax highlighting and callouts support. pub mod code; mod doctype; @@ -41,6 +44,7 @@ pub mod toc; pub mod video; pub mod visitor; +pub use backend::Backend; pub use doctype::Doctype; /// Create default document attributes for rendering. @@ -120,6 +124,25 @@ pub fn default_rendering_attributes() -> DocumentAttributes { attrs } +/// Output destination for conversion. +/// +/// This enum explicitly represents the three possible output modes: +/// - `Derived`: No explicit output specified, derive from input filename +/// - `Stdout`: Explicitly output to stdout (via `-o -`) +/// - `File`: Output to a specific file (via `-o path`) +#[derive(Debug, Clone, Default)] +pub enum OutputDestination { + /// Derive output path from input file (default behavior). + /// For HTML: `input.adoc` → `input.html` + /// For manpage: `cmd.adoc` → `cmd.1` + #[default] + Derived, + /// Write to stdout (equivalent to `-o -`). + Stdout, + /// Write to a specific file. + File(PathBuf), +} + /// Converter options. /// /// Use [`Options::builder()`] to construct an instance. This struct is marked @@ -144,6 +167,8 @@ pub struct Options { safe_mode: SafeMode, timings: bool, embedded: bool, + /// Output destination for conversion. + output_destination: OutputDestination, } impl Options { @@ -185,6 +210,14 @@ impl Options { pub fn embedded(&self) -> bool { self.embedded } + + /// Get the output destination. + /// + /// See [`OutputDestination`] for the possible values. + #[must_use] + pub fn output_destination(&self) -> &OutputDestination { + &self.output_destination + } } /// Builder for [`Options`]. @@ -197,6 +230,7 @@ pub struct OptionsBuilder { safe_mode: SafeMode, timings: bool, embedded: bool, + output_destination: OutputDestination, } impl OptionsBuilder { @@ -237,6 +271,16 @@ impl OptionsBuilder { self } + /// Set the output destination. + /// + /// See [`OutputDestination`] for the possible values. + /// If this is not called, converters will derive output path from input file. + #[must_use] + pub fn output_destination(mut self, destination: OutputDestination) -> Self { + self.output_destination = destination; + self + } + /// Build the [`Options`] instance. #[must_use] pub fn build(self) -> Options { @@ -246,6 +290,7 @@ impl OptionsBuilder { safe_mode: self.safe_mode, timings: self.timings, embedded: self.embedded, + output_destination: self.output_destination, } } } @@ -350,16 +395,28 @@ impl std::fmt::Display for GeneratorMetadata { /// Document attributes follow a layered precedence system (lowest to highest priority): /// /// 1. **Base rendering defaults** - from [`default_rendering_attributes()`] (admonition captions, toclevels, etc.) -/// 2. **Converter-specific defaults** - from [`Processable::document_attributes_defaults()`] (e.g., `man-linkstyle` for manpage) +/// 2. **Converter-specific defaults** - from [`Converter::document_attributes_defaults()`] (e.g., `man-linkstyle` for manpage) /// 3. **CLI attributes** - user-provided via `-a name=value` /// 4. **Document attributes** - `:name: value` in document header /// -/// Converters should use `document_attributes_defaults()` to provide backend-specific attribute defaults. -pub trait Processable { - /// The options type for this converter. - type Options; +/// ## Implementation +/// +/// Converters must implement these required methods: +/// - [`write_to`](Converter::write_to) - Core conversion logic +/// - [`derive_output_path`](Converter::derive_output_path) - Output path derivation +/// - [`backend`](Converter::backend) - Backend type for logging/messages +/// - [`options`](Converter::options) - Access to converter options +/// - [`document_attributes`](Converter::document_attributes) - Access to document attributes +/// +/// Converters get these methods for free: +/// - [`convert`](Converter::convert) - Main entry point with routing +/// - [`convert_to_stdout`](Converter::convert_to_stdout) - Output to stdout +/// - [`convert_to_file`](Converter::convert_to_file) - Output to file with timing +pub trait Converter: Sized { /// The error type for this converter. - type Error; + /// + /// Must implement `From` for the provided methods to work. + type Error: std::error::Error + From; /// Returns converter-specific default attributes. /// @@ -378,31 +435,161 @@ pub trait Processable { } /// Create a new converter instance. - fn new(options: Self::Options, document_attributes: DocumentAttributes) -> Self; + fn new(options: Options, document_attributes: DocumentAttributes) -> Self; + + /// Get a reference to the converter options. + fn options(&self) -> &Options; - /// Convert a pre-parsed document. + /// Get a reference to the document attributes. + #[must_use] + fn document_attributes(&self) -> &DocumentAttributes; + + /// Derive output path from input path (e.g., "doc.adoc" → "doc.html"). + /// + /// Returns `Ok(None)` if this converter doesn't support derived output paths + /// (e.g., terminal converter always outputs to stdout by default). + /// + /// Returns `Err` if derivation fails (e.g., output path would overwrite input). + /// + /// # Arguments + /// + /// * `input` - The input file path + /// * `doc` - The parsed document (for attributes like `manvolnum`) + /// + /// # Errors + /// + /// Returns an error if the derived output path is invalid (e.g., same as input). + fn derive_output_path( + &self, + input: &std::path::Path, + doc: &acdc_parser::Document, + ) -> Result, Self::Error>; + + /// Core conversion: write the document to any writer. /// - /// The CLI handles all parsing (stdin or files), and converters just focus on conversion. + /// This is the only method converters MUST implement with real conversion logic. + /// All other output methods delegate to this. /// /// # Arguments /// - /// * `doc` - The pre-parsed document - /// * `file` - Optional source file path (used for output path, metadata, etc.) - /// - `Some(path)` for file-based conversion - /// - `None` for stdin-based conversion + /// * `doc` - The parsed document + /// * `writer` - Any type implementing `Write` + /// * `source_file` - Optional source file path (for metadata, last modified, etc.) /// /// # Errors /// /// Returns an error if conversion or writing fails. - fn convert( + fn write_to( &self, doc: &acdc_parser::Document, - file: Option<&std::path::Path>, + writer: W, + source_file: Option<&std::path::Path>, ) -> Result<(), Self::Error>; - /// Get a reference to the document attributes. + /// Post-processing after successful file write. + /// + /// Override for converter-specific cleanup (e.g., CSS copying for HTML). + /// Default implementation does nothing. + fn after_write(&self, _doc: &acdc_parser::Document, _output_path: &std::path::Path) {} + + /// Returns the backend type for this converter. + /// + /// Used to identify the converter type for logging and success messages. #[must_use] - fn document_attributes(&self) -> DocumentAttributes; + fn backend(&self) -> Backend; + + /// Convert to stdout. + /// + /// # Errors + /// + /// Returns an error if conversion or writing fails. + fn convert_to_stdout( + &self, + doc: &acdc_parser::Document, + source_file: Option<&std::path::Path>, + ) -> Result<(), Self::Error> { + let stdout = std::io::stdout(); + self.write_to(doc, std::io::BufWriter::new(stdout.lock()), source_file) + } + + /// Convert to a specific file path. + /// + /// Handles timing output and success messages automatically. + /// + /// # Errors + /// + /// Returns an error if file creation, conversion, or writing fails. + fn convert_to_file( + &self, + doc: &acdc_parser::Document, + source_file: Option<&std::path::Path>, + output_path: &std::path::Path, + ) -> Result<(), Self::Error> { + let start = self.options().timings().then(std::time::Instant::now); + + if let Some(f) = source_file.filter(|_| self.options().timings()) { + println!("Input file: {}", f.display()); + } + + tracing::debug!( + source = ?source_file, + destination = ?output_path, + "converting document to {}", + self.backend() + ); + + let file = std::fs::File::create(output_path)?; + self.write_to(doc, std::io::BufWriter::new(file), source_file)?; + + if let Some(start) = start { + let elapsed = start.elapsed(); + tracing::debug!( + time = elapsed.pretty_print_precise(3), + source = ?source_file, + destination = ?output_path, + "time to convert document" + ); + println!(" Time to convert document: {}", elapsed.pretty_print()); + } + + println!( + "Generated {} file: {}", + self.backend(), + output_path.display() + ); + + self.after_write(doc, output_path); + Ok(()) + } + + /// Main entry point: route based on [`OutputDestination`]. + /// + /// This method handles all output routing logic: + /// - `Stdout`: Write to stdout + /// - `File(path)`: Write to specific file + /// - `Derived`: Derive path from input or fall back to stdout + /// + /// # Errors + /// + /// Returns an error if conversion or writing fails. + fn convert( + &self, + doc: &acdc_parser::Document, + source_file: Option<&std::path::Path>, + ) -> Result<(), Self::Error> { + match self.options().output_destination() { + OutputDestination::Stdout => self.convert_to_stdout(doc, source_file), + OutputDestination::File(path) => self.convert_to_file(doc, source_file, path), + OutputDestination::Derived => { + if let Some(input) = source_file + && let Some(output) = self.derive_output_path(input, doc)? + { + return self.convert_to_file(doc, source_file, &output); + } + self.convert_to_stdout(doc, source_file) + } + } + } } /// Walk the error source chain to find a parser error. diff --git a/converters/html/examples/generate_expected_fixtures.rs b/converters/html/examples/generate_expected_fixtures.rs index e8473d9..b6a4008 100644 --- a/converters/html/examples/generate_expected_fixtures.rs +++ b/converters/html/examples/generate_expected_fixtures.rs @@ -3,7 +3,7 @@ //! Usage: //! `cargo run -p acdc-converters-html --example generate_expected_fixtures` -use acdc_converters_core::{GeneratorMetadata, Options, Processable}; +use acdc_converters_core::{Converter, GeneratorMetadata, Options}; use acdc_converters_dev::generate_fixtures::FixtureGenerator; use acdc_converters_html::{Processor, RenderOptions}; diff --git a/converters/html/src/lib.rs b/converters/html/src/lib.rs index 257896f..8c76e9b 100644 --- a/converters/html/src/lib.rs +++ b/converters/html/src/lib.rs @@ -1,12 +1,11 @@ use std::{ cell::{Cell, RefCell}, - fs::File, - io::{self, BufWriter, Write}, + io::Write, + path::{Path, PathBuf}, rc::Rc, - time::Instant, }; -use acdc_converters_core::{Options, PrettyDuration, Processable, visitor::Visitor}; +use acdc_converters_core::{Backend, Converter, Options, visitor::Visitor}; use acdc_parser::{AttributeValue, Block, Document, DocumentAttributes, IndexTermKind, TocEntry}; /// An entry in the index catalog, collected during document traversal. @@ -165,8 +164,7 @@ pub(crate) const STYLESHEET_DEFAULT: &str = ""; pub(crate) const STYLESHEET_FILENAME_DEFAULT: &str = "asciidoctor.css"; pub(crate) const WEBFONTS_DEFAULT: &str = ""; -impl Processable for Processor { - type Options = Options; +impl Converter for Processor { type Error = Error; fn document_attributes_defaults() -> DocumentAttributes { @@ -212,118 +210,106 @@ impl Processable for Processor { } } - fn convert( + fn options(&self) -> &Options { + &self.options + } + + fn document_attributes(&self) -> &DocumentAttributes { + &self.document_attributes + } + + fn derive_output_path(&self, input: &Path, _doc: &Document) -> Result, Error> { + let html_path = input.with_extension("html"); + // Avoid overwriting the input file + if html_path == input { + return Err(Error::OutputPathSameAsInput(input.to_path_buf())); + } + Ok(Some(html_path)) + } + + fn write_to( &self, - doc: &acdc_parser::Document, - file: Option<&std::path::Path>, + doc: &Document, + writer: W, + source_file: Option<&Path>, ) -> Result<(), Self::Error> { - if let Some(file_path) = file { - // File-based conversion - write to .html file - let html_path = file_path.with_extension("html"); - if html_path == file_path { - return Err(Error::OutputPathSameAsInput(file_path.to_path_buf())); - } + let render_options = RenderOptions { + last_updated: source_file.and_then(|f| { + std::fs::metadata(f) + .ok() + .and_then(|m| m.modified().ok()) + .map(chrono::DateTime::from) + }), + embedded: self.options.embedded(), + ..RenderOptions::default() + }; + self.convert_to_writer(doc, writer, &render_options) + } - let render_options = RenderOptions { - last_updated: Some( - std::fs::metadata(file_path)? - .modified() - .map(chrono::DateTime::from)?, - ), - embedded: self.options.embedded(), - ..RenderOptions::default() - }; - - if self.options.timings() { - println!("Input file: {}", file_path.display()); - } - tracing::debug!(source = ?file_path, destination = ?html_path, "converting document"); + fn after_write(&self, doc: &Document, output_path: &Path) { + Self::handle_copycss(doc, output_path); + } - let now = Instant::now(); - let file_handle = File::create(&html_path)?; - let writer = BufWriter::new(file_handle); - self.convert_to_writer(doc, writer, &render_options)?; - let elapsed = now.elapsed(); - tracing::debug!(time = elapsed.pretty_print_precise(3), source = ?file_path, destination = ?html_path, "time to convert document"); + fn backend(&self) -> Backend { + Backend::Html + } +} - if self.options.timings() { - println!(" Time to convert document: {}", elapsed.pretty_print()); - } - println!("Generated HTML file: {}", html_path.display()); - - // Handle copycss if linkcss is set - let linkcss = doc.attributes.get("linkcss").is_some(); - if linkcss { - // Check if copycss is set (empty string means copy, unset means don't copy) - let should_copy = doc.attributes.contains_key("copycss"); - tracing::debug!("linkcss={}, copycss exists={}", linkcss, should_copy); - - if should_copy { - // Get stylesheet name - let stylesheet = doc - .attributes - .get("stylesheet") - .and_then(|v| { - let s = v.to_string(); - if s.is_empty() { None } else { Some(s) } - }) - .unwrap_or_else(|| STYLESHEET_FILENAME_DEFAULT.into()); - - // Get stylesdir - let stylesdir = doc - .attributes - .get("stylesdir") - .map_or_else(|| STYLESDIR_DEFAULT.into(), ToString::to_string); - - // Build source path - let source_path = if stylesdir.is_empty() || stylesdir == STYLESDIR_DEFAULT { - std::path::Path::new(&stylesheet).to_path_buf() - } else { - std::path::Path::new(&stylesdir).join(&stylesheet) - }; - - // Get output directory (same as HTML output) - let output_dir = html_path - .parent() - .unwrap_or_else(|| std::path::Path::new(".")); - let dest_path = output_dir.join(&stylesheet); - - // Copy the CSS file if source exists and is different from destination - if source_path != dest_path && source_path.exists() { - if let Err(e) = std::fs::copy(&source_path, &dest_path) { - tracing::warn!( - "Failed to copy stylesheet from {} to {}: {}", - source_path.display(), - dest_path.display(), - e - ); - } else { - tracing::debug!( - "Copied stylesheet from {} to {}", - source_path.display(), - dest_path.display() - ); - } - } - } - } +impl Processor { + /// Handle copying CSS if linkcss and copycss are set. + fn handle_copycss(doc: &acdc_parser::Document, html_path: &std::path::Path) { + let linkcss = doc.attributes.get("linkcss").is_some(); + if !linkcss { + return; + } - Ok(()) - } else { - // Stdin-based conversion - write to stdout - let render_options = RenderOptions { - embedded: self.options.embedded(), - ..RenderOptions::default() - }; - let stdout = io::stdout(); - let writer = BufWriter::new(stdout.lock()); - self.convert_to_writer(doc, writer, &render_options)?; - Ok(()) + let should_copy = doc.attributes.contains_key("copycss"); + tracing::debug!("linkcss={linkcss}, copycss exists={should_copy}"); + + if !should_copy { + return; } - } - fn document_attributes(&self) -> DocumentAttributes { - self.document_attributes.clone() + let stylesheet = doc + .attributes + .get("stylesheet") + .and_then(|v| { + let s = v.to_string(); + if s.is_empty() { None } else { Some(s) } + }) + .unwrap_or_else(|| STYLESHEET_FILENAME_DEFAULT.into()); + + let stylesdir = doc + .attributes + .get("stylesdir") + .map_or_else(|| STYLESDIR_DEFAULT.into(), ToString::to_string); + + let source_path = if stylesdir.is_empty() || stylesdir == STYLESDIR_DEFAULT { + std::path::Path::new(&stylesheet).to_path_buf() + } else { + std::path::Path::new(&stylesdir).join(&stylesheet) + }; + + let output_dir = html_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + let dest_path = output_dir.join(&stylesheet); + + if source_path != dest_path && source_path.exists() { + if let Err(e) = std::fs::copy(&source_path, &dest_path) { + tracing::warn!( + "Failed to copy stylesheet from {} to {}: {e}", + source_path.display(), + dest_path.display(), + ); + } else { + tracing::debug!( + "Copied stylesheet from {} to {}", + source_path.display(), + dest_path.display() + ); + } + } } } @@ -384,7 +370,7 @@ pub(crate) fn write_attribution( #[cfg(test)] mod tests { use super::*; - use acdc_converters_core::Processable; + use acdc_converters_core::Converter; type TestResult = Result<(), Box>; diff --git a/converters/html/tests/integration_test.rs b/converters/html/tests/integration_test.rs index 2d7f600..bf8418e 100644 --- a/converters/html/tests/integration_test.rs +++ b/converters/html/tests/integration_test.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use acdc_converters_core::{GeneratorMetadata, Options as ConverterOptions, Processable}; +use acdc_converters_core::{Converter, GeneratorMetadata, Options as ConverterOptions}; use acdc_converters_dev::output::remove_lines_trailing_whitespace; use acdc_converters_html::{Processor, RenderOptions}; use acdc_parser::Options as ParserOptions; diff --git a/converters/manpage/examples/generate_expected_fixtures.rs b/converters/manpage/examples/generate_expected_fixtures.rs index ca7357e..0d3e025 100644 --- a/converters/manpage/examples/generate_expected_fixtures.rs +++ b/converters/manpage/examples/generate_expected_fixtures.rs @@ -3,7 +3,7 @@ //! Usage: //! `cargo run -p acdc-converters-manpage --example generate_expected_fixtures` -use acdc_converters_core::{GeneratorMetadata, Options, Processable}; +use acdc_converters_core::{Converter, GeneratorMetadata, Options}; use acdc_converters_dev::generate_fixtures::FixtureGenerator; use acdc_converters_manpage::Processor; @@ -13,7 +13,7 @@ fn main() -> Result<(), Box> { .generator_metadata(GeneratorMetadata::new("acdc", "0.1.0")) .build(); let processor = Processor::new(options, doc.attributes.clone()); - processor.convert_to_writer(doc, output)?; + processor.write_to(doc, output, None)?; Ok(()) }) } diff --git a/converters/manpage/src/lib.rs b/converters/manpage/src/lib.rs index e35c751..64d1c33 100644 --- a/converters/manpage/src/lib.rs +++ b/converters/manpage/src/lib.rs @@ -8,7 +8,7 @@ //! //! ```ignore //! use acdc_converters_manpage::Processor; -//! use acdc_converters_core::{Options, Processable}; +//! use acdc_converters_core::{Converter, Options}; //! //! let options = Options::default(); //! let processor = Processor::new(options, Default::default()); @@ -27,13 +27,11 @@ //! - `\fB`, `\fI`, `\fP` for inline formatting use std::{ - fs::File, - io::{self, BufWriter, Write}, - path::Path, - time::Instant, + io::Write, + path::{Path, PathBuf}, }; -use acdc_converters_core::{Options, PrettyDuration, Processable, visitor::Visitor}; +use acdc_converters_core::{Backend, Converter, Options, visitor::Visitor}; use acdc_parser::{AttributeValue, Document, DocumentAttributes}; mod admonition; @@ -60,21 +58,6 @@ pub struct Processor { } impl Processor { - /// Convert a document to manpage format, writing to the provided writer. - /// - /// # Errors - /// - /// Returns an error if document conversion or writing fails. - pub fn convert_to_writer(&self, doc: &Document, writer: W) -> Result<(), Error> { - let processor = Processor { - document_attributes: doc.attributes.clone(), - ..self.clone() - }; - let mut visitor = ManpageVisitor::new(writer, processor); - visitor.visit_document(doc)?; - Ok(()) - } - /// Determine the output file extension based on the volume number. fn output_extension(doc: &Document) -> String { // Read manvolnum from document attributes (set by parser) @@ -90,8 +73,7 @@ impl Processor { } } -impl Processable for Processor { - type Options = Options; +impl Converter for Processor { type Error = Error; fn document_attributes_defaults() -> DocumentAttributes { @@ -117,54 +99,40 @@ impl Processable for Processor { } } - fn convert(&self, doc: &Document, file: Option<&Path>) -> Result<(), Self::Error> { - if let Some(file_path) = file { - // File-based conversion - write to .N file (where N is volume number) - let extension = Self::output_extension(doc); - let manpage_path = file_path.with_extension(&extension); - - if manpage_path == file_path { - return Err(Error::OutputPathSameAsInput(file_path.to_path_buf())); - } - - if self.options.timings() { - println!("Input file: {}", file_path.display()); - } - tracing::debug!( - source = ?file_path, - destination = ?manpage_path, - "converting document to manpage" - ); - - let now = Instant::now(); - let file_handle = File::create(&manpage_path)?; - let writer = BufWriter::new(file_handle); - self.convert_to_writer(doc, writer)?; - let elapsed = now.elapsed(); - - tracing::debug!( - time = elapsed.pretty_print_precise(3), - source = ?file_path, - destination = ?manpage_path, - "time to convert document" - ); + fn options(&self) -> &Options { + &self.options + } - if self.options.timings() { - println!(" Time to convert document: {}", elapsed.pretty_print()); - } - println!("Generated manpage file: {}", manpage_path.display()); + fn document_attributes(&self) -> &DocumentAttributes { + &self.document_attributes + } - Ok(()) - } else { - // Stdin-based conversion - write to stdout - let stdout = io::stdout(); - let writer = BufWriter::new(stdout.lock()); - self.convert_to_writer(doc, writer)?; - Ok(()) + fn derive_output_path(&self, input: &Path, doc: &Document) -> Result, Error> { + let extension = Self::output_extension(doc); + let manpage_path = input.with_extension(&extension); + // Avoid overwriting the input file + if manpage_path == input { + return Err(Error::OutputPathSameAsInput(input.to_path_buf())); } + Ok(Some(manpage_path)) + } + + fn write_to( + &self, + doc: &Document, + writer: W, + _source_file: Option<&Path>, + ) -> Result<(), Self::Error> { + let processor = Processor { + document_attributes: doc.attributes.clone(), + ..self.clone() + }; + let mut visitor = ManpageVisitor::new(writer, processor); + visitor.visit_document(doc)?; + Ok(()) } - fn document_attributes(&self) -> DocumentAttributes { - self.document_attributes.clone() + fn backend(&self) -> Backend { + Backend::Manpage } } diff --git a/converters/manpage/tests/integration_test.rs b/converters/manpage/tests/integration_test.rs index c547a00..0cc95cf 100644 --- a/converters/manpage/tests/integration_test.rs +++ b/converters/manpage/tests/integration_test.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use acdc_converters_core::{GeneratorMetadata, Options as ConverterOptions, Processable}; +use acdc_converters_core::{Converter, GeneratorMetadata, Options as ConverterOptions}; use acdc_converters_dev::output::remove_lines_trailing_whitespace; use acdc_converters_manpage::Processor; use acdc_parser::Options as ParserOptions; @@ -32,7 +32,7 @@ fn test_with_fixtures(#[files("tests/fixtures/source/*.adoc")] path: PathBuf) -> .generator_metadata(GeneratorMetadata::new("acdc", "0.1.0")) .build(); let processor = Processor::new(converter_options, doc.attributes.clone()); - processor.convert_to_writer(&doc, &mut output)?; + processor.write_to(&doc, &mut output, Some(path.as_path()))?; // Read expected output let expected = std::fs::read_to_string(&expected_path)?; diff --git a/converters/terminal/examples/generate_expected_fixtures.rs b/converters/terminal/examples/generate_expected_fixtures.rs index 53c0538..e55c2fb 100644 --- a/converters/terminal/examples/generate_expected_fixtures.rs +++ b/converters/terminal/examples/generate_expected_fixtures.rs @@ -3,14 +3,14 @@ //! Usage: //! `cargo run -p acdc-converters-terminal --features images,highlighting --example generate_expected_fixtures` -use acdc_converters_core::{Options, Processable}; +use acdc_converters_core::{Converter, Options}; use acdc_converters_dev::generate_fixtures::FixtureGenerator; use acdc_converters_terminal::Processor; fn main() -> Result<(), Box> { FixtureGenerator::new("terminal", "txt").generate(|doc, output| { let processor = Processor::new(Options::default(), doc.attributes.clone()); - processor.convert_to_writer(doc, output)?; + processor.write_to(doc, output, None)?; Ok(()) }) } diff --git a/converters/terminal/src/lib.rs b/converters/terminal/src/lib.rs index be90492..8ab8952 100644 --- a/converters/terminal/src/lib.rs +++ b/converters/terminal/src/lib.rs @@ -1,10 +1,11 @@ use std::{ cell::Cell, - io::{BufWriter, Write}, + io::Write, + path::{Path, PathBuf}, rc::Rc, }; -use acdc_converters_core::{Options, Processable, visitor::Visitor}; +use acdc_converters_core::{Backend, Converter, Options, visitor::Visitor}; use acdc_parser::{Document, DocumentAttributes, TocEntry}; pub(crate) use appearance::Appearance; @@ -23,28 +24,7 @@ pub struct Processor { pub appearance: Appearance, } -impl Processor { - /// Convert a document to terminal output, writing to the provided writer. - /// - /// # Errors - /// - /// Returns an error if document conversion or writing fails. - pub fn convert_to_writer(&self, doc: &Document, writer: W) -> Result<(), Error> { - let processor = Processor { - document_attributes: doc.attributes.clone(), - toc_entries: doc.toc_entries.clone(), - options: self.options.clone(), - example_counter: self.example_counter.clone(), - appearance: self.appearance.clone(), - }; - let mut visitor = TerminalVisitor::new(writer, processor); - visitor.visit_document(doc)?; - Ok(()) - } -} - -impl Processable for Processor { - type Options = Options; +impl Converter for Processor { type Error = Error; fn document_attributes_defaults() -> DocumentAttributes { @@ -70,20 +50,39 @@ impl Processable for Processor { } } - fn convert( + fn options(&self) -> &Options { + &self.options + } + + fn document_attributes(&self) -> &DocumentAttributes { + &self.document_attributes + } + + fn derive_output_path(&self, _input: &Path, _doc: &Document) -> Result, Error> { + // Terminal converter always outputs to stdout by default + Ok(None) + } + + fn write_to( &self, - doc: &acdc_parser::Document, - _file: Option<&std::path::Path>, + doc: &Document, + writer: W, + _source_file: Option<&Path>, ) -> Result<(), Self::Error> { - // Terminal always outputs to stdout, file parameter is ignored - let stdout = std::io::stdout(); - let writer = BufWriter::new(stdout.lock()); - self.convert_to_writer(doc, writer)?; + let processor = Processor { + document_attributes: doc.attributes.clone(), + toc_entries: doc.toc_entries.clone(), + options: self.options.clone(), + example_counter: self.example_counter.clone(), + appearance: self.appearance.clone(), + }; + let mut visitor = TerminalVisitor::new(writer, processor); + visitor.visit_document(doc)?; Ok(()) } - fn document_attributes(&self) -> DocumentAttributes { - self.document_attributes.clone() + fn backend(&self) -> Backend { + Backend::Terminal } } diff --git a/converters/terminal/tests/integration_test.rs b/converters/terminal/tests/integration_test.rs index 6fdca30..d5576c7 100644 --- a/converters/terminal/tests/integration_test.rs +++ b/converters/terminal/tests/integration_test.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use acdc_converters_core::{Options as ConverterOptions, Processable}; +use acdc_converters_core::{Converter, Options as ConverterOptions}; use acdc_converters_dev::output::remove_lines_trailing_whitespace; use acdc_converters_terminal::Processor; use acdc_parser::Options as ParserOptions; @@ -58,7 +58,7 @@ fn test_fixture(fixture_name: &str, osc8: bool) -> Result<(), Error> { // Convert to Terminal output let mut output = Vec::new(); let processor = Processor::new(ConverterOptions::default(), doc.attributes.clone()); - processor.convert_to_writer(&doc, &mut output)?; + processor.write_to(&doc, &mut output, Some(input_path.as_path()))?; if osc8 && !processor.appearance.capabilities.osc8_links { // If the fixture name indicates osc8 links but we're running in a terminal that