Skip to main content

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}