use std::ops::Range;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LineState {
#[default]
Prose,
Cola,
Code,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TokenKind {
Heading,
Fence,
CodeLine,
Keyword,
Boolean,
Identifier,
Number,
String,
Punctuation,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Token {
pub range: Range<usize>,
pub kind: TokenKind,
}
pub fn line_tokens(line: &str, state: LineState) -> (Vec<Token>, LineState) {
let trimmed = line.trim_end_matches('\n');
let fence = trimmed.trim_start();
match state {
LineState::Prose => {
if let Some(info) = fence.strip_prefix("```") {
let next = if info.trim() == "cola" {
LineState::Cola
} else {
LineState::Code
};
(vec![whole(line, TokenKind::Fence)], next)
} else if fence.starts_with('#') {
(vec![whole(line, TokenKind::Heading)], LineState::Prose)
} else {
(Vec::new(), LineState::Prose)
}
}
LineState::Code => {
if fence.starts_with("```") {
(vec![whole(line, TokenKind::Fence)], LineState::Prose)
} else {
(vec![whole(line, TokenKind::CodeLine)], LineState::Code)
}
}
LineState::Cola => {
if fence.starts_with("```") {
return (vec![whole(line, TokenKind::Fence)], LineState::Prose);
}
(cola_tokens(line), LineState::Cola)
}
}
}
fn whole(line: &str, kind: TokenKind) -> Token {
Token {
range: 0..line.trim_end_matches('\n').len(),
kind,
}
}
fn cola_tokens(line: &str) -> Vec<Token> {
let b = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
let c = b[i];
if c == b'"' || c == b'\'' {
let quote = c;
let start = i;
i += 1;
while i < b.len() && b[i] != quote {
i += if b[i] == b'\\' { 2 } else { 1 };
}
i = (i + 1).min(b.len());
out.push(Token {
range: start..i,
kind: TokenKind::String,
});
continue;
}
if c.is_ascii_alphabetic() || c == b'_' {
let start = i;
while i < b.len()
&& (b[i].is_ascii_alphanumeric() || matches!(b[i], b'_' | b'.' | b'-'))
{
i += 1;
}
let word = &line[start..i];
let kind = match word {
"plural" => TokenKind::Keyword,
"true" | "false" => TokenKind::Boolean,
_ => TokenKind::Identifier,
};
out.push(Token {
range: start..i,
kind,
});
continue;
}
if c.is_ascii_digit()
|| ((c == b'+' || c == b'-') && b.get(i + 1).is_some_and(u8::is_ascii_digit))
{
let start = i;
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
out.push(Token {
range: start..i,
kind: TokenKind::Number,
});
continue;
}
if matches!(c, b':' | b',' | b';') {
out.push(Token {
range: i..i + 1,
kind: TokenKind::Punctuation,
});
i += 1;
continue;
}
i += line[i..].chars().next().map(char::len_utf8).unwrap_or(1);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn run(doc: &str) -> Vec<(String, TokenKind)> {
let mut state = LineState::default();
let mut out = Vec::new();
for line in doc.split_inclusive('\n') {
let (toks, next) = line_tokens(line, state);
state = next;
for t in toks {
out.push((line[t.range.clone()].to_string(), t.kind));
}
}
out
}
#[test]
fn literate_structure_and_cola_entities_lex_by_region() {
use TokenKind::*;
let doc = "# Domain\n\nProse explaining things.\n\n```cola\nusers plural user:\n name: \"Alice\", age: 42, active: true;\n```\n\n```rust\nfn x() {}\n```\n";
let got = run(doc);
assert_eq!(got[0], ("# Domain".into(), Heading));
assert!(!got.iter().any(|(t, _)| t.contains("Prose")));
assert_eq!(got[1], ("```cola".into(), Fence));
assert_eq!(got[2], ("users".into(), Identifier));
assert_eq!(got[3], ("plural".into(), Keyword));
assert!(got.contains(&("\"Alice\"".into(), String)));
assert!(got.contains(&("42".into(), Number)));
assert!(got.contains(&("true".into(), Boolean)));
assert!(got.contains(&("fn x() {}".into(), CodeLine)));
}
#[test]
fn mid_edit_unterminated_string_reaches_eol_and_recovers() {
let (toks, next) = line_tokens("name: \"unterm\n", LineState::Cola);
assert_eq!(toks.last().unwrap().kind, TokenKind::String);
assert_eq!(next, LineState::Cola);
}
#[test]
fn lexer_covers_the_grammars_literal_terminals() {
let grammar = include_str!("grammar/cola.rustemo");
assert!(grammar.contains("PluralKeyword: \"plural\""));
let (t, _) = line_tokens("plural", LineState::Cola);
assert_eq!(t[0].kind, TokenKind::Keyword);
for (lit, kind) in [("true", TokenKind::Boolean), ("false", TokenKind::Boolean)] {
assert!(grammar.contains(&format!("'{lit}'")));
let (t, _) = line_tokens(lit, LineState::Cola);
assert_eq!(t[0].kind, kind, "{lit}");
}
for p in [":", ",", ";"] {
assert!(grammar.contains(&format!("'{p}'")));
let (t, _) = line_tokens(p, LineState::Cola);
assert_eq!(t[0].kind, TokenKind::Punctuation, "{p}");
}
}
}