Skip to main content

lux/
learn.rs

1//! `lux learn` — the language's own teaching material, built into the binary.
2//!
3//! The content lives in `learn-lux.md` and is baked in at compile time, so the
4//! reference a learner holds always matches the binary's behaviour and needs no
5//! network or stray file. That one file is also the test corpus: every example
6//! here is real lux that the suite runs and converts.
7//!
8//! The file is a sequence of *topics*, each read at two levels. A topic's
9//! *card* is one screen — a stable id, a title, a one-sentence concept, a
10//! runnable example, and an optional `try:` experiment that nudges the learner
11//! back to the editor. An earned, optional *more* page carries the deeper why,
12//! the universal name for the idea, and reason-annotated cross-references. The
13//! id is the join key — what you type (`lux learn match`), what a guided lesson
14//! lists, and what an error message points at. Everything `lux learn` shows is
15//! some traversal of this one list of topics, plus two pages of furniture: the
16//! `basics` skeleton and the graduation `ladder`.
17
18const DOC: &str = include_str!("../learn-lux.md");
19
20/// Guided lessons: short, ordered sequences of topics for a first read. Each is
21/// a few topics on one theme, walked in order. The names are disjoint from the
22/// topic ids, so one argument resolves unambiguously to a lesson or a topic.
23const PATHS: &[(&str, &[&str])] = &[
24    (
25        "start",
26        &["hello", "errors", "variables", "numbers", "strings"],
27    ),
28    ("logic", &["booleans", "if", "while"]),
29    ("data", &["arrays", "for", "functions", "scope"]),
30    ("types", &["structs", "enums", "match"]),
31    ("safety", &["option", "conversions", "result", "io", "shell"]),
32    ("build", &["crawl"]),
33];
34
35/// A single learnable idea. The card is always present; `more` is earned.
36pub struct Topic {
37    pub id: String,
38    pub title: String,
39    pub concept: String,
40    pub example: String,
41    pub try_hint: Option<String>,
42    pub more: Option<More>,
43}
44
45/// The earned second level of a topic: the deeper prose, plus any
46/// reason-annotated cross-references to related topics.
47pub struct More {
48    pub prose: String,
49    pub see: Vec<See>,
50}
51
52/// One cross-reference: a topic id and the reason a learner would follow it.
53pub struct See {
54    pub id: String,
55    pub reason: String,
56}
57
58// --- parsing ---------------------------------------------------------------
59
60/// The slice of the document that `lux learn` draws from — everything above the
61/// `learn:end` marker. The notes below it are for the maintainer, not learners.
62fn learner_region() -> &'static str {
63    DOC.split("<!-- learn:end -->").next().unwrap_or(DOC)
64}
65
66/// Parse every `<!-- topic: id -->` block in document order.
67pub fn topics() -> Vec<Topic> {
68    let region = learner_region();
69    let marker = "<!-- topic:";
70    let mut out = Vec::new();
71    let mut rest = region;
72    while let Some(pos) = rest.find(marker) {
73        let after = &rest[pos + marker.len()..];
74        let id_end = after.find("-->").unwrap_or(0);
75        let id = after[..id_end].trim().to_string();
76        let body_start = after.find('\n').map(|i| i + 1).unwrap_or(after.len());
77        let body_region = &after[body_start..];
78        let next = body_region.find(marker).unwrap_or(body_region.len());
79        out.push(parse_topic(id, &body_region[..next]));
80        rest = &body_region[next..];
81    }
82    out
83}
84
85fn parse_topic(id: String, body: &str) -> Topic {
86    let (card, more_src) = match body.split_once("<!-- more -->") {
87        Some((c, m)) => (c, Some(m)),
88        None => (body, None),
89    };
90    let (title, concept, example, try_hint) = parse_card(card);
91    Topic {
92        id,
93        title,
94        concept,
95        example,
96        try_hint,
97        more: more_src.map(parse_more),
98    }
99}
100
101/// Parse a topic card: title, concept paragraph, example, and `try:` hint.
102fn parse_card(body: &str) -> (String, String, String, Option<String>) {
103    let mut lines = body.lines().peekable();
104
105    // Title: the `## ...` heading.
106    let mut title = String::new();
107    for line in lines.by_ref() {
108        if let Some(rest) = line.trim_start().strip_prefix("## ") {
109            title = rest.trim().to_string();
110            break;
111        }
112    }
113
114    // Concept: the prose paragraph up to the example fence.
115    let mut concept = Vec::new();
116    while let Some(line) = lines.peek() {
117        if line.trim_start().starts_with("```") {
118            break;
119        }
120        let l = line.trim();
121        if !l.is_empty() {
122            concept.push(l.to_string());
123        }
124        lines.next();
125    }
126
127    // Example: the lines inside the ```lux fence.
128    lines.next(); // consume the opening fence
129    let mut example = Vec::new();
130    for line in lines.by_ref() {
131        if line.trim_start().starts_with("```") {
132            break;
133        }
134        example.push(line);
135    }
136
137    // try: hint — the first blockquote line after the example, if any. A card's
138    // only footer is an experiment; cross-references live on the more page.
139    let mut try_hint = None;
140    for line in lines.by_ref() {
141        let l = line.trim();
142        if l.is_empty() {
143            continue;
144        }
145        if let Some(rest) = l.strip_prefix("> ") {
146            let rest = rest.trim();
147            let hint = rest.strip_prefix("try:").map(str::trim).unwrap_or(rest);
148            try_hint = Some(hint.to_string());
149        }
150        break;
151    }
152
153    (title, concept.join(" "), example.join("\n"), try_hint)
154}
155
156/// Parse a more page: the deeper prose, then an optional `> see:` block whose
157/// entries read `id — reason`, separated by `·`.
158fn parse_more(body: &str) -> More {
159    let mut prose = Vec::new();
160    let mut quote = String::new();
161    let mut in_quote = false;
162    for line in body.lines() {
163        let t = line.trim();
164        if let Some(rest) = t.strip_prefix("> ") {
165            in_quote = true;
166            if !quote.is_empty() {
167                quote.push(' ');
168            }
169            quote.push_str(rest.trim());
170        } else if !in_quote && !t.is_empty() {
171            prose.push(t.to_string());
172        }
173    }
174
175    let mut see = Vec::new();
176    if let Some(rest) = quote.strip_prefix("see:") {
177        for piece in rest.split('·') {
178            let p = piece.trim();
179            if p.is_empty() {
180                continue;
181            }
182            let (id, reason) = match p.split_once('—') {
183                Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
184                None => (p.to_string(), String::new()),
185            };
186            see.push(See { id, reason });
187        }
188    }
189
190    More {
191        prose: prose.join(" "),
192        see,
193    }
194}
195
196// --- rendering -------------------------------------------------------------
197
198const WIDTH: usize = 76;
199
200/// A topic's default view: the one-screen card, plus a pointer to its more page
201/// when one exists so the deeper level is discoverable.
202fn render_card(t: &Topic) -> String {
203    let mut out = String::new();
204    out.push_str(&plain(&t.title));
205    out.push_str("\n\n");
206    out.push_str(&wrap(&plain(&t.concept), WIDTH));
207    out.push_str("\n\n");
208    for line in t.example.lines() {
209        if line.is_empty() {
210            out.push('\n');
211        } else {
212            out.push_str("    ");
213            out.push_str(line);
214            out.push('\n');
215        }
216    }
217    if let Some(hint) = &t.try_hint {
218        out.push('\n');
219        out.push_str(&wrap(&plain(&format!("try: {}", hint)), WIDTH));
220        out.push('\n');
221    }
222    if t.more.is_some() {
223        out.push_str(&format!("\nmore: lux learn {} more\n", t.id));
224    }
225    out
226}
227
228/// A topic's earned second level: the deeper prose and its cross-references.
229fn render_more(t: &Topic, m: &More) -> String {
230    let mut out = String::new();
231    out.push_str(&plain(&t.title));
232    out.push_str(" — more\n\n");
233    out.push_str(&wrap(&plain(&m.prose), WIDTH));
234    out.push('\n');
235    if !m.see.is_empty() {
236        out.push_str("\nsee also:\n");
237        for s in &m.see {
238            if s.reason.is_empty() {
239                out.push_str(&format!("  lux learn {}\n", s.id));
240            } else {
241                let lead = format!("  lux learn {} — ", s.id);
242                let wrapped = wrap_indent(&plain(&s.reason), WIDTH, &lead, "    ");
243                out.push_str(&wrapped);
244                out.push('\n');
245            }
246        }
247    }
248    out
249}
250
251/// Strip the markdown a learner shouldn't see on a terminal — code-span
252/// backticks and emphasis asterisks. The examples themselves are never run
253/// through this; only the surrounding prose.
254fn plain(s: &str) -> String {
255    s.chars().filter(|c| *c != '`' && *c != '*').collect()
256}
257
258/// Drop leading `#` heading markers from each line, so a furniture section
259/// reads as a plain title on a terminal rather than raw markdown.
260fn dehead(s: &str) -> String {
261    s.lines()
262        .map(|l| l.trim_start_matches('#').trim_start_matches(' '))
263        .collect::<Vec<_>>()
264        .join("\n")
265}
266
267/// Reflow any markdown tables in a block into aligned columns for the terminal,
268/// passing every other line through untouched. The markdown source stays a real
269/// table — this only changes how it looks on a screen, not on GitHub.
270fn format_tables(text: &str) -> String {
271    let mut out = String::new();
272    let mut block: Vec<&str> = Vec::new();
273    for line in text.lines() {
274        if line.trim_start().starts_with('|') {
275            block.push(line);
276        } else {
277            if !block.is_empty() {
278                out.push_str(&render_table(&block));
279                block.clear();
280            }
281            out.push_str(line);
282            out.push('\n');
283        }
284    }
285    if !block.is_empty() {
286        out.push_str(&render_table(&block));
287    }
288    out
289}
290
291/// One markdown table → space-aligned columns with a rule under the header.
292fn render_table(lines: &[&str]) -> String {
293    let rows: Vec<Vec<String>> = lines
294        .iter()
295        .filter(|l| !is_rule_row(l))
296        .map(|l| split_cells(l))
297        .collect();
298    if rows.is_empty() {
299        return String::new();
300    }
301    let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
302    let mut widths = vec![0usize; cols];
303    for r in &rows {
304        for (i, c) in r.iter().enumerate() {
305            widths[i] = widths[i].max(c.chars().count());
306        }
307    }
308
309    let gap = "  ";
310    let mut out = String::new();
311    for (ri, r) in rows.iter().enumerate() {
312        let mut line = String::new();
313        for (i, c) in r.iter().enumerate() {
314            line.push_str(c);
315            if i + 1 < r.len() {
316                let pad = widths[i].saturating_sub(c.chars().count());
317                line.push_str(&" ".repeat(pad));
318                line.push_str(gap);
319            }
320        }
321        out.push_str(line.trim_end());
322        out.push('\n');
323        if ri == 0 {
324            let width: usize = widths.iter().sum::<usize>() + gap.len() * cols.saturating_sub(1);
325            out.push_str(&"─".repeat(width));
326            out.push('\n');
327        }
328    }
329    out
330}
331
332/// A markdown table's `|---|---|` separator row: cells of only dashes.
333fn is_rule_row(line: &str) -> bool {
334    split_cells(line)
335        .iter()
336        .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-'))
337}
338
339/// Split a markdown table row into trimmed cells, dropping the outer pipes.
340fn split_cells(line: &str) -> Vec<String> {
341    line.trim()
342        .trim_matches('|')
343        .split('|')
344        .map(|c| c.trim().to_string())
345        .collect()
346}
347
348/// Greedy word-wrap to a column width, so a long concept stays one screen.
349fn wrap(text: &str, width: usize) -> String {
350    wrap_indent(text, width, "", "")
351}
352
353/// Word-wrap with a one-time lead on the first line and a hanging indent on the
354/// rest — used for `see also:` entries where the reason trails the topic name.
355fn wrap_indent(text: &str, width: usize, lead: &str, hang: &str) -> String {
356    let mut out = String::new();
357    let mut line = String::from(lead);
358    let mut has_word = false;
359    for word in text.split_whitespace() {
360        if has_word && line.len() + 1 + word.len() > width {
361            out.push_str(&line);
362            out.push('\n');
363            line = String::from(hang);
364            has_word = false;
365        }
366        if has_word {
367            line.push(' ');
368        }
369        line.push_str(word);
370        has_word = true;
371    }
372    out.push_str(&line);
373    out
374}
375
376/// The intro paragraphs at the top of the file, above the first topic.
377fn intro() -> String {
378    let head = learner_region()
379        .split("<!-- topic:")
380        .next()
381        .unwrap_or_default();
382    head.lines()
383        .filter(|l| !l.trim_start().starts_with('#'))
384        .collect::<Vec<_>>()
385        .join("\n")
386        .trim()
387        .to_string()
388}
389
390/// The first sentence of the intro, unwrapped and stripped of markdown — a
391/// one-line description for the landing page.
392fn tagline() -> String {
393    let para = intro().split("\n\n").next().unwrap_or_default().to_string();
394    let unwrapped = para.split_whitespace().collect::<Vec<_>>().join(" ");
395    let plain = plain(&unwrapped);
396    match plain.find(". ") {
397        Some(i) => plain[..=i].trim().to_string(),
398        None => plain,
399    }
400}
401
402/// A `## ...` furniture section of the learner region, from its heading to the
403/// next top-level heading or the end of the region.
404fn section(anchor: &str) -> String {
405    let region = learner_region();
406    let start = match region.find(anchor) {
407        Some(i) => i,
408        None => return String::new(),
409    };
410    let after = &region[start + anchor.len()..];
411    let end = after.find("\n## ").unwrap_or(after.len());
412    format!("{}{}", anchor, &after[..end])
413        .trim_end()
414        .to_string()
415}
416
417/// The procedural-language skeleton: the pieces every language shares and where
418/// to learn each in lux. The inward companion to the graduation ladder.
419fn basics_page() -> String {
420    section("## The shape every language shares")
421}
422
423/// The graduation table beneath the topics — where each lux feature lands in
424/// Rust, Swift, and Go.
425fn ladder() -> String {
426    section("## Where each feature takes you")
427}
428
429/// The closing note: what carries past lux, and past code, once the language
430/// itself is outgrown. The human companion to the basics skeleton and the
431/// graduation ladder.
432fn bridge_page() -> String {
433    section("## Beyond lux")
434}
435
436/// `lux learn basics`: the shape every procedural language shares.
437pub fn basics() -> String {
438    let mut out = format_tables(&plain(&dehead(&basics_page())));
439    out.push('\n');
440    out
441}
442
443/// `lux learn beyond`: what you keep after you outgrow lux.
444pub fn beyond() -> String {
445    let mut out = format_tables(&plain(&dehead(&bridge_page())));
446    out.push('\n');
447    out
448}
449
450/// `lux learn` with no argument: a short landing page — the guided lessons, how
451/// to look up one topic, how to go deeper, and how to read the whole thing.
452pub fn menu() -> String {
453    let topics = topics();
454
455    let mut out = String::new();
456    out.push_str("lux learn — the language, one short topic at a time\n\n");
457    let tagline = tagline();
458    if !tagline.is_empty() {
459        out.push_str(&wrap(&tagline, WIDTH));
460        out.push_str("\n\n");
461    }
462
463    out.push_str("guided lessons — read each short topic, then go write code:\n");
464    for (name, ids) in PATHS {
465        out.push_str(&format!("  lux learn {:<8} {}\n", name, ids.join(", ")));
466    }
467
468    out.push_str("\nlook up one idea:\n");
469    out.push_str("  lux learn <topic>\n");
470    let ids: Vec<&str> = topics.iter().map(|t| t.id.as_str()).collect();
471    out.push_str(&wrap_ids(&ids, "    "));
472
473    out.push_str("\ngo deeper on any topic:\n");
474    out.push_str("  lux learn <topic> more\n");
475
476    out.push_str("\nthe bigger picture:\n");
477    out.push_str("  lux learn basics    the shape every language shares\n");
478    out.push_str("  lux learn tour      the whole language, top to bottom\n");
479    out.push_str("  lux learn beyond    what you keep after you outgrow lux\n");
480    out
481}
482
483/// The whole language top to bottom: intro, the basics skeleton, every card,
484/// then the ladder.
485pub fn tour() -> String {
486    let mut out = String::new();
487    out.push_str(&intro());
488    out.push_str("\n\n");
489    out.push_str(&rule());
490    out.push('\n');
491    out.push_str(&format_tables(&plain(&dehead(&basics_page()))));
492    out.push_str("\n\n");
493    out.push_str(&rule());
494    out.push('\n');
495    for t in topics() {
496        out.push_str(&render_card(&t));
497        out.push('\n');
498        out.push_str(&rule());
499        out.push('\n');
500    }
501    let ladder = ladder();
502    if !ladder.is_empty() {
503        out.push_str(&format_tables(&plain(&dehead(&ladder))));
504        out.push('\n');
505    }
506    let bridge = bridge_page();
507    if !bridge.is_empty() {
508        out.push_str(&rule());
509        out.push('\n');
510        out.push_str(&format_tables(&plain(&dehead(&bridge))));
511        out.push('\n');
512    }
513    out
514}
515
516/// Resolve one `lux learn` argument: a guided lesson name or a topic id (card).
517pub fn lookup(name: &str) -> Option<String> {
518    if let Some((_, ids)) = PATHS.iter().find(|(n, _)| *n == name) {
519        return Some(render_path(name, ids));
520    }
521    topics().iter().find(|t| t.id == name).map(render_card)
522}
523
524/// Resolve `lux learn <topic> more`: the topic's deeper page, or its card
525/// with a note when the topic has no deeper page.
526pub fn topic_more(name: &str) -> Option<String> {
527    let t = topics().into_iter().find(|t| t.id == name)?;
528    Some(match &t.more {
529        Some(m) => render_more(&t, m),
530        None => {
531            let mut s = render_card(&t);
532            s.push_str("\n(this topic has no deeper page — the card above is the whole story.)\n");
533            s
534        }
535    })
536}
537
538fn render_path(name: &str, ids: &[&str]) -> String {
539    let all = topics();
540    let mut out = String::new();
541    out.push_str(&format!("lesson: {}\n\n", name));
542    out.push_str(&rule());
543    out.push('\n');
544    for id in ids {
545        if let Some(t) = all.iter().find(|t| &t.id == id) {
546            out.push_str(&render_card(t));
547            out.push('\n');
548            out.push_str(&rule());
549            out.push('\n');
550        }
551    }
552    if let Some(pos) = PATHS.iter().position(|(n, _)| *n == name) {
553        if let Some((next, _)) = PATHS.get(pos + 1) {
554            out.push_str(&format!("next lesson: lux learn {}\n", next));
555        } else {
556            out.push_str("that's the last lesson — `lux learn tour` shows it all.\n");
557        }
558    }
559    out
560}
561
562fn rule() -> String {
563    "─".repeat(60) + "\n"
564}
565
566/// Wrap a list of ids to a readable width, each line under the given indent.
567fn wrap_ids(ids: &[&str], indent: &str) -> String {
568    let mut out = String::new();
569    let mut line = String::from(indent);
570    for id in ids {
571        if line.len() + id.len() + 1 > 72 && line.trim() != "" {
572            out.push_str(line.trim_end());
573            out.push('\n');
574            line = String::from(indent);
575        }
576        line.push_str(id);
577        line.push(' ');
578    }
579    if line.trim() != "" {
580        out.push_str(line.trim_end());
581        out.push('\n');
582    }
583    out
584}
585
586/// The guided lessons, exposed so a test can check every member resolves.
587pub fn paths() -> &'static [(&'static str, &'static [&'static str])] {
588    PATHS
589}