use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ShortcodeTokenType {
Fence,
Name,
Number,
Ratio,
BraceOpen,
BraceClose,
ClassName,
Divider,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShortcodeToken {
#[serde(rename = "type")]
pub token_type: ShortcodeTokenType,
pub from: usize,
pub to: usize,
}
pub fn tokenize_opening_line(line: &str) -> Vec<ShortcodeToken> {
let bytes = line.as_bytes();
let len = bytes.len();
let mut pos = skip_whitespace(bytes, 0);
let mut tokens = Vec::new();
if !bytes[pos..].starts_with(b":::") {
return tokens;
}
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Fence,
from: pos,
to: pos + 3,
});
pos += 3;
if pos < len && is_name_start(bytes[pos]) {
let start = pos;
pos += 1;
while pos < len && is_word_char(bytes[pos]) {
pos += 1;
}
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Name,
from: start,
to: pos,
});
}
while pos < len {
let new_pos = skip_whitespace(bytes, pos);
if new_pos >= len {
break;
}
pos = new_pos;
if let Some(end) = try_ratio(bytes, pos) {
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Ratio,
from: pos,
to: end,
});
pos = end;
continue;
}
if bytes[pos].is_ascii_digit() {
let start = pos;
while pos < len && bytes[pos].is_ascii_digit() {
pos += 1;
}
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Number,
from: start,
to: pos,
});
continue;
}
if bytes[pos] == b'{' {
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::BraceOpen,
from: pos,
to: pos + 1,
});
pos += 1;
continue;
}
if bytes[pos] == b'.'
&& pos + 1 < len
&& is_name_start(bytes[pos + 1])
{
let start = pos;
pos += 2; while pos < len && is_class_char(bytes[pos]) {
pos += 1;
}
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::ClassName,
from: start,
to: pos,
});
continue;
}
if bytes[pos] == b'}' {
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::BraceClose,
from: pos,
to: pos + 1,
});
pos += 1;
continue;
}
pos += 1;
}
tokens
}
pub fn tokenize_closing_line(line: &str) -> Vec<ShortcodeToken> {
let bytes = line.as_bytes();
let pos = skip_whitespace(bytes, 0);
let mut tokens = Vec::new();
if bytes[pos..].starts_with(b":::") {
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Fence,
from: pos,
to: pos + 3,
});
}
tokens
}
pub fn tokenize_divider_line(line: &str) -> Vec<ShortcodeToken> {
let bytes = line.as_bytes();
let pos = skip_whitespace(bytes, 0);
let mut tokens = Vec::new();
if bytes[pos..].starts_with(b"+++") || bytes[pos..].starts_with(b"---") {
tokens.push(ShortcodeToken {
token_type: ShortcodeTokenType::Divider,
from: pos,
to: pos + 3,
});
}
tokens
}
pub fn tokens_to_html(line: &str, tokens: &[ShortcodeToken]) -> String {
let mut out = String::with_capacity(line.len() * 2);
let mut cursor = 0;
for tok in tokens {
if tok.from > cursor {
#[allow(clippy::string_slice)]
html_escape_into(&line[cursor..tok.from], &mut out);
}
let class = css_class(tok.token_type);
out.push_str("<span class=\"");
out.push_str(class);
out.push_str("\">");
#[allow(clippy::string_slice)]
html_escape_into(&line[tok.from..tok.to], &mut out);
out.push_str("</span>");
cursor = tok.to;
}
if cursor < line.len() {
#[allow(clippy::string_slice)]
html_escape_into(&line[cursor..], &mut out);
}
out
}
fn skip_whitespace(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
pos += 1;
}
pos
}
fn is_name_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
fn is_class_char(b: u8) -> bool {
is_word_char(b) || b == b'-'
}
fn try_ratio(bytes: &[u8], pos: usize) -> Option<usize> {
let len = bytes.len();
if pos >= len || !bytes[pos].is_ascii_digit() {
return None;
}
let mut i = pos;
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= len || bytes[i] != b':' {
return None;
}
i += 1;
if i >= len || !bytes[i].is_ascii_digit() {
return None;
}
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
Some(i)
}
fn css_class(tt: ShortcodeTokenType) -> &'static str {
match tt {
ShortcodeTokenType::Fence => "hl-punct",
ShortcodeTokenType::Name => "hl-tag",
ShortcodeTokenType::Number => "hl-attr",
ShortcodeTokenType::Ratio => "hl-val",
ShortcodeTokenType::BraceOpen => "hl-brace",
ShortcodeTokenType::BraceClose => "hl-brace",
ShortcodeTokenType::ClassName => "hl-val",
ShortcodeTokenType::Divider => "hl-punct",
}
}
fn html_escape_into(s: &str, out: &mut String) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct Fixture {
description: String,
input: String,
kind: String,
expected: Vec<ShortcodeToken>,
}
const FIXTURES: &str = include_str!("../../../tests/fixtures/shortcode-tokens.json");
#[test]
fn fixture_driven_tests() {
let fixtures: Vec<Fixture> =
serde_json::from_str(FIXTURES).expect("failed to parse fixtures JSON");
for fixture in &fixtures {
let tokens = match fixture.kind.as_str() {
"opening" => tokenize_opening_line(&fixture.input),
"closing" => tokenize_closing_line(&fixture.input),
"divider" => tokenize_divider_line(&fixture.input),
other => panic!("unknown kind {:?} in fixture {:?}", other, fixture.description),
};
assert_eq!(
tokens, fixture.expected,
"FAILED: {}\n input: {:?}\n got: {:?}\n expected: {:?}",
fixture.description, fixture.input, tokens, fixture.expected,
);
}
}
#[test]
fn tokens_to_html_basic() {
let line = ":::grid 3";
let tokens = tokenize_opening_line(line);
let html = tokens_to_html(line, &tokens);
assert_eq!(
html,
"<span class=\"hl-punct\">:::</span>\
<span class=\"hl-tag\">grid</span> \
<span class=\"hl-attr\">3</span>"
);
}
#[test]
fn tokens_to_html_escapes_special_chars() {
let line = ":::tag <>&\"";
let tokens = tokenize_opening_line(line);
let html = tokens_to_html(line, &tokens);
assert!(html.contains("<>&""), "html was: {html}");
}
#[test]
fn tokens_to_html_closing() {
let line = " :::";
let tokens = tokenize_closing_line(line);
let html = tokens_to_html(line, &tokens);
assert_eq!(html, " <span class=\"hl-punct\">:::</span>");
}
#[test]
fn tokens_to_html_divider() {
let line = "---";
let tokens = tokenize_divider_line(line);
let html = tokens_to_html(line, &tokens);
assert_eq!(html, "<span class=\"hl-punct\">---</span>");
}
}