quicklatex 0.1.0

A program to help me write LaTeX quickly
Documentation
use std::{
    mem::take,
    path::{Path, PathBuf},
};

const MAX_RECURSION_DEPTH: i32 = 1000;
const MAX_REPETITION_COUNT: u32 = 1000;
const MAX_LENGTH_MULTIPLICATION: usize = 100;
const MAX_LENGTH_MULTIPLICATION_OFFSET: usize = 1000;

use anyhow::{bail, Context, Error};
use diskit::{diskit_extend::DiskitExt, Diskit};
use home::home_dir;

use crate::{
    misc::{starts_with, Span},
    noisy::Noisy,
    second_pass::{Block, Text, TextPiece},
    Timers,
};

/// A replacement rule
///
/// This struct stores a single replacement rule as was specified in
/// an `qlr` file.
// The only situation in which a value of this type leaves the library
// is via the [`repls`](Noisy::repls) field of [`Noisy`](Noisy) which
// means that the [`noisy`](Self::noisy) field is always `true` in all
// values that a user will get from this library.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Replacement
{
    /// The string that is getting replaced.
    pub(crate) from: String,
    /// The string to which it will be replaced.
    pub(crate) to: String,
    /// This specifies whether a warning should be generated.  As
    /// outlined (above)[Replacement] this is always `true` for you.
    pub(crate) noisy: bool,
    /// This specifies whether this replacement rule is run multiple
    /// times or a single time.  When `true` the different iterations
    /// are done directly one after the other (i.e. without other
    /// rules being run between them) until nothing changes anymore or
    /// some hard-coded upper limit is reached.
    pub(crate) repeated: bool,
    /// If this is `Some`, then this string must be present
    /// *somewhere* in the document for this rule not to be discarded.
    pub(crate) condition: Option<String>,
    /// This specifies whether this rule is a refkind rule or not.  If
    /// it is then the string `\ref` is appended to the `from` field
    /// and the replacement happens in a seperate occasion after all
    /// the non-refkind replacements.
    pub(crate) refkind: bool,
}

impl Block
{
    fn fix_math(self) -> Self
    {
        if starts_with(&self.kind, "align")
        {
            let span = Span {
                start: self.content.pieces[0].1.start,
                end: self.content.pieces[self.content.pieces.len() - 1].1.end,
            };

            let text = format!("{}", self.content);
            let lines = text
                .lines()
                .filter(|line| !line.chars().all(char::is_whitespace))
                .collect::<Vec<_>>();

            let mut text = vec!['\n'];

            for (i, line) in lines.iter().enumerate()
            {
                let line: &str = line;

                text.extend_from_slice(&line.chars().collect::<Vec<_>>());

                if i != lines.len() - 1
                // && !line.contains("noslash")
                {
                    text.extend_from_slice(&[' ', '\\', '\\']);
                }

                text.extend_from_slice(&['\n']);
            }

            Self {
                kind: self.kind,
                content: Text {
                    pieces: vec![(TextPiece::Text(text), span)],
                },
            }
        }
        else
        {
            Self {
                kind: self.kind,
                content: self.content.fix_math(),
            }
        }
    }
}

impl TextPiece
{
    fn fix_math(self) -> Self
    {
        match self
        {
            Self::Block(block) => Self::Block(block.fix_math()),
            Self::Braces(braces) => Self::Braces(braces.fix_math()),
            val => val,
        }
    }
}

impl Text
{
    pub fn fix_math(self) -> Self
    {
        Self {
            pieces: self
                .pieces
                .into_iter()
                .map(|(piece, span)| (piece.fix_math(), span))
                .collect(),
        }
    }
}

fn parse_filename(path: &str) -> Result<PathBuf, Error>
{
    let mut rv = String::with_capacity(path.len());

    let mut in_quote = false;

    for c in path.chars()
    {
        let was_in_quote = in_quote;

        match (in_quote, c)
        {
            (false, '\\') => in_quote = true,
            (false, c) => rv.push(c),
            (true, '\\') => rv.push('\\'),
            (true, 'n') => rv.push('\n'),
            (true, 't') => rv.push('\t'),
            (true, 's') => rv.push(' '),
            (true, c) => bail!("Unknown escape sequence \\{c:?}"),
        }

        if was_in_quote
        {
            in_quote = false;
        }
    }

    Ok(rv.into())
}

// Decided against.  Also pedantic lint.
#[allow(clippy::fn_params_excessive_bools)]
fn parse_replacement(
    replacement: &str,
    noisy: bool,
    repeated: bool,
    condition: bool,
    refkind: bool,
) -> Result<Replacement, Error>
{
    let mut fragments = vec![];
    let mut fragment = String::new();

    let mut in_quote = false;

    for c in replacement.chars()
    {
        let was_in_quote = in_quote;

        match (in_quote, c)
        {
            (false, '\\') => in_quote = true,
            (false, ';') => fragments.push(take(&mut fragment)),
            (false, c) => fragment.push(c),
            (true, '\\') => fragment.push('\\'),
            (true, 'n') => fragment.push('\n'),
            (true, 's') => fragment.push(' '),
            (true, ';') => fragment.push(';'),
            (true, c) => bail!("Unknown escape sequence \\{c:?}"),
        }

        if was_in_quote
        {
            in_quote = false;
        }
    }

    fragments.push(take(&mut fragment));

    if fragments.len() != (2 + condition as u8 as usize)
    {
        bail!("Wrong number of fragments found");
    }

    let condition = if condition { Some(take(&mut fragments[2])) } else { None };

    Ok(Replacement {
        from: take(&mut fragments[0]),
        to: take(&mut fragments[1]),
        noisy,
        repeated,
        condition,
        refkind,
    })
}

// Look in lib.rs for justification.
#[allow(clippy::needless_pass_by_value)]
fn read_replacements<D: Diskit>(file: &Path, recursion_depth: i32, d: D) -> Result<Vec<Replacement>, Error>
{
    let mut replacements = vec![];

    if recursion_depth <= 0
    {
        bail!("Recursion depth limit exceeded");
    }

    for line in d
        .clone()
        .read_to_string(file)
        .with_context(|| format!("Couldn't read replacement file {file:?}"))?
        .lines()
    {
        let (command, value) = line
            .split_once(' ')
            .with_context(|| format!("Couldn't read all lines in replacement file {file:?}"))?;

        if command == "include" || command == "include_default"
        {
            let root = if command == "include"
            {
                file.parent()
                    .with_context(|| format!("Bug in quicklatex about path {file:?}"))?
                    .to_owned()
            }
            else
            {
                home_dir()
                    .context("Couldn't get home directory")?
                    .join(PathBuf::from("./.zvavybir/quicklatex/"))
            };

            replacements.append(
                &mut read_replacements(
                    &root.join(
                        parse_filename(value)
                            .with_context(|| format!("Couldn't read import statement in replacement file {file:?}"))?,
                    ),
                    recursion_depth - 1,
                    d.clone(),
                )
                .with_context(|| format!("Included from replacement file {file:?}"))?,
            );
        }
        else if command.starts_with("replace")
        {
            replacements.push(
                parse_replacement(
                    value,
                    command.contains("_noisy"),
                    command.contains("_repeated"),
                    command.contains("_conditionally"),
                    command.contains("_refkind"),
                )
                .with_context(|| format!("Couldn't read replacement from replacement file {file:?}"))?,
            );
        }
        else
        {
            bail!("Unknown command {command:?} at replacement file {file:?}")
        }
    }

    Ok(replacements)
}

pub fn literal<D: Diskit>(
    mut s: String,
    path: &Path,
    timers: &mut Timers,
    d: D,
) -> Result<(String, Noisy, Vec<Replacement>), Error>
{
    timers.start("repl_full")?;

    let primary_file = path.with_extension("qlr");
    let default_file = home_dir()
        .context("Couldn't get home directory")?
        .join(PathBuf::from("./.zvavybir/quicklatex/default.qlr"));

    let replacements = if primary_file.exists()
    {
        read_replacements(&primary_file, MAX_RECURSION_DEPTH, d)
            .context("Couldn't read corresponding replacement file")?
    }
    else if default_file.exists()
    {
        read_replacements(&default_file, MAX_RECURSION_DEPTH, d).context("Couldn't read default replacement file")?
    }
    else
    {
        vec![]
    };

    timers.start("repl_inner")?;

    let mut noisy = vec![];

    for replacement in &replacements
    {
        if replacement.refkind
        {
            continue;
        }

        if let Some(condition) = &replacement.condition
        {
            if !s.contains(condition)
            {
                continue;
            }
        }

        // Just because something can be collapsed does not mean that
        // collapsing it makes it more readable.  I think that an
        // `else if` doesn't make semantically sense.
        #[allow(clippy::collapsible_else_if)]
        if replacement.repeated
        {
            let initial_len = s.len();

            if s.contains(&replacement.from)
            {
                for _ in 0..MAX_REPETITION_COUNT
                {
                    let new_s = s.replace(&replacement.from, &replacement.to);
                    if s == new_s
                        || new_s.len() > (initial_len * MAX_LENGTH_MULTIPLICATION + MAX_LENGTH_MULTIPLICATION_OFFSET)
                    {
                        break;
                    }
                    s = new_s;
                }
                if replacement.noisy
                {
                    noisy.push(replacement.clone());
                }
            }
        }
        else
        {
            if s.contains(&replacement.from)
            {
                s = s.replace(&replacement.from, &replacement.to);
                if replacement.noisy
                {
                    noisy.push(replacement.clone());
                }
            }
        }
    }

    timers.stop("repl_inner")?;
    timers.stop("repl_full")?;

    Ok((s, Noisy::from_replacements(noisy), replacements))
}