text-tags 0.1.0

A lightweight, text-tag markup parser
Documentation
/*
 * Copyright (c) Peter Bjorklund. All rights reserved. https://github.com/swamp/swamp
 * Licensed under the MIT License. See LICENSE in the project root for license information.
 */

use fixed32::Fp;
use seq_map::SeqMap;

#[derive(Clone, Debug, PartialEq)]
pub enum Node {
    Text(String),
    Element(Element),
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ArgValue {
    Int(i64),
    Fixed(Fp),
    Bool(bool),
    Hex(String),
    Keyword(String),
    Str(String),
}

#[derive(Clone, Debug, PartialEq)]
pub struct Element {
    pub name: String,
    pub args: SeqMap<String, ArgValue>,
    pub children: Vec<Node>,
}

#[derive(Debug)]
pub struct ParseError {
    pub message: String,
    pub line: usize,
    pub column: usize,
}

pub struct Parser<'a> {
    input: &'a [u8],
    pos: usize,
    line: usize,
    col: usize,
    pub errors: Vec<ParseError>,
}

impl<'a> Parser<'a> {
    pub fn new(input: &'a str) -> Self {
        Self {
            input: input.as_bytes(),
            pos: 0,
            line: 1,
            col: 1,
            errors: Vec::new(),
        }
    }

    fn peek(&self) -> Option<u8> {
        self.input.get(self.pos).copied()
    }

    fn next(&mut self) -> Option<u8> {
        let b = self.peek()?;
        self.pos += 1;
        if b == b'\n' {
            self.line += 1;
            self.col = 1;
        } else {
            self.col += 1;
        }
        Some(b)
    }

    fn eof(&self) -> bool {
        self.pos >= self.input.len()
    }

    fn starts_with(&self, pat: &[u8]) -> bool {
        self.input
            .get(self.pos..)
            .map_or(false, |r| r.starts_with(pat))
    }

    fn consume_ws(&mut self) {
        while let Some(b) = self.peek() {
            if b.is_ascii_whitespace() {
                self.next();
            } else {
                break;
            }
        }
    }

    pub fn parse(&mut self) -> Vec<Node> {
        self.parse_nodes(None)
    }

    fn parse_nodes(&mut self, stop_tag: Option<&str>) -> Vec<Node> {
        let mut nodes = Vec::new();
        while !self.eof() {
            if let Some(tag) = stop_tag {
                if self.starts_with(format!("[/{tag}]").as_bytes()) {
                    break;
                }
            }
            if self.starts_with(b"[/") {
                let (l, c) = (self.line, self.col);
                self.errors.push(ParseError {
                    message: "Unexpected closing tag".into(),
                    line: l,
                    column: c,
                });
                while let Some(b) = self.next() {
                    if b == b']' {
                        break;
                    }
                }
                continue;
            }

            if self.peek() == Some(b'[') {
                nodes.push(self.parse_element());
            } else {
                let txt = self.parse_text();
                if !txt.is_empty() {
                    nodes.push(Node::Text(txt));
                }
            }
        }
        nodes
    }

    fn parse_text(&mut self) -> String {
        let mut out = String::new();
        while let Some(b) = self.peek() {
            // Stop on an unescaped '[' (start of a tag)
            if b == b'[' {
                break;
            }

            // Handle backslash-escaping of '[' or ']'
            if b == b'\\' {
                if let Some(next) = self.input.get(self.pos + 1) {
                    if *next == b'[' || *next == b']' {
                        self.next();
                        let escaped = self.next().unwrap();
                        out.push(escaped as char);
                        continue;
                    }
                }
            }

            out.push(self.next().unwrap() as char);
        }
        out
    }

    fn parse_element(&mut self) -> Node {
        let (l, c) = (self.line, self.col);
        self.next(); // '['

        // name
        self.consume_ws();
        let name_start = self.pos;
        while let Some(b) = self.peek() {
            if b.is_ascii_alphanumeric() {
                self.next();
            } else {
                break;
            }
        }
        let name = String::from_utf8_lossy(&self.input[name_start..self.pos]).to_string();
        if name.is_empty() {
            self.errors.push(ParseError {
                message: "Missing tag name".into(),
                line: l,
                column: c,
            });
        }

        // args
        let mut args = SeqMap::new();
        loop {
            self.consume_ws();
            match self.peek() {
                Some(b'=') | Some(b']') | None => break,
                Some(_) => {
                    let val = self.parse_arg_value();
                    self.consume_ws();
                    if self.peek() == Some(b'=') {
                        if let ArgValue::Keyword(k) = val.clone() {
                            self.next();
                            self.consume_ws();
                            let v2 = self.parse_arg_value();
                            let _ = args.insert(k, v2);
                        } else {
                            self.errors.push(ParseError {
                                message: "Invalid arg name".into(),
                                line: l,
                                column: c,
                            });
                        }
                    } else {
                        let _ = args.insert("value".into(), val);
                    }
                }
            }
        }
        if self.next() != Some(b']') {
            self.errors.push(ParseError {
                message: format!("Unterminated [{}] tag", name),
                line: l,
                column: c,
            });
        }

        // children
        let children = self.parse_nodes(Some(&name));

        // skip closing
        if self.starts_with(format!("[/{name}]").as_bytes()) {
            for _ in 0..(name.len() + 3) {
                self.next();
            }
        } else {
            self.errors.push(ParseError {
                message: format!("Missing closing tag [/{name}]"),
                line: self.line,
                column: self.col,
            });
        }

        Node::Element(Element {
            name,
            args,
            children,
        })
    }

    fn parse_arg_value(&mut self) -> ArgValue {
        self.consume_ws();

        // quoted
        if self.peek() == Some(b'"') {
            self.next();
            let start = self.pos;
            while let Some(b) = self.peek() {
                if b == b'"' {
                    break;
                }
                self.next();
            }
            let s = String::from_utf8_lossy(&self.input[start..self.pos]).to_string();
            self.next();
            return ArgValue::Str(s);
        }

        // hex
        if self.peek() == Some(b'#') {
            let start = self.pos;
            self.next();
            while let Some(b) = self.peek() {
                if (b as char).is_ascii_hexdigit() {
                    self.next();
                } else {
                    break;
                }
            }
            return ArgValue::Hex(
                String::from_utf8_lossy(&self.input[start..self.pos]).to_string(),
            );
        }

        // unquoted token
        let start = self.pos;
        while let Some(b) = self.peek() {
            if b.is_ascii_whitespace() || b == b']' || b == b'=' {
                break;
            }
            self.next();
        }
        let tok = String::from_utf8_lossy(&self.input[start..self.pos]).to_string();
        if tok == "true" {
            ArgValue::Bool(true)
        } else if tok == "false" {
            ArgValue::Bool(false)
        } else if let Ok(i) = tok.parse::<i64>() {
            ArgValue::Int(i)
        } else if let Ok(f) = tok.parse::<f32>() {
            let a: Fp = f.into();
            ArgValue::Fixed(a)
        } else {
            ArgValue::Keyword(tok)
        }
    }
}

pub fn parse(input: &str) -> Vec<Node> {
    Parser::new(input).parse()
}