1const DOC: &str = include_str!("../learn-lux.md");
19
20const 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
35pub 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
45pub struct More {
48 pub prose: String,
49 pub see: Vec<See>,
50}
51
52pub struct See {
54 pub id: String,
55 pub reason: String,
56}
57
58fn learner_region() -> &'static str {
63 DOC.split("<!-- learn:end -->").next().unwrap_or(DOC)
64}
65
66pub 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
101fn parse_card(body: &str) -> (String, String, String, Option<String>) {
103 let mut lines = body.lines().peekable();
104
105 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 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 lines.next(); 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 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
156fn 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
196const WIDTH: usize = 76;
199
200fn 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
228fn 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
251fn plain(s: &str) -> String {
255 s.chars().filter(|c| *c != '`' && *c != '*').collect()
256}
257
258fn 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
267fn 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
291fn 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
332fn 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
339fn 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
348fn wrap(text: &str, width: usize) -> String {
350 wrap_indent(text, width, "", "")
351}
352
353fn 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
376fn 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
390fn 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
402fn 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 = ®ion[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
417fn basics_page() -> String {
420 section("## The shape every language shares")
421}
422
423fn ladder() -> String {
426 section("## Where each feature takes you")
427}
428
429fn bridge_page() -> String {
433 section("## Beyond lux")
434}
435
436pub fn basics() -> String {
438 let mut out = format_tables(&plain(&dehead(&basics_page())));
439 out.push('\n');
440 out
441}
442
443pub fn beyond() -> String {
445 let mut out = format_tables(&plain(&dehead(&bridge_page())));
446 out.push('\n');
447 out
448}
449
450pub 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
483pub 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
516pub 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
524pub 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
566fn 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
586pub fn paths() -> &'static [(&'static str, &'static [&'static str])] {
588 PATHS
589}