rsticle 0.1.2

Treat source files as articles / narrative documentation
Documentation
#![doc = env!("CARGO_PKG_DESCRIPTION")]
#![doc = ""]
//! This is the library. If you want to document your example code in `rustdoc`, see
//! [`rsticle-rustdoc`].
//!
//! See the [Readme] for a general overview.
//!
//!   [`rsticle-rustdoc`]: https://docs.rs/rsticle-rustdoc
//!   [Readme]: https://codeberg.org/wldmr/rsticle/src/branch/main/README.md

// Doctest the Readme, just to be extra paranoid
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct Readme;

use std::{
    borrow::Cow,
    io::{BufRead, Cursor, Write},
    ops::Deref,
};

/// A configuration for document extraction.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Profile<'a> {
    /// Introduces a narrative comment.
    pub narrative: Cow<'a, str>,

    /// If a line of code ends with this, do not include it the output.
    pub ignore_line_postfix: Cow<'a, str>,

    /// Starts a block of lines, all of which will be ignored until [`ignored_block_end`](Self::ignored_block_end).
    pub ignored_block_start: Cow<'a, str>,
    /// Ends an ignored block.
    pub ignored_block_end: Cow<'a, str>,

    /// Sets the *additional* indentation for all following code blocks.
    pub indent: Cow<'a, str>,
}

impl Default for Profile<'_> {
    fn default() -> Self {
        SLASH.clone()
    }
}

impl<'a> Profile<'a> {
    /// Create a profile from a line comment string.
    ///
    /// For instance, if in your language's line comments start with `~~~`,
    /// you can quickly create a standard profile for it like so:
    ///
    /// ```rust
    /// # use rsticle::Profile;
    /// let profile = Profile::from_comment("~~~");
    /// assert_eq!(profile.narrative, "~~~:");
    /// assert_eq!(profile.ignore_line_postfix, "~~~");
    /// assert_eq!(profile.ignored_block_start, "~~~{");
    /// assert_eq!(profile.ignored_block_end, "~~~}");
    /// assert_eq!(profile.indent, "~~~>");
    /// ```
    ///
    /// If you need more customization, use the various `with_*` builder methods.
    pub fn from_comment(comment: &str) -> Self {
        Self {
            narrative: Cow::Owned(format!("{comment}:")),
            ignore_line_postfix: Cow::Owned(format!("{comment}")),
            ignored_block_start: Cow::Owned(format!("{comment}{{")),
            ignored_block_end: Cow::Owned(format!("{comment}}}")),
            indent: Cow::Owned(format!("{comment}>")),
        }
    }

    pub fn with_narrative(mut self, s: impl Into<Cow<'a, str>>) -> Self {
        self.narrative = s.into();
        self
    }

    pub fn with_ignore_line_postfix(mut self, s: impl Into<Cow<'a, str>>) -> Self {
        self.ignore_line_postfix = s.into();
        self
    }

    pub fn with_ignored_block_start(mut self, s: impl Into<Cow<'a, str>>) -> Self {
        self.ignored_block_start = s.into();
        self
    }

    pub fn with_ignored_block_end(mut self, s: impl Into<Cow<'a, str>>) -> Self {
        self.ignored_block_end = s.into();
        self
    }

    pub fn with_indent(mut self, s: impl Into<Cow<'a, str>>) -> Self {
        self.indent = s.into();
        self
    }
}

/// Default profile for languages with `//` comments
///
/// ```rust
/// # use rsticle::*;
/// assert_eq!(SLASH, Profile::from_comment("//"));
/// ```
pub const SLASH: Profile = Profile {
    narrative: Cow::Borrowed("//:"),
    ignore_line_postfix: Cow::Borrowed("//"),
    ignored_block_start: Cow::Borrowed("//{"),
    ignored_block_end: Cow::Borrowed("//}"),
    indent: Cow::Borrowed("//>"),
};

/// Default profile for languages with `//` comments
pub const HASH: Profile = Profile {
    narrative: Cow::Borrowed("#:"),
    ignore_line_postfix: Cow::Borrowed("#"),
    ignored_block_start: Cow::Borrowed("#{"),
    ignored_block_end: Cow::Borrowed("#}"),
    indent: Cow::Borrowed("#>"),
};

/// Default profile for languages with `--` comments
pub const DASH: Profile = Profile {
    narrative: Cow::Borrowed("--:"),
    ignore_line_postfix: Cow::Borrowed("--"),
    ignored_block_start: Cow::Borrowed("--{"),
    ignored_block_end: Cow::Borrowed("--}"),
    indent: Cow::Borrowed("-->"),
};

/// Default profile for languages with `;` comments
pub const SEMICOLON: Profile = Profile {
    narrative: Cow::Borrowed(";:"),
    ignore_line_postfix: Cow::Borrowed(";"),
    ignored_block_start: Cow::Borrowed(";{"),
    ignored_block_end: Cow::Borrowed(";}"),
    indent: Cow::Borrowed(";>"),
};

/// Something went wrong during conversion.
#[derive(Debug)]
pub struct ConvertError(String);

impl std::fmt::Display for ConvertError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "rsticle Error: {}", self.0)
    }
}
impl std::error::Error for ConvertError {}

impl From<std::io::Error> for ConvertError {
    fn from(value: std::io::Error) -> Self {
        Self(value.to_string())
    }
}
impl From<std::fmt::Error> for ConvertError {
    fn from(value: std::fmt::Error) -> Self {
        Self(value.to_string())
    }
}

type Indent = usize;
/// Initial value when the *next* line is (expected to be) code,
/// but we don't know its indent yet.

#[derive(PartialEq, Eq)]
enum State {
    IgnoringBlock,
    Code(Option<Indent>),
    Narrative,
}

/// Streaming conversion (i.e. file to file).
///
/// ```rust
/// # use std::io::{Cursor, BufReader, BufWriter};
/// # use rsticle::{convert, SLASH};
/// # let input_file = Cursor::new("fn nothing() {}"); // totally faking the "files"
/// # let output_file = Vec::new();
/// let input = BufReader::new(input_file);
/// let mut output = BufWriter::new(output_file);
/// assert!(convert(&SLASH, input, &mut output).is_ok());
/// ```
pub fn convert(
    profile: &Profile<'_>,
    input: impl BufRead,
    output: &mut impl Write,
) -> Result<(), ConvertError> {
    let mut state = State::Code(None);
    let mut add_indent = 0;
    let mut line_buf = Vec::<String>::with_capacity(128);

    let narrative_prefix = profile.narrative.len();
    let narrative = profile.narrative.deref();
    let ignore_line_postfix = profile.ignore_line_postfix.deref();
    let ignored_block_start = profile.ignored_block_start.deref();
    let ignored_block_end = profile.ignored_block_end.deref();
    let indent = profile.indent.deref();

    for (line_no, line) in input.lines().enumerate() {
        let line = line?;
        let trimmed = line.trim();
        match &mut state {
            State::IgnoringBlock => {
                if trimmed.starts_with(ignored_block_end) {
                    state = State::Code(None);
                }
            }

            State::Code(block_indent) => {
                if trimmed.starts_with(narrative) {
                    flush_dedented(&mut line_buf, output, *block_indent, add_indent)?;
                    output_line(output, trimmed[narrative_prefix..].trim())?;
                    state = State::Narrative;
                } else if trimmed.starts_with(ignored_block_start) {
                    flush_dedented(&mut line_buf, output, *block_indent, add_indent)?;
                    state = State::IgnoringBlock
                } else if trimmed.starts_with(indent) {
                    flush_dedented(&mut line_buf, output, *block_indent, add_indent)?;
                    add_indent = trimmed[indent.len()..].trim().chars().count();
                } else if !trimmed.ends_with(ignore_line_postfix) {
                    if let Some(line_indent) = buffer_line(&mut line_buf, line) {
                        let min_indent = block_indent
                            .map(|it| it.min(line_indent))
                            .unwrap_or(line_indent);
                        block_indent.replace(min_indent);
                    }
                }
            }

            State::Narrative => {
                if trimmed.starts_with(ignored_block_start) {
                    state = State::IgnoringBlock;
                } else if trimmed.starts_with(narrative) {
                    // Allow "empty" lines, but require non-empty narrative lines to start with a space.
                    let trimmed_without_prefix = &trimmed[narrative_prefix..];
                    if trimmed_without_prefix.is_empty() {
                        output_line(output, "")?;
                    } else if trimmed_without_prefix.starts_with(" ") {
                        output_line(output, &trimmed_without_prefix[1..])?;
                    } else {
                        let (line_example, dots) = trimmed
                            .get(0..narrative_prefix + 7)
                            .map(|shortened| (shortened, "..."))
                            .unwrap_or_else(|| (trimmed, ""));
                        return Err(ConvertError(format!(
                            "Line {} ({line_example}{dots}): Space required after {narrative}",
                            line_no + 1,
                        )));
                    }
                } else if trimmed.starts_with(indent) {
                    add_indent = trimmed[indent.len()..].trim().chars().count();
                } else {
                    let indent = if trimmed.ends_with(ignore_line_postfix) {
                        None
                    } else {
                        buffer_line(&mut line_buf, line)
                    };
                    state = State::Code(indent);
                }
            }
        }
    }

    // Pick up any straglers.
    if let State::Code(block_indent) = state {
        flush_dedented(&mut line_buf, output, block_indent, add_indent)?;
    }

    Ok(())
}

/// In-memory (i.e. non-streaming) conversion.
///
/// ``` rust
/// # use rsticle::*;
/// let source = r#"\
/// //: Look at this:
/// //:
/// //: ```rust
/// fn some_func() -> String {
///     String::new("Hi!")
/// }
/// //: ```rust
/// "#;
///
/// let doc = convert_str(&SLASH, source).unwrap();
///
/// assert_eq!(doc, r#"\
/// Look at this:
///
/// ```rust
/// fn some_func() -> String {
///     String::new("Hi!")
/// }
/// ```rust
/// "#)
/// ```
pub fn convert_str(profile: &Profile, input: &str) -> Result<String, ConvertError> {
    let input = Cursor::new(input);
    let mut output = Vec::new();
    convert(&profile, input, &mut output)?;
    String::from_utf8(output).map_err(|e| ConvertError(format!("Invalid UTF-8: {e}")))
}

fn buffer_line(buf: &mut Vec<String>, line: String) -> Option<Indent> {
    let indent = line.len() - line.trim_start().len();
    let not_just_whitespace = line.trim().len() > 0;
    buf.push(line);
    not_just_whitespace.then_some(indent)
}

fn output_line(output: &mut impl Write, line: &str) -> Result<(), ConvertError> {
    output.write_all(line.as_bytes())?;
    output.write_all(b"\n")?;
    Ok(())
}

fn flush_dedented(
    buf: &mut Vec<String>,
    output: &mut impl std::io::Write,
    dedent: Option<Indent>,
    add: Indent,
) -> Result<(), ConvertError> {
    let dedent = dedent.unwrap_or(0);
    let added_spaces = vec![b' '; add];
    for line in buf.drain(..) {
        if line.len() > dedent && line.trim().len() != 0 {
            // ^ so we don't output pointless lines containing only spaces
            let content = &line[dedent..];
            output.write_all(&added_spaces)?;
            output.write_all(content.as_bytes())?;
        }
        output.write_all(b"\n")?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {}