1use once_cell::sync::Lazy;
8use regex::Regex;
9
10static TAB_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^( {2,}|\t)").expect("valid regex"));
11static FENCE_RE: Lazy<Regex> = Lazy::new(|| {
12 Regex::new(
13 r"^(?P<indent> {0,3})(?P<fence>`{3,20}|~{3,20})(?:(?P<js>js|javascript|ts|typescript)|(?P<bash>sh|shell|bash)|.*)$",
14 )
15 .expect("valid regex")
16});
17
18struct Fence {
19 prefix: &'static str,
20 close: &'static str,
21 indent: usize,
22 end: Regex,
23}
24
25#[derive(PartialEq, Eq)]
26enum State {
27 Root,
28 Tab,
29 Fence,
30}
31
32struct Script {
33 out: Vec<String>,
34 state: State,
35 prev_empty: bool,
36 fence: Option<Fence>,
37}
38
39impl Script {
40 fn open_fence(&mut self, caps: ®ex::Captures<'_>) {
41 let fence = &caps["fence"];
42 let marker = regex::escape(&fence[..1]);
43 let (open, prefix, close) = if caps.name("js").is_some() {
44 ("", "", "")
45 } else if caps.name("bash").is_some() {
46 ("await $`", "", "`")
47 } else {
48 ("", "// ", "")
49 };
50 let end = format!(r"^ {{0,3}}{marker}{{{},}}[ \t]*$", fence.len());
51 self.fence = Some(Fence {
52 prefix,
53 close,
54 indent: caps["indent"].len(),
55 end: Regex::new(&end).expect("valid fence regex"),
56 });
57 self.out.push(open.to_string());
58 self.state = State::Fence;
59 self.prev_empty = false;
60 }
61
62 fn root(&mut self, line: &str) {
63 if let Some(caps) = FENCE_RE.captures(line) {
64 self.open_fence(&caps);
65 return;
66 }
67 if self.prev_empty && TAB_RE.is_match(line) {
68 self.out.push(line.to_string());
69 self.state = State::Tab;
70 return;
71 }
72 self.prev_empty = line.is_empty();
73 self.out.push(format!("// {line}"));
74 }
75
76 fn tab(&mut self, line: &str) {
77 if line.is_empty() || TAB_RE.is_match(line) {
78 self.out.push(line.to_string());
79 } else {
80 self.out.push(format!("// {line}"));
81 self.state = State::Root;
82 }
83 self.prev_empty = line.is_empty();
84 }
85
86 fn in_fence(&mut self, line: &str) {
87 let Some(fence) = &self.fence else {
88 self.state = State::Root;
89 return self.root(line);
90 };
91 if fence.end.is_match(line) {
92 self.out.push(fence.close.to_string());
93 self.state = State::Root;
94 self.prev_empty = true;
95 self.fence = None;
96 return;
97 }
98 let spaces = line
99 .bytes()
100 .take(fence.indent)
101 .take_while(|b| *b == b' ')
102 .count();
103 self.out
104 .push(format!("{}{}", fence.prefix, &line[spaces..]));
105 self.prev_empty = false;
106 }
107
108 fn push(&mut self, line: &str) {
109 match self.state {
110 State::Fence => self.in_fence(line),
111 State::Tab => self.tab(line),
112 State::Root => self.root(line),
113 }
114 }
115}
116
117fn split_lines(text: &str) -> Vec<&str> {
120 let mut lines = Vec::new();
121 let mut start = 0;
122 let mut iter = text.char_indices().peekable();
123 while let Some((i, c)) = iter.next() {
124 if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
125 lines.push(&text[start..i]);
126 let mut end = i + c.len_utf8();
127 if c == '\r' && iter.peek().is_some_and(|(_, n)| *n == '\n') {
128 iter.next();
129 end += 1;
130 }
131 start = end;
132 }
133 }
134 lines.push(&text[start..]);
135 lines
136}
137
138pub fn transform_markdown(source: &str) -> String {
140 let mut script = Script {
141 out: Vec::new(),
142 state: State::Root,
143 prev_empty: true,
144 fence: None,
145 };
146 for line in split_lines(source) {
147 script.push(line);
148 }
149 script.out.join("\n")
150}