oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1//! The Markdown subset the operations accept text in. Deliberately
2//! small: this is how an agent or a script says "bold this word", not a
3//! document format.
4//!
5//! - Paragraphs split on blank lines; the lines of one paragraph join
6//! with a space.
7//! - A line starting `- ` or `1. ` (any digits) is its own paragraph with
8//! the marker stripped — a `list` block is one paragraph per item.
9//! - Inline: `**bold**`, `*italic*`, `` `code` ``, `==highlight==`,
10//! `[text](url)`. A delimiter with no closing partner is literal text.
11//! Inside a code span nothing else is parsed. A link's text may carry
12//! marks; a link inside a link is literal.
13//! - The only escape is `\*`, which yields a literal `*`.
14//!
15//! `parse_lines` is the plain variant for `code` blocks: one paragraph
16//! per line, nothing interpreted, blank lines kept as empty paragraphs.
17
18use crate::schema::{Paragraph, Run};
19
20/// The Markdown subset as paragraphs of runs.
21pub fn parse(input: &str) -> Vec<Paragraph> {
22 let mut sources: Vec<String> = Vec::new();
23 let mut current: Option<String> = None;
24 for line in input.lines() {
25 let line = line.trim();
26 if line.is_empty() {
27 if let Some(c) = current.take() {
28 sources.push(c);
29 }
30 continue;
31 }
32 if let Some(item) = list_item(line) {
33 if let Some(c) = current.take() {
34 sources.push(c);
35 }
36 current = Some(item.to_string());
37 continue;
38 }
39 match current.as_mut() {
40 Some(c) => {
41 c.push(' ');
42 c.push_str(line);
43 }
44 None => current = Some(line.to_string()),
45 }
46 }
47 if let Some(c) = current {
48 sources.push(c);
49 }
50 sources.iter().map(|s| parse_inline(s)).collect()
51}
52
53/// One plain paragraph per line, for `code` blocks.
54pub fn parse_lines(input: &str) -> Vec<Paragraph> {
55 input
56 .lines()
57 .map(|line| {
58 if line.is_empty() {
59 Vec::new()
60 } else {
61 vec![Run {
62 t: line.to_string(),
63 ..Run::default()
64 }]
65 }
66 })
67 .collect()
68}
69
70/// The text after a list marker, if `line` starts with one.
71fn list_item(line: &str) -> Option<&str> {
72 if let Some(rest) = line.strip_prefix("- ") {
73 return Some(rest.trim_start());
74 }
75 let digits = line.bytes().take_while(u8::is_ascii_digit).count();
76 if digits > 0 {
77 if let Some(rest) = line[digits..].strip_prefix(". ") {
78 return Some(rest.trim_start());
79 }
80 }
81 None
82}
83
84#[derive(Default, Clone)]
85struct Marks {
86 b: bool,
87 i: bool,
88 c: bool,
89 h: bool,
90 href: Option<String>,
91}
92
93struct Parser {
94 runs: Paragraph,
95 buf: String,
96 marks: Marks,
97}
98
99impl Parser {
100 fn flush(&mut self) {
101 if self.buf.is_empty() {
102 return;
103 }
104 let run = Run {
105 t: std::mem::take(&mut self.buf),
106 b: self.marks.b,
107 i: self.marks.i,
108 c: self.marks.c,
109 h: self.marks.h,
110 href: self.marks.href.clone(),
111 };
112 match self.runs.last_mut() {
113 Some(last) if last.same_marks(&run) => last.t.push_str(&run.t),
114 _ => self.runs.push(run),
115 }
116 }
117
118 /// Toggles a mark on `\*\*`-style delimiters: closes it when open,
119 /// opens it when a closing partner exists later in `rest`, and
120 /// otherwise leaves the delimiter as text. Returns whether the
121 /// delimiter was consumed.
122 fn toggle(
123 &mut self,
124 delim: &str,
125 rest: &str,
126 get: fn(&Marks) -> bool,
127 set: fn(&mut Marks, bool),
128 ) -> bool {
129 if get(&self.marks) {
130 self.flush();
131 set(&mut self.marks, false);
132 return true;
133 }
134 if has_partner(rest, delim) {
135 self.flush();
136 set(&mut self.marks, true);
137 return true;
138 }
139 false
140 }
141
142 fn parse(&mut self, src: &str) {
143 let mut i = 0;
144 while i < src.len() {
145 let rest = &src[i..];
146 if rest.starts_with("\\*") {
147 self.buf.push('*');
148 i += 2;
149 continue;
150 }
151 if let Some(after) = rest.strip_prefix("**") {
152 if self.toggle("**", after, |m| m.b, |m, v| m.b = v) {
153 i += 2;
154 continue;
155 }
156 } else if let Some(after) = rest.strip_prefix("==") {
157 if self.toggle("==", after, |m| m.h, |m, v| m.h = v) {
158 i += 2;
159 continue;
160 }
161 } else if let Some(after) = rest.strip_prefix('*') {
162 if self.toggle("*", after, |m| m.i, |m, v| m.i = v) {
163 i += 1;
164 continue;
165 }
166 } else if let Some(after) = rest.strip_prefix('`') {
167 if let Some(end) = after.find('`') {
168 self.flush();
169 let was = self.marks.c;
170 self.marks.c = true;
171 self.buf.push_str(&after[..end]);
172 self.flush();
173 self.marks.c = was;
174 i += 1 + end + 1;
175 continue;
176 }
177 } else if rest.starts_with('[') && self.marks.href.is_none() {
178 if let Some((text, url, len)) = link(rest) {
179 self.flush();
180 let outer = self.marks.clone();
181 self.marks.href = Some(url.to_string());
182 self.parse(text);
183 self.flush();
184 self.marks = outer;
185 i += len;
186 continue;
187 }
188 }
189 let ch = rest.chars().next().unwrap_or('\0');
190 self.buf.push(ch);
191 i += ch.len_utf8();
192 }
193 }
194}
195
196/// Whether `delim` occurs in `rest` as itself — for a single `*`, one
197/// that is not half of a `**`, so `lone* **bold**` does not open italic
198/// on the bold marker.
199fn has_partner(rest: &str, delim: &str) -> bool {
200 if delim != "*" {
201 return rest.contains(delim);
202 }
203 let bytes = rest.as_bytes();
204 let mut i = 0;
205 while i < bytes.len() {
206 if bytes[i] == b'*' {
207 if i + 1 < bytes.len() && bytes[i + 1] == b'*' {
208 i += 2;
209 continue;
210 }
211 return true;
212 }
213 i += 1;
214 }
215 false
216}
217
218/// `[text](url)` at the start of `s`: the text, the url, and the bytes
219/// the whole link spans.
220fn link(s: &str) -> Option<(&str, &str, usize)> {
221 let close = s.find("](")?;
222 let text = &s[1..close];
223 let url_start = close + 2;
224 let url_end = url_start + s[url_start..].find(')')?;
225 let url = &s[url_start..url_end];
226 if text.is_empty() || url.is_empty() {
227 return None;
228 }
229 Some((text, url, url_end + 1))
230}
231
232fn parse_inline(src: &str) -> Paragraph {
233 let mut p = Parser {
234 runs: Vec::new(),
235 buf: String::new(),
236 marks: Marks::default(),
237 };
238 p.parse(src);
239 p.flush();
240 p.runs
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn plain(t: &str) -> Run {
248 Run {
249 t: t.to_string(),
250 ..Run::default()
251 }
252 }
253
254 #[test]
255 fn paragraphs_split_on_blank_lines_and_list_markers() {
256 let got = parse("a\nb\n\nc\n- d\n1. e\n\n\n");
257 assert_eq!(got.len(), 4);
258 assert_eq!(got[0], vec![plain("a b")]);
259 assert_eq!(got[1], vec![plain("c")]);
260 assert_eq!(got[2], vec![plain("d")]);
261 assert_eq!(got[3], vec![plain("e")]);
262 }
263
264 #[test]
265 fn inline_marks() {
266 let got = parse("**b** *i* `c *x*` ==h== [t](u) \\* lone* **open");
267 assert_eq!(
268 got[0],
269 vec![
270 Run {
271 t: "b".into(),
272 b: true,
273 ..Run::default()
274 },
275 plain(" "),
276 Run {
277 t: "i".into(),
278 i: true,
279 ..Run::default()
280 },
281 plain(" "),
282 Run {
283 t: "c *x*".into(),
284 c: true,
285 ..Run::default()
286 },
287 plain(" "),
288 Run {
289 t: "h".into(),
290 h: true,
291 ..Run::default()
292 },
293 plain(" "),
294 Run {
295 t: "t".into(),
296 href: Some("u".into()),
297 ..Run::default()
298 },
299 plain(" * lone* **open"),
300 ]
301 );
302 }
303
304 #[test]
305 fn marks_nest_and_links_carry_marks() {
306 let got = parse("**a *b* c** [**x**](y)");
307 assert_eq!(
308 got[0],
309 vec![
310 Run {
311 t: "a ".into(),
312 b: true,
313 ..Run::default()
314 },
315 Run {
316 t: "b".into(),
317 b: true,
318 i: true,
319 ..Run::default()
320 },
321 Run {
322 t: " c".into(),
323 b: true,
324 ..Run::default()
325 },
326 plain(" "),
327 Run {
328 t: "x".into(),
329 b: true,
330 href: Some("y".into()),
331 ..Run::default()
332 },
333 ]
334 );
335 }
336
337 #[test]
338 fn empty_input_is_no_paragraphs_and_lines_keep_blanks() {
339 assert!(parse("").is_empty());
340 assert!(parse(" \n\n ").is_empty());
341 let got = parse_lines("x\n\ny");
342 assert_eq!(got, vec![vec![plain("x")], vec![], vec![plain("y")]]);
343 }
344}
345