Skip to main content

command_stream/zx/
md.rs

1//! Markdown to script transform (zx `transformMarkdown`).
2//!
3//! Prose becomes `// ` comments, indented blocks and js/ts fences are kept as
4//! code, sh/bash/shell fences become an awaited `$` template and any other
5//! fence is commented out.
6
7use 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: &regex::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
117/// Split on every ECMAScript line terminator (`\r\n`, `\n`, `\r`, U+2028,
118/// U+2029).
119fn 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
138/// Turn a markdown document into an executable zx script.
139pub 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}