Skip to main content

lux/
magic.rs

1//! `lux magic` — spells for things you want to do now.
2//!
3//! Where `lux learn` is a concept ladder ("what is X?"), magic is task indexed
4//! ("how do I X?"). A spell is a small, runnable program that already works,
5//! plus a trail to the `lux learn` topics that explain the ideas it uses. Spells
6//! are allowed to run ahead of where a reader has climbed the learn ladder —
7//! that is the point: a working shape now, with an honest signpost to where the
8//! trick is explained. The same spell reads as plain lux once its trail is
9//! climbed; the magic was never magic.
10//!
11//! The content lives in `magic-lux.md`, baked in at compile time, and is also
12//! the test corpus: every spell is real lux the suite runs and translates.
13
14const DOC: &str = include_str!("../magic-lux.md");
15
16/// One spell: the join key `id`, a short title, the "how do I…?" question, a
17/// runnable example, and the learn topics that explain it.
18pub struct Spell {
19    pub id: String,
20    pub title: String,
21    pub question: String,
22    pub example: String,
23    pub trail: Vec<String>,
24}
25
26const WIDTH: usize = 76;
27
28// --- parsing ---------------------------------------------------------------
29
30/// Parse every `<!-- spell: id -->` block in document order.
31pub fn spells() -> Vec<Spell> {
32    let marker = "<!-- spell:";
33    let mut out = Vec::new();
34    let mut rest = DOC;
35    while let Some(pos) = rest.find(marker) {
36        let after = &rest[pos + marker.len()..];
37        let id_end = after.find("-->").unwrap_or(0);
38        let id = after[..id_end].trim().to_string();
39        let body_start = after.find('\n').map(|i| i + 1).unwrap_or(after.len());
40        let body = &after[body_start..];
41        let next = body.find(marker).unwrap_or(body.len());
42        out.push(parse_spell(id, &body[..next]));
43        rest = &body[next..];
44    }
45    out
46}
47
48fn parse_spell(id: String, body: &str) -> Spell {
49    let mut lines = body.lines().peekable();
50
51    let mut title = String::new();
52    for line in lines.by_ref() {
53        if let Some(rest) = line.trim_start().strip_prefix("## ") {
54            title = rest.trim().to_string();
55            break;
56        }
57    }
58
59    // The question: the prose paragraph up to the example fence.
60    let mut question = Vec::new();
61    while let Some(line) = lines.peek() {
62        if line.trim_start().starts_with("```") {
63            break;
64        }
65        let l = line.trim();
66        if !l.is_empty() {
67            question.push(l.to_string());
68        }
69        lines.next();
70    }
71
72    // The example: the lines inside the ```lux fence.
73    lines.next(); // consume the opening fence
74    let mut example = Vec::new();
75    for line in lines.by_ref() {
76        if line.trim_start().starts_with("```") {
77            break;
78        }
79        example.push(line);
80    }
81
82    // The trail: the `> trail:` blockquote, topic ids split on `·`.
83    let mut trail = Vec::new();
84    for line in lines.by_ref() {
85        let l = line.trim();
86        if let Some(rest) = l.strip_prefix("> ") {
87            if let Some(ids) = rest.trim().strip_prefix("trail:") {
88                for piece in ids.split('·') {
89                    let p = piece.trim();
90                    if !p.is_empty() {
91                        trail.push(p.to_string());
92                    }
93                }
94            }
95            break;
96        }
97    }
98
99    Spell {
100        id,
101        title,
102        question: question.join(" "),
103        example: example.join("\n"),
104        trail,
105    }
106}
107
108/// The short summary after the `id — ` in a spell's title, for the menu line.
109fn summary(title: &str) -> String {
110    match title.split_once('—') {
111        Some((_, rest)) => rest.trim().to_string(),
112        None => title.to_string(),
113    }
114}
115
116/// The intro paragraph above the first spell — its first sentence is the
117/// landing-page tagline. The file opens with a maintainer comment, so the intro
118/// is the prose between that comment's close and the first spell.
119fn intro() -> String {
120    let after_comment = match DOC.split_once("-->") {
121        Some((_, rest)) => rest,
122        None => DOC,
123    };
124    after_comment
125        .split("<!-- spell:")
126        .next()
127        .unwrap_or_default()
128        .trim()
129        .to_string()
130}
131
132// --- rendering -------------------------------------------------------------
133
134/// A spell's view: the question, the runnable shape, and its trail.
135fn render(s: &Spell) -> String {
136    let mut out = String::new();
137    out.push_str(&plain(&s.title));
138    out.push_str("\n\n");
139    out.push_str(&wrap(&plain(&s.question), WIDTH));
140    out.push_str("\n\n");
141    for line in s.example.lines() {
142        if line.is_empty() {
143            out.push('\n');
144        } else {
145            out.push_str("    ");
146            out.push_str(line);
147            out.push('\n');
148        }
149    }
150    if !s.trail.is_empty() {
151        let follow: Vec<String> = s.trail.iter().map(|t| format!("lux learn {}", t)).collect();
152        out.push('\n');
153        out.push_str(&wrap(
154            &format!("how it works: {}", follow.join(", ")),
155            WIDTH,
156        ));
157        out.push('\n');
158    }
159    out
160}
161
162/// `lux magic` with no argument: the spells on offer, each a "how do I…?".
163pub fn menu() -> String {
164    let spells = spells();
165    let mut out = String::new();
166    out.push_str("lux magic — spells for things you want to do now\n\n");
167    let tagline = tagline();
168    if !tagline.is_empty() {
169        out.push_str(&wrap(&tagline, WIDTH));
170        out.push_str("\n\n");
171    }
172    out.push_str("cast a spell:\n");
173    let width = spells.iter().map(|s| s.id.len()).max().unwrap_or(0);
174    for s in &spells {
175        out.push_str(&format!(
176            "  lux magic {:<width$}  {}\n",
177            s.id,
178            summary(&s.title),
179            width = width
180        ));
181    }
182    out.push_str("\neach spell ends with how it works — a trail into `lux learn`,\n");
183    out.push_str("where the ideas it uses are explained.\n");
184    out
185}
186
187/// The first sentence of the intro, stripped of markdown.
188fn tagline() -> String {
189    let para = intro().split("\n\n").next().unwrap_or_default().to_string();
190    let unwrapped = para.split_whitespace().collect::<Vec<_>>().join(" ");
191    let plain = plain(&unwrapped);
192    match plain.find(". ") {
193        Some(i) => plain[..=i].trim().to_string(),
194        None => plain,
195    }
196}
197
198/// Resolve `lux magic <id>` to its rendered spell.
199pub fn lookup(name: &str) -> Option<String> {
200    spells().iter().find(|s| s.id == name).map(render)
201}
202
203/// Strip the markdown a reader shouldn't see on a terminal — code-span
204/// backticks and emphasis asterisks. Examples are never run through this.
205fn plain(s: &str) -> String {
206    s.chars().filter(|c| *c != '`' && *c != '*').collect()
207}
208
209/// Greedy word-wrap to a column width.
210fn wrap(text: &str, width: usize) -> String {
211    let mut out = String::new();
212    let mut line = String::new();
213    let mut has_word = false;
214    for word in text.split_whitespace() {
215        if has_word && line.len() + 1 + word.len() > width {
216            out.push_str(&line);
217            out.push('\n');
218            line = String::new();
219            has_word = false;
220        }
221        if has_word {
222            line.push(' ');
223        }
224        line.push_str(word);
225        has_word = true;
226    }
227    out.push_str(&line);
228    out
229}