path.rs raw

//! Vector-path literals shared by both surface languages: the s-expression
//! form `(path (move x y) (line x y) (cubic …) (close))` and the
//! `(svg-path "M0 0 L10 10 …")` convenience, both producing the same
//! [`PathCmd`] sequence. One parser so the two spellings can never drift.

use crate::diagnostics::Diagnostic;
use crate::ir::PathCmd;
use crate::sexpr::{Sexpr, Span};

/// Parse a `(path …)` or `(svg-path "…")` list form. `items` is the whole
/// list including the head symbol; returns `None` (not an error) when the
/// head is neither, so callers can fall through to other value kinds.
pub fn parse_path_form(items: &[Sexpr], span: Span) -> Option<Result<Vec<PathCmd>, Diagnostic>> {
    match items.first().and_then(Sexpr::as_symbol) {
        Some("path") => Some(parse_sexpr_path(&items[1..])),
        Some("svg-path") => Some(parse_svg_form(&items[1..], span)),
        _ => None,
    }
}

/// `(path (move x y) (line x y) (quad cx cy x y) (cubic c1x c1y c2x c2y x y)
/// (close))`.
fn parse_sexpr_path(cmds: &[Sexpr]) -> Result<Vec<PathCmd>, Diagnostic> {
    let mut out = Vec::with_capacity(cmds.len());
    for cmd in cmds {
        let Some(list) = cmd.as_list() else {
            return Err(Diagnostic::new(
                "a path command is a list like `(move x y)` or `(close)`",
                cmd.span(),
            ));
        };
        let Some(op) = list.first().and_then(Sexpr::as_symbol) else {
            return Err(Diagnostic::new(
                "a path command must start with `move`, `line`, `quad`, \
                 `cubic`, or `close`",
                cmd.span(),
            ));
        };
        let nums = |n: usize| -> Result<Vec<f64>, Diagnostic> {
            if list.len() != n + 1 {
                return Err(Diagnostic::new(
                    format!("`{op}` takes {n} coordinate(s), found {}", list.len() - 1),
                    cmd.span(),
                ));
            }
            list[1..].iter().map(number).collect()
        };
        out.push(match op {
            "move" => {
                let c = nums(2)?;
                PathCmd::Move(c[0], c[1])
            }
            "line" => {
                let c = nums(2)?;
                PathCmd::Line(c[0], c[1])
            }
            "quad" => {
                let c = nums(4)?;
                PathCmd::Quad(c[0], c[1], c[2], c[3])
            }
            "cubic" => {
                let c = nums(6)?;
                PathCmd::Cubic(c[0], c[1], c[2], c[3], c[4], c[5])
            }
            "close" => {
                nums(0)?;
                PathCmd::Close
            }
            other => {
                return Err(Diagnostic::new(
                    format!(
                        "unknown path command `{other}`; expected move, line, quad, cubic, or close"
                    ),
                    cmd.span(),
                ));
            }
        });
    }
    Ok(out)
}

fn number(value: &Sexpr) -> Result<f64, Diagnostic> {
    match value {
        Sexpr::Int(v, _) => Ok(*v as f64),
        Sexpr::Float(v, _) => Ok(*v),
        other => Err(Diagnostic::new(
            format!(
                "expected a path coordinate number, found {}",
                other.kind_name()
            ),
            other.span(),
        )),
    }
}

/// `(svg-path "…")` — exactly one string argument, an SVG path-data string.
fn parse_svg_form(args: &[Sexpr], span: Span) -> Result<Vec<PathCmd>, Diagnostic> {
    let [Sexpr::Str(data, str_span)] = args else {
        return Err(Diagnostic::new(
            "`svg-path` takes exactly one string of SVG path data",
            span,
        ));
    };
    parse_svg_data(data, *str_span)
}

/// A pragmatic SVG path-data parser covering the absolute and relative
/// command set guiduck's tokens use: M/L/H/V/C/Q/Z (and lowercase relative
/// variants). Enough for hand-written and design-tool-exported marks; not a
/// full SVG arc/smooth-curve implementation (A/S/T are rejected with a
/// pointed message rather than silently mis-drawn).
fn parse_svg_data(data: &str, span: Span) -> Result<Vec<PathCmd>, Diagnostic> {
    let mut lex = SvgLexer::new(data, span);
    let mut out = Vec::new();
    let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
    let (mut sx, mut sy) = (0.0_f64, 0.0_f64);
    let mut cmd = 0_u8;
    loop {
        match lex.peek()? {
            Peek::End => break,
            Peek::Command(c) => {
                lex.take_command();
                if c.eq_ignore_ascii_case(&b'Z') {
                    out.push(PathCmd::Close);
                    cx = sx;
                    cy = sy;
                    cmd = 0;
                    continue;
                }
                // Set the command and fall through to read its first operand
                // set (an M's first pair really is a Move).
                cmd = c;
            }
            // A number where a command is expected repeats the last command
            // (an implicit M repeats as L, per the SVG grammar).
            Peek::Number => {
                if cmd == 0 {
                    return Err(Diagnostic::new(
                        "SVG path data must start with a move command (M or m)",
                        span,
                    ));
                } else if cmd == b'M' {
                    cmd = b'L';
                } else if cmd == b'm' {
                    cmd = b'l';
                }
            }
        }
        let rel = cmd.is_ascii_lowercase();
        match cmd.to_ascii_uppercase() {
            b'M' => {
                let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
                out.push(PathCmd::Move(x, y));
                sx = x;
                sy = y;
            }
            b'L' => {
                let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
                out.push(PathCmd::Line(x, y));
            }
            b'H' => {
                let v = lex.number()?;
                cx = if rel { cx + v } else { v };
                out.push(PathCmd::Line(cx, cy));
            }
            b'V' => {
                let v = lex.number()?;
                cy = if rel { cy + v } else { v };
                out.push(PathCmd::Line(cx, cy));
            }
            b'Q' => {
                let (qx, qy) = lex.control(cx, cy, rel)?;
                let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
                out.push(PathCmd::Quad(qx, qy, x, y));
            }
            b'C' => {
                let (a, b) = lex.control(cx, cy, rel)?;
                let (c, d) = lex.control(cx, cy, rel)?;
                let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
                out.push(PathCmd::Cubic(a, b, c, d, x, y));
            }
            other => {
                return Err(Diagnostic::new(
                    format!(
                        "unsupported SVG path command `{}`; use the s-expr `(path …)` \
                         form or M/L/H/V/C/Q/Z",
                        other as char
                    ),
                    span,
                ));
            }
        }
    }
    Ok(out)
}

enum Peek {
    Command(u8),
    Number,
    End,
}

/// A tiny scanner over SVG path data: whitespace/comma separated numbers
/// interleaved with single-letter commands.
struct SvgLexer<'a> {
    rest: &'a str,
    span: Span,
}

impl<'a> SvgLexer<'a> {
    fn new(data: &'a str, span: Span) -> Self {
        Self { rest: data, span }
    }

    fn skip_sep(&mut self) {
        self.rest = self
            .rest
            .trim_start_matches(|c: char| c.is_whitespace() || c == ',');
    }

    fn peek(&mut self) -> Result<Peek, Diagnostic> {
        self.skip_sep();
        match self.rest.chars().next() {
            None => Ok(Peek::End),
            Some(c) if c.is_ascii_alphabetic() => Ok(Peek::Command(c as u8)),
            Some(_) => Ok(Peek::Number),
        }
    }

    fn take_command(&mut self) {
        self.rest = &self.rest[1..];
    }

    fn number(&mut self) -> Result<f64, Diagnostic> {
        self.skip_sep();
        let end = self
            .rest
            .find(|c: char| {
                c.is_whitespace() || c == ',' || (c.is_ascii_alphabetic() && c != 'e' && c != 'E')
            })
            .unwrap_or(self.rest.len());
        let (tok, rest) = self.rest.split_at(end);
        self.rest = rest;
        tok.parse::<f64>().map_err(|_| {
            Diagnostic::new(format!("`{tok}` is not a valid SVG path number"), self.span)
        })
    }

    /// Read an (x, y) pair and advance the current point.
    fn point(&mut self, cx: &mut f64, cy: &mut f64, rel: bool) -> Result<(f64, f64), Diagnostic> {
        let x = self.number()?;
        let y = self.number()?;
        let (ax, ay) = if rel { (*cx + x, *cy + y) } else { (x, y) };
        *cx = ax;
        *cy = ay;
        Ok((ax, ay))
    }

    /// Read an (x, y) control point resolved against the current point
    /// without moving it.
    fn control(&mut self, cx: f64, cy: f64, rel: bool) -> Result<(f64, f64), Diagnostic> {
        let x = self.number()?;
        let y = self.number()?;
        Ok(if rel { (cx + x, cy + y) } else { (x, y) })
    }
}

#[cfg(test)]
mod tests;