sillycode/
parser.rs

1use std::fmt;
2use strum::IntoEnumIterator;
3use strum_macros::EnumIter;
4
5/// Styling options for text formatting.
6#[derive(EnumIter, Debug, Clone, Copy, PartialEq)]
7pub enum StyleKind {
8  /// Bold text `[b]` - renders as `<strong>`
9  Bold,
10  /// Italic text `[i]` - renders as `<em>`
11  Italic,
12  /// Underlined text `[u]` - renders as `<ins>`
13  Underline,
14  /// Strikethrough text `[s]` - renders as `<del>`
15  Strikethrough,
16  /// Link `[url]` - renders as `<a href="...">`
17  Link,
18}
19
20impl StyleKind {
21
22  /// Returns the sillycode tag name for this style.
23  pub const fn to_tag(&self) -> &str {
24    match self {
25      StyleKind::Bold => "b",
26      StyleKind::Italic => "i",
27      StyleKind::Underline => "u",
28      StyleKind::Strikethrough => "s",
29      StyleKind::Link => "url",
30    }
31  }
32
33}
34
35/// Emoticon types supported by sillycode.
36#[derive(EnumIter, Debug, Clone, Copy, PartialEq)]
37pub enum EmoteKind {
38  /// Smiley face `[:)]` - renders as:
39  /// ![](https://sillypost.net/static/emoticons/smile.png)
40  Smile,
41  /// Sad face `[:(`] - renders as:
42  /// ![](https://sillypost.net/static/emoticons/sad.png)
43  Sad,
44  /// Big smile `[:D]` - renders as:
45  /// ![](https://sillypost.net/static/emoticons/colond.png)
46  ColonD,
47  /// Colon-three face `[:3]` - renders as:
48  /// ![](https://sillypost.net/static/emoticons/colonthree.png)
49  ColonThree,
50  /// Fearful face `[D:]` - renders as:
51  /// ![](https://sillypost.net/static/emoticons/fearful.png)
52  Fearful,
53  /// Sunglasses `[B)]` - renders as:
54  /// ![](https://sillypost.net/static/emoticons/sunglasses.png)
55  Sunglasses,
56  /// Crying face `[;(`] - renders as:
57  /// ![](https://sillypost.net/static/emoticons/crying.png)
58  Crying,
59  /// Winking face `[;)]` - renders as:
60  /// ![](https://sillypost.net/static/emoticons/winking.png)
61  Winking,
62}
63
64impl EmoteKind {
65
66  /// Returns the sillycode tag for this emoticon.
67  pub const fn to_tag(&self) -> &str {
68    match self {
69      EmoteKind::Smile => ":)",
70      EmoteKind::Sad => ":(",
71      EmoteKind::ColonD => ":D",
72      EmoteKind::ColonThree => ":3",
73      EmoteKind::Fearful => "D:",
74      EmoteKind::Sunglasses => "B)",
75      EmoteKind::Crying => ";(",
76      EmoteKind::Winking => ";)",
77    }
78  }
79
80  /// Returns the file name for this emoticon without extension.
81  pub const fn to_name(&self) -> &str {
82    match self {
83      EmoteKind::Smile => "smile",
84      EmoteKind::Sad => "sad",
85      EmoteKind::ColonD => "colond",
86      EmoteKind::ColonThree => "colonthree",
87      EmoteKind::Fearful => "fearful",
88      EmoteKind::Sunglasses => "sunglasses",
89      EmoteKind::Crying => "crying",
90      EmoteKind::Winking => "winking",
91    }
92  }
93
94}
95
96/// RGB color value with 8-bit components.
97#[derive(Default, Debug, Clone, Copy, PartialEq)]
98pub struct Color {
99  /// Red component (0-255).
100  pub r: u8,
101  /// Green component (0-255).
102  pub g: u8,
103  /// Blue component (0-255).
104  pub b: u8,
105}
106
107impl Color {
108
109  /// Creates a new color from RGB components.
110  pub const fn new(r: u8, g: u8, b: u8) -> Self {
111    Self { r, g, b }
112  }
113
114}
115
116impl fmt::Display for Color {
117
118  /// Formats the color as a hexadecimal string like `"#ad77f1"`.
119  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120    write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
121  }
122
123}
124
125/// A single element of parsed sillycode markup.
126#[derive(Debug, Clone, PartialEq)]
127pub enum Part {
128  /// Plain text content.
129  Text(String),
130  /// Escape backslash character.
131  Escape,
132  /// Line break character.
133  Newline,
134  /// Style formatting toggle, enable or disable, each effect is independent.
135  Style(StyleKind, bool),
136  /// Color formatting toggle, enable or disable, acts as a stack.
137  Color(Color, bool),
138  /// Emoticon image.
139  Emote(EmoteKind),
140}
141
142impl Part {
143
144  /// parses a style tag body like "b" or "/url"
145  fn parse_style_tag(mut body: &str) -> Option<Self> {
146    let mut enable = true;
147
148    // check if the tag is closing
149    if body.starts_with('/') {
150      enable = false;
151      body = &body[1..];
152    }
153
154    for style in StyleKind::iter() {
155      if body == style.to_tag() {
156        return Some(Self::Style(style, enable));
157      }
158    }
159
160    None
161  }
162
163  /// parses an emote tag body like ":)"
164  fn parse_emote_tag(body: &str) -> Option<Self> {
165    for emote in EmoteKind::iter() {
166      if body == emote.to_tag() {
167        return Some(Self::Emote(emote));
168      }
169    }
170
171    None
172  }
173
174  /// parses a color tag body like "color=#ad77f1"
175  fn parse_color_tag(body: &str) -> Option<Self> {
176    if body.len() == 13 && body.starts_with("color=#") {
177      let r = u8::from_str_radix(&body[ 7.. 9], 16).ok()?;
178      let g = u8::from_str_radix(&body[ 9..11], 16).ok()?;
179      let b = u8::from_str_radix(&body[11..13], 16).ok()?;
180      Some(Self::Color(Color::new(r, g, b), true))
181    } else if body == "/color" {
182      Some(Self::Color(Color::default(), false))
183    } else {
184      None
185    }
186  }
187
188  /// parses any tag body
189  fn parse_tag(body: &str) -> Option<Self> {
190    if body.is_empty() || body.len() > 32 {
191      return None;
192    }
193
194    Self::parse_style_tag(body)
195      .or_else(|| Self::parse_emote_tag(body))
196      .or_else(|| Self::parse_color_tag(body))
197  }
198
199}
200
201impl fmt::Display for Part {
202
203  /// Formats the part back to sillycode markup.
204  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205    match self {
206      Part::Text(text) => write!(f, "{text}"),
207      Part::Escape => write!(f, "\\"),
208      Part::Newline => write!(f, "\n"),
209      Part::Style(style, enable) => {
210        if *enable {
211          write!(f, "[{}]", style.to_tag())
212        } else {
213          write!(f, "[/{}]", style.to_tag())
214        }
215      }
216      Part::Color(color, enable) => {
217        if *enable {
218          write!(f, "[color={}]", color)
219        } else {
220          write!(f, "[/color]")
221        }
222      }
223      Part::Emote(emote) => write!(f, "[{}]", emote.to_tag()),
224    }
225  }
226
227}
228
229/// parser for sillycode markup
230#[derive(Default, Debug)]
231struct Parser {
232  /// output parts
233  parts: Vec<Part>,
234  /// buffer for text parts
235  buffer: String,
236  /// whether the previous character was an escape
237  escape: bool,
238}
239
240impl Parser {
241
242  /// creates a new parser
243  fn new() -> Self {
244    Self::default()
245  }
246
247  /// emits a new part
248  fn emit(&mut self, part: Part) {
249    self.parts.push(part);
250  }
251
252  /// flushes the buffer as a text part if it's not empty
253  fn flush(&mut self) {
254    if !self.buffer.is_empty() {
255      self.emit(Part::Text(self.buffer.clone()));
256      self.buffer.clear();
257    }
258  }
259
260  /// attempts to parse a tag at the current position
261  fn tag(&mut self) -> bool {
262    // find the last opening bracket
263    let index = match self.buffer.rfind('[') {
264        Some(index) => index,
265        None => return false,
266    };
267
268    // detect escape
269    if index == 0 && matches!(self.parts.last(), Some(Part::Escape)) {
270      return false;
271    }
272
273    // extract the tag body
274    let body = &self.buffer[index+1..];
275
276    // parse the tag
277    let part = Part::parse_tag(body);
278
279    // if we parsed a tag
280    if let Some(part) = part {
281      // remove the tag from the buffer
282      self.buffer.drain(index..);
283      // emit both the remaining buffer and the parsed part
284      self.flush();
285      self.emit(part);
286      // success!
287      true
288    } else {
289      // we did not parse a tag :<
290      false
291    }
292  }
293
294  /// parses sillycode markup
295  fn parse(mut self, input: &str) -> Vec<Part> {
296    // main parsing loop
297    for char in input.chars() {
298      // if we are not escaping
299      if !self.escape {
300        // check for escape
301        if char == '\\' {
302          self.escape = true;
303          self.flush();
304          self.emit(Part::Escape);
305          continue;
306        }
307        // check for tag close
308        if char == ']' {
309          if self.tag() {
310            continue;
311          }
312        }
313      }
314
315      // make sure to reset the escape flag
316      self.escape = false;
317
318      // check for newline
319      if char == '\n' {
320        self.flush();
321        self.emit(Part::Newline);
322        continue;
323      }
324
325      // collect normal characters in the buffer
326      self.buffer.push(char);
327    }
328
329    // flush any remaining text
330    self.flush();
331
332    // we are done :3
333    self.parts
334  }
335
336}
337
338/// Parses sillycode markup into a list of parts.
339pub fn parse(input: &str) -> Vec<Part> {
340  Parser::new().parse(input)
341}
342
343/// Calculates the display length of parsed parts.
344pub fn length(parts: &[Part]) -> usize {
345  parts.iter().fold(0, |acc, part| {
346    match part {
347      Part::Text(text) => acc + text.chars().count(),
348      Part::Newline | Part::Emote(_) => acc + 1,
349      _ => acc,
350    }
351  })
352}