1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub enum ShortcodeTokenType {
15 Fence,
16 Name,
17 Number,
18 Ratio,
19 BraceOpen,
20 BraceClose,
21 ClassName,
22 Divider,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ShortcodeToken {
27 #[serde(rename = "type")]
28 pub token_type: ShortcodeTokenType,
29 pub from: usize,
30 pub to: usize,
31}
32
33pub fn tokenize_opening_line(line: &str) -> Vec<ShortcodeToken> {
39 let bytes = line.as_bytes();
40 let len = bytes.len();
41 let mut pos = skip_whitespace(bytes, 0);
42 let mut tokens = Vec::new();
43
44 if !bytes[pos..].starts_with(b":::") {
46 return tokens;
47 }
48 tokens.push(ShortcodeToken {
49 token_type: ShortcodeTokenType::Fence,
50 from: pos,
51 to: pos + 3,
52 });
53 pos += 3;
54
55 if pos < len && is_name_start(bytes[pos]) {
57 let start = pos;
58 pos += 1;
59 while pos < len && is_word_char(bytes[pos]) {
60 pos += 1;
61 }
62 tokens.push(ShortcodeToken {
63 token_type: ShortcodeTokenType::Name,
64 from: start,
65 to: pos,
66 });
67 }
68
69 while pos < len {
71 let new_pos = skip_whitespace(bytes, pos);
73 if new_pos >= len {
74 break;
75 }
76 pos = new_pos;
77
78 if let Some(end) = try_ratio(bytes, pos) {
80 tokens.push(ShortcodeToken {
81 token_type: ShortcodeTokenType::Ratio,
82 from: pos,
83 to: end,
84 });
85 pos = end;
86 continue;
87 }
88
89 if bytes[pos].is_ascii_digit() {
91 let start = pos;
92 while pos < len && bytes[pos].is_ascii_digit() {
93 pos += 1;
94 }
95 tokens.push(ShortcodeToken {
96 token_type: ShortcodeTokenType::Number,
97 from: start,
98 to: pos,
99 });
100 continue;
101 }
102
103 if bytes[pos] == b'{' {
105 tokens.push(ShortcodeToken {
106 token_type: ShortcodeTokenType::BraceOpen,
107 from: pos,
108 to: pos + 1,
109 });
110 pos += 1;
111 continue;
112 }
113
114 if bytes[pos] == b'.'
116 && pos + 1 < len
117 && is_name_start(bytes[pos + 1])
118 {
119 let start = pos;
120 pos += 2; while pos < len && is_class_char(bytes[pos]) {
122 pos += 1;
123 }
124 tokens.push(ShortcodeToken {
125 token_type: ShortcodeTokenType::ClassName,
126 from: start,
127 to: pos,
128 });
129 continue;
130 }
131
132 if bytes[pos] == b'}' {
134 tokens.push(ShortcodeToken {
135 token_type: ShortcodeTokenType::BraceClose,
136 from: pos,
137 to: pos + 1,
138 });
139 pos += 1;
140 continue;
141 }
142
143 pos += 1;
145 }
146
147 tokens
148}
149
150pub fn tokenize_closing_line(line: &str) -> Vec<ShortcodeToken> {
152 let bytes = line.as_bytes();
153 let pos = skip_whitespace(bytes, 0);
154 let mut tokens = Vec::new();
155
156 if bytes[pos..].starts_with(b":::") {
157 tokens.push(ShortcodeToken {
158 token_type: ShortcodeTokenType::Fence,
159 from: pos,
160 to: pos + 3,
161 });
162 }
163
164 tokens
165}
166
167pub fn tokenize_divider_line(line: &str) -> Vec<ShortcodeToken> {
169 let bytes = line.as_bytes();
170 let pos = skip_whitespace(bytes, 0);
171 let mut tokens = Vec::new();
172
173 if bytes[pos..].starts_with(b"+++") || bytes[pos..].starts_with(b"---") {
174 tokens.push(ShortcodeToken {
175 token_type: ShortcodeTokenType::Divider,
176 from: pos,
177 to: pos + 3,
178 });
179 }
180
181 tokens
182}
183
184pub fn tokens_to_html(line: &str, tokens: &[ShortcodeToken]) -> String {
193 let mut out = String::with_capacity(line.len() * 2);
194 let mut cursor = 0;
195
196 for tok in tokens {
197 if tok.from > cursor {
199 #[allow(clippy::string_slice)]
204 html_escape_into(&line[cursor..tok.from], &mut out);
205 }
206 let class = css_class(tok.token_type);
207 out.push_str("<span class=\"");
208 out.push_str(class);
209 out.push_str("\">");
210 #[allow(clippy::string_slice)]
213 html_escape_into(&line[tok.from..tok.to], &mut out);
214 out.push_str("</span>");
215 cursor = tok.to;
216 }
217
218 if cursor < line.len() {
220 #[allow(clippy::string_slice)]
223 html_escape_into(&line[cursor..], &mut out);
224 }
225
226 out
227}
228
229fn skip_whitespace(bytes: &[u8], mut pos: usize) -> usize {
234 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
235 pos += 1;
236 }
237 pos
238}
239
240fn is_name_start(b: u8) -> bool {
241 b.is_ascii_alphabetic() || b == b'_'
242}
243
244fn is_word_char(b: u8) -> bool {
245 b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
246}
247
248fn is_class_char(b: u8) -> bool {
250 is_word_char(b) || b == b'-'
251}
252
253fn try_ratio(bytes: &[u8], pos: usize) -> Option<usize> {
256 let len = bytes.len();
257 if pos >= len || !bytes[pos].is_ascii_digit() {
258 return None;
259 }
260
261 let mut i = pos;
263 while i < len && bytes[i].is_ascii_digit() {
264 i += 1;
265 }
266
267 if i >= len || bytes[i] != b':' {
269 return None;
270 }
271 i += 1;
272
273 if i >= len || !bytes[i].is_ascii_digit() {
275 return None;
276 }
277 while i < len && bytes[i].is_ascii_digit() {
278 i += 1;
279 }
280
281 Some(i)
282}
283
284fn css_class(tt: ShortcodeTokenType) -> &'static str {
285 match tt {
286 ShortcodeTokenType::Fence => "hl-punct",
287 ShortcodeTokenType::Name => "hl-tag",
288 ShortcodeTokenType::Number => "hl-attr",
289 ShortcodeTokenType::Ratio => "hl-val",
290 ShortcodeTokenType::BraceOpen => "hl-brace",
291 ShortcodeTokenType::BraceClose => "hl-brace",
292 ShortcodeTokenType::ClassName => "hl-val",
293 ShortcodeTokenType::Divider => "hl-punct",
294 }
295}
296
297fn html_escape_into(s: &str, out: &mut String) {
298 for ch in s.chars() {
299 match ch {
300 '&' => out.push_str("&"),
301 '<' => out.push_str("<"),
302 '>' => out.push_str(">"),
303 '"' => out.push_str("""),
304 _ => out.push(ch),
305 }
306 }
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[derive(Deserialize)]
319 struct Fixture {
320 description: String,
321 input: String,
322 kind: String,
323 expected: Vec<ShortcodeToken>,
324 }
325
326 const FIXTURES: &str = include_str!("../../../tests/fixtures/shortcode-tokens.json");
327
328 #[test]
329 fn fixture_driven_tests() {
330 let fixtures: Vec<Fixture> =
331 serde_json::from_str(FIXTURES).expect("failed to parse fixtures JSON");
332
333 for fixture in &fixtures {
334 let tokens = match fixture.kind.as_str() {
335 "opening" => tokenize_opening_line(&fixture.input),
336 "closing" => tokenize_closing_line(&fixture.input),
337 "divider" => tokenize_divider_line(&fixture.input),
338 other => panic!("unknown kind {:?} in fixture {:?}", other, fixture.description),
339 };
340
341 assert_eq!(
342 tokens, fixture.expected,
343 "FAILED: {}\n input: {:?}\n got: {:?}\n expected: {:?}",
344 fixture.description, fixture.input, tokens, fixture.expected,
345 );
346 }
347 }
348
349 #[test]
350 fn tokens_to_html_basic() {
351 let line = ":::grid 3";
352 let tokens = tokenize_opening_line(line);
353 let html = tokens_to_html(line, &tokens);
354 assert_eq!(
355 html,
356 "<span class=\"hl-punct\">:::</span>\
357 <span class=\"hl-tag\">grid</span> \
358 <span class=\"hl-attr\">3</span>"
359 );
360 }
361
362 #[test]
363 fn tokens_to_html_escapes_special_chars() {
364 let line = ":::tag <>&\"";
366 let tokens = tokenize_opening_line(line);
367 let html = tokens_to_html(line, &tokens);
368 assert!(html.contains("<>&""), "html was: {html}");
371 }
372
373 #[test]
374 fn tokens_to_html_closing() {
375 let line = " :::";
376 let tokens = tokenize_closing_line(line);
377 let html = tokens_to_html(line, &tokens);
378 assert_eq!(html, " <span class=\"hl-punct\">:::</span>");
379 }
380
381 #[test]
382 fn tokens_to_html_divider() {
383 let line = "---";
384 let tokens = tokenize_divider_line(line);
385 let html = tokens_to_html(line, &tokens);
386 assert_eq!(html, "<span class=\"hl-punct\">---</span>");
387 }
388}