AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/toml/src/lexer.rs

Latest change d7fe19be2f456eff31bbb2b789e79c235c51dddb - akurai-toml: literal and multi-line strings, full escapes, inline tables by Ólafur Búi Ólafsson

//! Low-level scanning: a character [`Cursor`] over the input plus the scalar and
//! key readers. Structural parsing (headers, arrays, table nesting) lives in
//! `parser.rs` and string forms in `strings.rs`; this module only turns runs of
//! characters into keys and scalar [`Value`]s, tracking the line number for
//! error reporting.

use crate::error::TomlError;
use crate::strings;
use crate::value::Value;

/// A cursor over the input characters. Operates on a `Vec<char>` for simple,
/// correct indexing (the input is already valid UTF-8). Tracks the 1-based line
/// so every error points somewhere useful.
pub(crate) struct Cursor {
    chars: Vec<char>,
    pos: usize,
    line: usize,
}

impl Cursor {
    pub fn new(input: &str) -> Cursor {
        Cursor {
            chars: input.chars().collect(),
            pos: 0,
            line: 1,
        }
    }

    pub fn line(&self) -> usize {
        self.line
    }

    pub fn at_eof(&self) -> bool {
        self.pos >= self.chars.len()
    }

    pub fn peek(&self) -> Option<char> {
        self.chars.get(self.pos).copied()
    }

    pub fn peek_at(&self, n: usize) -> Option<char> {
        self.chars.get(self.pos + n).copied()
    }

    pub fn bump(&mut self) -> Option<char> {
        let c = self.peek();
        if let Some(ch) = c {
            self.pos += 1;
            if ch == '\n' {
                self.line += 1;
            }
        }
        c
    }

    /// Skip spaces and tabs (TOML ignores indentation). Stops at a newline.
    pub fn skip_inline_ws(&mut self) {
        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
            self.pos += 1;
        }
    }

    /// If the cursor is on a `#`, consume the rest of the line up to (but not
    /// including) the newline.
    pub fn skip_comment(&mut self) {
        if self.peek() == Some('#') {
            while !matches!(self.peek(), None | Some('\n')) {
                self.pos += 1;
            }
        }
    }

    /// Skip everything that separates statements: inline whitespace, comments,
    /// and newlines, repeatedly. Used both at top level and inside arrays.
    pub fn skip_blank(&mut self) {
        loop {
            self.skip_inline_ws();
            match self.peek() {
                Some('#') => self.skip_comment(),
                Some('\n') => {
                    self.bump();
                }
                _ => break,
            }
        }
    }

    /// Read a bare key: one or more of `A-Za-z0-9_-`.
    pub fn read_bare_key(&mut self) -> Result<String, TomlError> {
        let mut s = String::new();
        while let Some(c) = self.peek() {
            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
                s.push(c);
                self.pos += 1;
            } else {
                break;
            }
        }
        if s.is_empty() {
            return Err(TomlError::new("expected a bare key", self.line));
        }
        Ok(s)
    }

    /// Read a single scalar value (string, bool, integer, or float). Arrays and
    /// inline tables are handled by the parser; this rejects `[` and `{`.
    pub fn read_scalar(&mut self) -> Result<Value, TomlError> {
        self.skip_inline_ws();
        match self.peek() {
            None => Err(TomlError::new("expected a value", self.line)),
            Some('"' | '\'') => Ok(Value::Str(strings::read_string(self)?)),
            Some('{') => Err(TomlError::new(
                "unexpected '{' (expected a scalar)",
                self.line,
            )),
            Some('[') => Err(TomlError::new(
                "unexpected '[' (expected a scalar)",
                self.line,
            )),
            _ => self.read_number_or_bool(),
        }
    }

    /// Read an un-quoted token (everything up to a delimiter) and classify it as
    /// a boolean or a number.
    fn read_number_or_bool(&mut self) -> Result<Value, TomlError> {
        let line = self.line;
        let mut raw = String::new();
        while let Some(c) = self.peek() {
            if c.is_whitespace() || matches!(c, ',' | ']' | '}' | '#') {
                break;
            }
            raw.push(c);
            self.pos += 1;
        }
        if raw.is_empty() {
            return Err(TomlError::new("expected a value", line));
        }
        match raw.as_str() {
            "true" => return Ok(Value::Bool(true)),
            "false" => return Ok(Value::Bool(false)),
            _ => {}
        }
        parse_number(&raw, line)
    }
}

/// Classify and parse a numeric token. Rejects unsupported non-decimal and
/// special-float forms.
fn parse_number(raw: &str, line: usize) -> Result<Value, TomlError> {
    let body = raw.strip_prefix(['+', '-']).unwrap_or(raw);
    if body.starts_with("0x") || body.starts_with("0o") || body.starts_with("0b") {
        return Err(TomlError::new(
            "non-decimal integers (0x/0o/0b) are not supported",
            line,
        ));
    }
    if matches!(body, "inf" | "nan") {
        return Err(TomlError::new("inf/nan floats are not supported", line));
    }

    let cleaned = strip_underscores(raw, line)?;
    let is_float = cleaned.contains(['.', 'e', 'E']);
    if is_float {
        cleaned
            .parse::<f64>()
            .map(Value::Float)
            .map_err(|_| TomlError::new(format!("invalid float '{raw}'"), line))
    } else {
        cleaned
            .parse::<i64>()
            .map(Value::Int)
            .map_err(|_| TomlError::new(format!("invalid integer '{raw}'"), line))
    }
}

/// Remove `_` digit separators, requiring each to sit between two ASCII digits
/// (TOML's rule). Any stray underscore is an error.
fn strip_underscores(raw: &str, line: usize) -> Result<String, TomlError> {
    let chars: Vec<char> = raw.chars().collect();
    let mut out = String::with_capacity(chars.len());
    for (i, &c) in chars.iter().enumerate() {
        if c == '_' {
            let prev_digit = i > 0 && chars[i - 1].is_ascii_digit();
            let next_digit = chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
            if !(prev_digit && next_digit) {
                return Err(TomlError::new(
                    format!("misplaced '_' in number '{raw}'"),
                    line,
                ));
            }
        } else {
            out.push(c);
        }
    }
    Ok(out)
}