outl_exec/language.rs
1//! Extract `(language, body)` from a block's raw text.
2//!
3//! A code block in outl is a multi-line bullet whose text *is* the
4//! fenced markdown — opening fence, body, closing fence — preserved
5//! verbatim across parse/render cycles. The block text looks like a
6//! standard CommonMark fence:
7//!
8//! - line 1: triple-backtick + language tag (e.g. `lisp`)
9//! - line 2..N: the body
10//! - last line: triple-backtick closer
11//!
12//! This module is the boundary between "block text as it lives in the
13//! AST" and "what the runtime sees" (just the body, plus the language
14//! tag). Pure functions, no I/O.
15
16/// What [`extract_fence`] returns when the block is a code fence.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FenceParts {
19 /// Info-string after the opening backticks, lower-cased. Examples:
20 /// `"lisp"`, `"python"`, `"js"`. Empty when the user wrote a bare
21 /// `` ``` `` opener without a language tag.
22 pub language: String,
23 /// Body of the fence, without leading/trailing fence lines. The
24 /// final newline is stripped — runtimes get exactly what the user
25 /// typed between the fences.
26 pub body: String,
27}
28
29/// Parse the language tag and body out of a block whose first line
30/// opens a fence. Returns `None` if the text doesn't start with
31/// `` ``` `` — i.e. the block isn't a code block at all.
32///
33/// The opening fence's info-string is taken verbatim up to the first
34/// whitespace, lower-cased. Lines after the closer (if any) are
35/// silently dropped; we don't expect them in our own render path but
36/// outline editors might produce odd things.
37pub fn extract_fence(text: &str) -> Option<FenceParts> {
38 let mut lines = text.split('\n');
39 let first = lines.next()?.trim_start();
40 let after_ticks = first.strip_prefix("```")?;
41
42 // Info-string is the run of non-whitespace chars after ```.
43 let language = after_ticks
44 .split_whitespace()
45 .next()
46 .unwrap_or("")
47 .to_ascii_lowercase();
48
49 let mut body = String::new();
50 let mut first_body_line = true;
51 for line in lines {
52 if line.trim_start().starts_with("```") {
53 break; // closing fence
54 }
55 if !first_body_line {
56 body.push('\n');
57 }
58 body.push_str(line);
59 first_body_line = false;
60 }
61
62 Some(FenceParts { language, body })
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn extracts_simple_lisp_block() {
71 let parts = extract_fence("```lisp\n(+ 1 2)\n```").unwrap();
72 assert_eq!(parts.language, "lisp");
73 assert_eq!(parts.body, "(+ 1 2)");
74 }
75
76 #[test]
77 fn extracts_multi_line_body() {
78 let parts = extract_fence("```python\nfor i in range(3):\n print(i)\n```").unwrap();
79 assert_eq!(parts.language, "python");
80 assert_eq!(parts.body, "for i in range(3):\n print(i)");
81 }
82
83 #[test]
84 fn language_lowercased() {
85 let parts = extract_fence("```LISP\n(+ 1 2)\n```").unwrap();
86 assert_eq!(parts.language, "lisp");
87 }
88
89 #[test]
90 fn empty_language_when_bare_fence() {
91 let parts = extract_fence("```\nplain text\n```").unwrap();
92 assert_eq!(parts.language, "");
93 assert_eq!(parts.body, "plain text");
94 }
95
96 #[test]
97 fn returns_none_for_non_fence_block() {
98 assert!(extract_fence("just regular text").is_none());
99 assert!(extract_fence("- bullet content").is_none());
100 }
101
102 #[test]
103 fn handles_info_string_with_extra_attrs() {
104 // CommonMark allows ```lang attrs — we only care about the lang.
105 let parts = extract_fence("```python {.numberLines}\nprint(1)\n```").unwrap();
106 assert_eq!(parts.language, "python");
107 }
108
109 #[test]
110 fn missing_closer_is_tolerated() {
111 // If the closing fence got lost somehow, body is whatever's left.
112 let parts = extract_fence("```lisp\n(+ 1 2)\n").unwrap();
113 assert_eq!(parts.body, "(+ 1 2)\n");
114 }
115}