#![warn(clippy::pedantic)]
use serde::Deserialize;
use serde::Serialize;
use std::{
error::Error,
fmt::{Display, Write},
ops::RangeInclusive,
};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
pub enum RedcellNode {
String(String),
CardSearch {
display: String,
search: String,
},
Saga(Vec<RedcellString>),
LineBreak,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]
pub struct RedcellString {
pub elements: Vec<RedcellNode>,
}
impl RedcellString {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.elements.is_empty()
}
#[must_use]
pub fn to_raw(&self) -> String {
let mut result = String::new();
for el in &self.elements {
match el {
RedcellNode::String(string) => result += string,
RedcellNode::CardSearch { display, .. } => result += display,
RedcellNode::Saga(rich_strings) => {
for el in rich_strings {
writeln!(&mut result, "+ {}", el.to_raw())
.expect("Write impl on strings is infallible.");
}
}
RedcellNode::LineBreak => result.push('\n'),
}
}
result
}
#[must_use]
pub fn to_redcell(&self) -> String {
let mut result = String::new();
for el in &self.elements {
match el {
RedcellNode::String(string) => result += string,
RedcellNode::CardSearch { display, search } => {
write!(&mut result, "[{display}]({search})")
.expect("Write impl on strings is infallible.");
}
RedcellNode::Saga(rich_strings) => {
for el in rich_strings {
writeln!(&mut result, "+ {}", el.to_redcell())
.expect("Write impl on strings is infallible.");
}
}
RedcellNode::LineBreak => result.push('\n'),
}
}
result
}
}
#[derive(Debug)]
pub enum ParseErr {
IncompleteHemolinkBody { starts_at: usize },
IncompleteHemolinkLink { starts_at: usize },
HemolinkMissingLink { range: RangeInclusive<usize> },
EscapedWrong { escaped: Option<char> },
}
impl Display for ParseErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IncompleteHemolinkBody { starts_at } => {
write!(f, "Hemolink body was ended prematurely at byte {starts_at}")
}
Self::IncompleteHemolinkLink { starts_at } => {
write!(f, "Hemolink link was ended prematurely at byte {starts_at}")
}
Self::HemolinkMissingLink { range } => {
write!(
f,
"Hemolink link was ended without a link at bytes {}..={}",
range.start(),
range.end()
)
}
Self::EscapedWrong { escaped } => match escaped {
Some(char) => write!(f, "Tried to escape a `{char:?}` character."),
None => write!(f, "Expecting escaped character, but file ended."),
},
}
}
}
impl Error for ParseErr {}
#[derive(Default)]
enum ParserState {
#[default]
String,
HemolinkBody { starts_at: usize },
HemolinkAwaitLink {
display: String,
display_at: RangeInclusive<usize>,
},
HemolinkLink { display: String, starts_at: usize },
}
#[derive(Default)]
struct Parser {
main_reg: Vec<RedcellNode>,
side_reg: Vec<RedcellNode>,
escape: bool,
current_string: String,
in_saga_line: bool,
state: ParserState,
}
impl Parser {
fn end_line(mut self, eof: bool) -> Self {
if !self.current_string.is_empty() {
self.main_reg.push(RedcellNode::String(self.current_string));
self.current_string = String::new();
}
if self.in_saga_line {
std::mem::swap(&mut self.main_reg, &mut self.side_reg);
self.in_saga_line = false;
if let Some(RedcellNode::String(thing)) = self.side_reg.first_mut() {
*thing = thing.trim_start().to_string();
}
if let Some(RedcellNode::Saga(ongoing)) = self.main_reg.last_mut() {
ongoing.push(RedcellString {
elements: self.side_reg,
});
} else {
self.main_reg.push(RedcellNode::Saga(vec![RedcellString {
elements: self.side_reg,
}]));
}
self.side_reg = vec![];
} else if !eof {
self.main_reg.push(RedcellNode::LineBreak);
}
self
}
}
#[allow(clippy::too_many_lines)]
pub fn parse(text: &str) -> Result<RedcellString, ParseErr> {
let text = text.char_indices().filter(|(_, x)| *x != '\r');
let mut parser = Parser::default();
for (idx, char) in text {
if char == '\r' {
continue;
}
match parser.state {
ParserState::String => match char {
'[' if !parser.escape => {
if !parser.current_string.is_empty() {
parser
.main_reg
.push(RedcellNode::String(parser.current_string));
parser.current_string = String::new();
}
parser.state = ParserState::HemolinkBody { starts_at: idx };
}
'\\' if !parser.escape => {
parser.escape = true;
}
'\n' => {
if parser.escape {
return Err(ParseErr::EscapedWrong {
escaped: Some('\n'),
});
}
parser = parser.end_line(false);
}
'+' if !parser.escape && !parser.in_saga_line => {
if parser.current_string.trim().is_empty()
&& parser.main_reg.last().is_none_or(|x| {
matches!(x, RedcellNode::LineBreak | RedcellNode::Saga(_))
})
{
parser.current_string = String::new();
std::mem::swap(&mut parser.main_reg, &mut parser.side_reg);
parser.in_saga_line = true;
} else {
parser.current_string.push(char);
parser.escape = false;
}
}
_ => {
parser.current_string.push(char);
parser.escape = false;
}
},
ParserState::HemolinkBody { starts_at } => match char {
']' if !parser.escape => {
parser.state = ParserState::HemolinkAwaitLink {
display: parser.current_string,
display_at: starts_at..=idx,
};
parser.current_string = String::new();
}
'\\' if !parser.escape => {
parser.escape = true;
}
_ => {
parser.current_string.push(char);
parser.escape = false;
}
},
ParserState::HemolinkAwaitLink { ref display, .. } => match char {
'(' => {
parser.state = ParserState::HemolinkLink {
display: display.clone(),
starts_at: idx,
}
}
char if char.is_whitespace() => (),
_ => (),
},
ParserState::HemolinkLink { ref display, .. } => match char {
')' if !parser.escape => {
parser.main_reg.push(RedcellNode::CardSearch {
display: display.clone(),
search: parser.current_string.trim().to_string(),
});
parser.current_string = String::new();
parser.state = ParserState::String;
}
'\\' if !parser.escape => {
parser.escape = true;
}
char => {
parser.current_string.push(char);
parser.escape = false;
}
},
}
}
if parser.escape {
return Err(ParseErr::EscapedWrong { escaped: None });
}
match parser.state {
ParserState::String => parser = parser.end_line(true),
ParserState::HemolinkBody { starts_at } => {
return Err(ParseErr::IncompleteHemolinkBody { starts_at });
}
ParserState::HemolinkAwaitLink { display_at, .. } => {
return Err(ParseErr::HemolinkMissingLink { range: display_at });
}
ParserState::HemolinkLink { starts_at, .. } => {
return Err(ParseErr::IncompleteHemolinkLink { starts_at });
}
}
Ok(RedcellString {
elements: parser.main_reg,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_line() {
let input = "this is an example text.";
let expected = RedcellString {
elements: vec![RedcellNode::String(input.to_string())],
};
assert_eq!(expected, parse(input).unwrap(), "Unix version");
assert_eq!(
expected,
parse(&input.replace('\n', "\n\r")).unwrap(),
"Windows version"
);
assert_eq!(expected.to_redcell(), input);
}
#[test]
fn multiline() {
let input = "this is an example text.\nwith multiple lines.";
let expected = RedcellString {
elements: vec![
RedcellNode::String("this is an example text.".to_string()),
RedcellNode::LineBreak,
RedcellNode::String("with multiple lines.".to_string()),
],
};
assert_eq!(expected, parse(input).unwrap(), "Unix version");
assert_eq!(
expected,
parse(&input.replace('\n', "\n\r")).unwrap(),
"Windows version"
);
assert_eq!(expected.to_redcell(), input);
}
#[test]
fn hemolink() {
let input = "this has a [hemolink](thing) in the middle";
let expected = RedcellString {
elements: vec![
RedcellNode::String("this has a ".to_string()),
RedcellNode::CardSearch {
display: "hemolink".to_string(),
search: "thing".to_string(),
},
RedcellNode::String(" in the middle".to_string()),
],
};
assert_eq!(expected, parse(input).unwrap(), "Unix version");
assert_eq!(
expected,
parse(&input.replace('\n', "\n\r")).unwrap(),
"Windows version"
);
assert_eq!(expected.to_redcell(), input);
}
#[test]
fn hemolink_in_newline() {
let input = "this has a line.\nand a newline with a [hemolink](thing).";
let expected = RedcellString {
elements: vec![
RedcellNode::String("this has a line.".to_string()),
RedcellNode::LineBreak,
RedcellNode::String("and a newline with a ".to_string()),
RedcellNode::CardSearch {
display: "hemolink".to_string(),
search: "thing".to_string(),
},
RedcellNode::String(".".to_string()),
],
};
assert_eq!(expected, parse(input).unwrap(), "Unix version");
assert_eq!(
expected,
parse(&input.replace('\n', "\n\r")).unwrap(),
"Windows version"
);
assert_eq!(expected.to_redcell(), input);
}
#[test]
fn saga() {
let input = "this has a line\n+ and a saga\n+ with stuff\n";
let expected = RedcellString {
elements: vec![
RedcellNode::String("this has a line".to_string()),
RedcellNode::LineBreak,
RedcellNode::Saga(vec![
RedcellString {
elements: vec![RedcellNode::String("and a saga".to_string())],
},
RedcellString {
elements: vec![RedcellNode::String("with stuff".to_string())],
},
]),
],
};
assert_eq!(expected, parse(input).unwrap(), "Unix version");
assert_eq!(
expected,
parse(&input.replace('\n', "\n\r")).unwrap(),
"Windows version"
);
assert_eq!(expected.to_redcell(), input);
}
}