Skip to main content

decl_lang/
repl.rs

1//! The REPL (docs/tooling/02_repl.md) — a port of the reference
2//! implementation's repl.ts: an interactive session over a universe —
3//! expressions evaluated partially, session outputs and declarations,
4//! documents bound and edited with exact undo, and the command-line verbs
5//! root for root. Everything it prints goes to standard output; a scripted
6//! session (`--script`) prints the transcript the terminal would show, so
7//! the three implementations can be diffed.
8use crate::session::{
9    fmt_diag, parse_decl, parse_expr, pretty_json, BindSource, EditKind, Mode, Op, Session,
10    SessionError,
11};
12use regex::Regex;
13use std::io::{BufRead, Read, Write};
14
15pub const COMMANDS: &[(&str, &str, &str)] = &[
16    // the universe
17    (
18        ":load file.decl",
19        "open the universe from an entry module (a new session)",
20        "universe",
21    ),
22    (
23        ":reload",
24        "re-read every module of the universe from disk",
25        "universe",
26    ),
27    (
28        ":roots",
29        "the roots of the universe and of the session",
30        "universe",
31    ),
32    // documents
33    (
34        ":bind name=doc.json",
35        "bind a JSON file to an input",
36        "documents",
37    ),
38    (
39        ":bind name { … }",
40        "bind an inline JSON document",
41        "documents",
42    ),
43    (
44        ":bind name = expr",
45        "bind the value of an expression as the document",
46        "documents",
47    ),
48    (":unbind name", "drop the binding", "documents"),
49    (
50        ":create path = expr",
51        "add a member, entry, or element at a path of a document",
52        "documents",
53    ),
54    (
55        ":update path = expr",
56        "replace the value at a path of a document",
57        "documents",
58    ),
59    (
60        ":remove path",
61        "remove the value at a path of a document",
62        "documents",
63    ),
64    (
65        ":diff name",
66        "the document against what it started from",
67        "documents",
68    ),
69    (
70        ":save name=file",
71        "write the document of a root to a file",
72        "documents",
73    ),
74    // session declarations
75    (":drop name", "remove a session declaration", "declarations"),
76    (
77        ":write file.decl",
78        "write the scratch module to a file",
79        "declarations",
80    ),
81    (
82        ":session",
83        "the session's declarations and documents",
84        "declarations",
85    ),
86    (
87        ":reset",
88        "drop every binding, edit, and declaration",
89        "declarations",
90    ),
91    // evaluation and validation
92    (":check", "static diagnostics of every module", "evaluation"),
93    (
94        ":evaluate [root…]",
95        "full evaluation: the documents of the roots",
96        "evaluation",
97    ),
98    (
99        ":validate [root…]",
100        "full validation: every diagnostic, then a verdict per root",
101        "evaluation",
102    ),
103    (
104        ":fmt",
105        "the scratch module, canonically formatted",
106        "evaluation",
107    ),
108    // inspection
109    (
110        ":type expr",
111        "the static type of an expression",
112        "inspection",
113    ),
114    (
115        ":doc name",
116        "a declaration and its documentation",
117        "inspection",
118    ),
119    (":path expr", "the canonical path of a place", "inspection"),
120    (
121        ":trace path",
122        "the derivation of a place, or its root cause",
123        "inspection",
124    ),
125    (
126        ":complete text",
127        "the completions offered at the end of the text",
128        "inspection",
129    ),
130    // history
131    (":undo [n]", "step the log back", "history"),
132    (":redo [n]", "step forward again", "history"),
133    (
134        ":history [file]",
135        "the log, or write it as a session file",
136        "history",
137    ),
138    // the session
139    (":time", "wall time of the last evaluation", "session"),
140    (":set pretty|compact", "value printing", "session"),
141    (":help [command]", "these commands", "session"),
142    (":quit", "end the session", "session"),
143];
144
145fn command_names() -> Vec<&'static str> {
146    let mut out: Vec<&'static str> = vec![];
147    for c in COMMANDS {
148        let n = c.0.split(' ').next().unwrap_or("");
149        if !out.contains(&n) {
150            out.push(n);
151        }
152    }
153    out
154}
155
156const KEYWORDS: &[&str] = &[
157    "if", "then", "else", "for", "in", "match", "with", "matches", "true", "false", "null",
158    "export",
159];
160
161fn is_decl_head(t: &str) -> bool {
162    Regex::new(
163        r"^\s*(?:export\s+)?(type|const|func|output|input|diagnostic|dimension|unit|import)\b",
164    )
165    .unwrap()
166    .is_match(t)
167}
168fn is_ident(s: &str) -> bool {
169    Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap().is_match(s)
170}
171
172/// `name = expr` / `name: T = expr` — a session output (the reference's OUTPUT_HEAD)
173fn output_head(t: &str) -> Option<(String, Option<String>, String)> {
174    let re = Regex::new(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*").unwrap();
175    let m = re.captures(t)?;
176    let name = m.get(1).unwrap().as_str().to_string();
177    let mut rest = &t[m.get(0).unwrap().end()..];
178    let mut ty: Option<String> = None;
179    if let Some(r) = rest.strip_prefix(':') {
180        let r = r.trim_start();
181        let eq = r.find('=')?;
182        let type_part = &r[..eq];
183        if type_part.trim_end().is_empty() {
184            return None;
185        }
186        ty = Some(type_part.trim().to_string());
187        rest = &r[eq..];
188    }
189    let rest = rest.strip_prefix('=')?;
190    if rest.starts_with('=') {
191        return None;
192    }
193    let expr = rest.trim_start();
194    if expr.is_empty() {
195        return None;
196    }
197    Some((name, ty, expr.trim().to_string()))
198}
199
200/// does the input so far leave an expression open (§2.9)?
201pub fn needs_more(text: &str) -> bool {
202    let cs: Vec<char> = text.chars().collect();
203    let mut depth = 0i32;
204    let mut in_str: Option<char> = None;
205    let mut i = 0;
206    while i < cs.len() {
207        let c = cs[i];
208        if let Some(q) = in_str {
209            if c == '\\' {
210                i += 1;
211            } else if c == q {
212                in_str = None;
213            }
214            i += 1;
215            continue;
216        }
217        if c == '"' || c == '`' {
218            in_str = Some(c);
219        } else if c == '/' && cs.get(i + 1) == Some(&'/') {
220            match cs[i..].iter().position(|&x| x == '\n') {
221                Some(nl) => i += nl,
222                None => break,
223            }
224        } else if "{[(".contains(c) {
225            depth += 1;
226        } else if "}])".contains(c) {
227            depth -= 1;
228        }
229        i += 1;
230    }
231    if depth > 0 || in_str == Some('`') {
232        return true;
233    }
234    if text.trim_start().starts_with(':') {
235        return false; // a command: only an open bracket continues it
236    }
237    let no_comment = Regex::new(r"//[^\n]*$")
238        .unwrap()
239        .replace(text, "")
240        .to_string();
241    let tail = no_comment.trim_end();
242    Regex::new(r"(?:[+\-*/%<>=!&|?:,]|\bthen|\belse|\bin|\bwith|=>)$")
243        .unwrap()
244        .is_match(tail)
245}
246
247pub struct Repl {
248    pub session: Session,
249    pub compact: bool,
250    pub errors: usize,
251    pub quit_requested: bool,
252    out: Box<dyn Fn(&str)>,
253    buffer: Vec<String>,
254}
255
256impl Repl {
257    pub fn new(out: Box<dyn Fn(&str)>, entry: Option<&str>) -> Repl {
258        Repl {
259            session: Session::new(entry),
260            compact: false,
261            errors: 0,
262            quit_requested: false,
263            out,
264            buffer: vec![],
265        }
266    }
267
268    /// feed one line; returns true when the input is complete and was handled
269    pub fn line(&mut self, text: &str) -> bool {
270        self.buffer.push(text.to_string());
271        let whole = self.buffer.join("\n");
272        if needs_more(&whole) {
273            return false;
274        }
275        self.buffer.clear();
276        self.input(&whole);
277        true
278    }
279    /// drop the input being continued (Ctrl-C at a continuation prompt)
280    pub fn discard(&mut self) {
281        self.buffer.clear();
282    }
283    pub fn pending(&self) -> bool {
284        !self.buffer.is_empty()
285    }
286
287    fn out(&self, line: &str) {
288        (self.out)(line)
289    }
290    fn error(&mut self, msg: &str) {
291        self.errors += 1;
292        self.out(&format!("error: {msg}"));
293    }
294    fn diag(&self, d: &crate::semantics::Diag, in_file: Option<&str>) {
295        self.out(&fmt_diag(d, in_file));
296    }
297    fn value(&self, json: &str) {
298        if self.compact {
299            self.out(json);
300        } else {
301            self.out(&pretty_json(json));
302        }
303    }
304
305    pub fn input(&mut self, text: &str) {
306        let t = text.trim();
307        if t.is_empty() || (t.starts_with("//") && !t.starts_with("///")) {
308            return;
309        }
310        let r = if t.starts_with(':') {
311            self.command(t)
312        } else if is_decl_head(t) {
313            self.add_declaration(t)
314        } else if let Some((name, ty, expr)) =
315            output_head(t).filter(|(n, _, _)| !KEYWORDS.contains(&n.as_str()))
316        {
317            self.session_output(&name, ty, &expr)
318        } else {
319            self.expression(t)
320        };
321        if let Err(e) = r {
322            self.error(&e.0);
323        }
324    }
325
326    fn expression(&mut self, text: &str) -> Result<(), SessionError> {
327        parse_expr(text)?;
328        let r = self.session.evaluate_expr(text)?;
329        for d in &r.diags {
330            self.diag(d, None);
331        }
332        match (&r.error, &r.value) {
333            (Some((code, message)), _) => {
334                if !message.is_empty() {
335                    self.out(&format!(
336                        "error{}: {message}",
337                        code.as_ref().map(|c| format!(" [{c}]")).unwrap_or_default()
338                    ));
339                }
340                self.out("(invalid)");
341            }
342            (None, Some(v)) => self.value(v),
343            (None, None) => self.out("(invalid)"),
344        }
345        self.out("(partial)");
346        Ok(())
347    }
348    fn add_declaration(&mut self, text: &str) -> Result<(), SessionError> {
349        let (_, name) = parse_decl(text)?;
350        self.session.apply(Op::Declare {
351            name,
352            text: text.trim().to_string(),
353        })
354    }
355    fn session_output(
356        &mut self,
357        name: &str,
358        ty: Option<String>,
359        expr: &str,
360    ) -> Result<(), SessionError> {
361        parse_expr(expr)?;
362        if let Some(t) = &ty {
363            parse_decl(&format!("output {name}: {t} = 0"))?;
364        }
365        self.session.apply(Op::Output {
366            name: name.to_string(),
367            ty,
368            expr: expr.to_string(),
369        })
370    }
371
372    fn command(&mut self, t: &str) -> Result<(), SessionError> {
373        let sp = t.find(char::is_whitespace);
374        let cmd = match sp {
375            Some(i) => &t[..i],
376            None => t,
377        };
378        let rest = match sp {
379            Some(i) => t[i + 1..].trim(),
380            None => "",
381        }
382        .to_string();
383        let cmd = cmd.to_string();
384        let no_args = |rest: &str| -> Result<(), SessionError> {
385            if !rest.is_empty() {
386                Err(SessionError(format!("{cmd} takes no argument")))
387            } else {
388                Ok(())
389            }
390        };
391        let one_name = |rest: &str| -> Result<String, SessionError> {
392            if !is_ident(rest) {
393                Err(SessionError(format!("{cmd} expects a name")))
394            } else {
395                Ok(rest.to_string())
396            }
397        };
398        let entry_abs = self.session.entry_abs().display().to_string();
399        let in_file = |file: &str| -> Option<String> {
400            if file == entry_abs {
401                None
402            } else {
403                Some(file.to_string())
404            }
405        };
406        match cmd.as_str() {
407            ":load" => {
408                if rest.is_empty() {
409                    return Err(SessionError(":load expects a file".into()));
410                }
411                self.session = Session::new(Some(&rest));
412                Ok(())
413            }
414            ":reload" => {
415                no_args(&rest)?;
416                let op = self.session.reload_op();
417                self.session.apply(op)
418            }
419            ":roots" => {
420                no_args(&rest)?;
421                let rs = self.session.roots();
422                if rs.is_empty() {
423                    self.out("(no roots)");
424                    return Ok(());
425                }
426                for r in rs {
427                    let status = if r.session {
428                        "session".to_string()
429                    } else if r.kind == "output" {
430                        if r.binding == "detached" {
431                            "detached".into()
432                        } else if r.exported {
433                            "exported".into()
434                        } else {
435                            "local".into()
436                        }
437                    } else {
438                        r.binding.clone()
439                    };
440                    let line = format!(
441                        "{:<7} {:<16} {:<12} {:<16} {}{}",
442                        r.kind,
443                        r.name,
444                        status,
445                        r.module,
446                        r.detail,
447                        if r.edited { " (edited)" } else { "" }
448                    );
449                    self.out(line.trim_end());
450                }
451                Ok(())
452            }
453            ":bind" => {
454                let eq_re = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]+)$").unwrap();
455                if let Some(m) = eq_re.captures(&rest) {
456                    let name = m.get(1).unwrap().as_str().to_string();
457                    let val = m.get(2).unwrap().as_str();
458                    let after_name = &rest[name.len()..];
459                    let starts_bracket = val.trim().starts_with('[') || val.trim().starts_with('{');
460                    if !starts_bracket && !after_name.starts_with(char::is_whitespace) {
461                        // name=file (no spaces around =)
462                        let file = val.trim().to_string();
463                        let text = std::fs::read_to_string(&file)
464                            .map_err(|_| SessionError(format!("cannot read {file}")))?;
465                        return self.session.apply(Op::Bind {
466                            name,
467                            src: BindSource::File { file, text },
468                        });
469                    }
470                    return self.session.apply(Op::Bind {
471                        name,
472                        src: BindSource::Expr {
473                            text: val.trim().to_string(),
474                        },
475                    });
476                }
477                let inline_re = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)\s+([\[{][\s\S]*)$").unwrap();
478                if let Some(m) = inline_re.captures(&rest) {
479                    let name = m.get(1).unwrap().as_str().to_string();
480                    let text = m.get(2).unwrap().as_str().to_string();
481                    return self.session.apply(Op::Bind {
482                        name,
483                        src: BindSource::Inline { text },
484                    });
485                }
486                Err(SessionError(
487                    ":bind expects name=doc.json, name { … }, or name = expr".into(),
488                ))
489            }
490            ":unbind" => {
491                let name = one_name(&rest)?;
492                self.session.apply(Op::Unbind { name })
493            }
494            ":create" | ":update" => {
495                let re = Regex::new(r"^(\S+)\s*=\s*([\s\S]+)$").unwrap();
496                let Some(m) = re.captures(&rest) else {
497                    return Err(SessionError(format!("{cmd} expects path = expr")));
498                };
499                let kind = if cmd == ":create" {
500                    EditKind::Create
501                } else {
502                    EditKind::Update
503                };
504                self.session.apply(Op::Edit {
505                    kind,
506                    path: m.get(1).unwrap().as_str().to_string(),
507                    expr: Some(m.get(2).unwrap().as_str().trim().to_string()),
508                })
509            }
510            ":remove" => {
511                if rest.is_empty() || rest.contains(char::is_whitespace) {
512                    return Err(SessionError(":remove expects a path".into()));
513                }
514                self.session.apply(Op::Edit {
515                    kind: EditKind::Remove,
516                    path: rest.clone(),
517                    expr: None,
518                })
519            }
520            ":diff" => {
521                let name = one_name(&rest)?;
522                for l in self.session.diff(&name)? {
523                    self.out(&l);
524                }
525                Ok(())
526            }
527            ":save" => {
528                let re = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)=(\S+)$").unwrap();
529                let Some(m) = re.captures(&rest) else {
530                    return Err(SessionError(":save expects name=file".into()));
531                };
532                self.session
533                    .save(m.get(1).unwrap().as_str(), m.get(2).unwrap().as_str())
534            }
535            ":drop" => {
536                let name = one_name(&rest)?;
537                self.session.apply(Op::Drop { name })
538            }
539            ":write" => {
540                if rest.is_empty() {
541                    return Err(SessionError(":write expects a file".into()));
542                }
543                self.session.write(&rest)
544            }
545            ":session" => {
546                no_args(&rest)?;
547                let ls = self.session.session_lines();
548                if ls.is_empty() {
549                    self.out("(empty session)");
550                }
551                for l in ls {
552                    self.out(&l);
553                }
554                Ok(())
555            }
556            ":reset" => {
557                no_args(&rest)?;
558                self.session.apply(Op::Reset)
559            }
560            ":check" => {
561                no_args(&rest)?;
562                let cs = self.session.check();
563                for (file, d) in &cs {
564                    self.diag(d, in_file(file).as_deref());
565                }
566                if cs.is_empty() {
567                    self.out("ok");
568                }
569                Ok(())
570            }
571            ":evaluate" => {
572                let names: Vec<String> = if rest.is_empty() {
573                    vec![]
574                } else {
575                    Regex::new(r"[\s,]+")
576                        .unwrap()
577                        .split(&rest)
578                        .filter(|s| !s.is_empty())
579                        .map(|s| s.to_string())
580                        .collect()
581                };
582                let (run, docs, exported) = self.session.evaluate(&names)?;
583                for d in &run.load_diags {
584                    self.diag(d, None);
585                }
586                for (file, d) in &run.checks {
587                    self.diag(d, in_file(file).as_deref());
588                }
589                for d in &run.session_checks {
590                    self.diag(d, None);
591                }
592                for d in &run.diags {
593                    self.diag(d, None);
594                }
595                if run.entry.is_none() {
596                    return Ok(());
597                }
598                if run.eng.is_none() {
599                    self.out("(not evaluated)");
600                    return Ok(());
601                }
602                if exported {
603                    if docs.iter().any(|(_, j)| j.is_none()) {
604                        self.out("(invalid)");
605                        return Ok(());
606                    }
607                    let text = format!(
608                        "{{{}}}",
609                        docs.iter()
610                            .map(|(n, j)| format!(
611                                "{}:{}",
612                                crate::semantics::json_str(n),
613                                j.clone().unwrap_or_default()
614                            ))
615                            .collect::<Vec<_>>()
616                            .join(",")
617                    );
618                    self.value(&text);
619                    return Ok(());
620                }
621                let many = docs.len() > 1;
622                for (name, json) in &docs {
623                    if many {
624                        self.out(&format!("{name}:"));
625                    }
626                    match json {
627                        None => self.out("(invalid)"),
628                        Some(j) => self.value(j),
629                    }
630                }
631                Ok(())
632            }
633            ":validate" => {
634                let names: Vec<String> = if rest.is_empty() {
635                    vec![]
636                } else {
637                    Regex::new(r"[\s,]+")
638                        .unwrap()
639                        .split(&rest)
640                        .filter(|s| !s.is_empty())
641                        .map(|s| s.to_string())
642                        .collect()
643                };
644                let (run, verdicts, diags) = self.session.validate(&names)?;
645                for d in &run.load_diags {
646                    self.diag(d, None);
647                }
648                for (file, d) in &run.checks {
649                    self.diag(d, in_file(file).as_deref());
650                }
651                for d in &run.session_checks {
652                    self.diag(d, None);
653                }
654                if run.eng.is_none() {
655                    self.out("(not evaluated)");
656                    return Ok(());
657                }
658                for d in &diags {
659                    self.diag(d, None);
660                }
661                if verdicts.is_empty() {
662                    self.out("(no roots)");
663                }
664                for (name, errors, warnings) in &verdicts {
665                    let n = |k: usize, w: &str| format!("{k} {w}{}", if k == 1 { "" } else { "s" });
666                    if *errors == 0 && *warnings == 0 {
667                        self.out(&format!("{name}: ok"));
668                    } else {
669                        let parts: Vec<String> = [
670                            if *errors > 0 {
671                                n(*errors, "error")
672                            } else {
673                                String::new()
674                            },
675                            if *warnings > 0 {
676                                n(*warnings, "warning")
677                            } else {
678                                String::new()
679                            },
680                        ]
681                        .into_iter()
682                        .filter(|s| !s.is_empty())
683                        .collect();
684                        self.out(&format!("{name}: {}", parts.join(", ")));
685                    }
686                }
687                Ok(())
688            }
689            ":fmt" => {
690                no_args(&rest)?;
691                let t = self.session.fmt()?;
692                if t.is_empty() {
693                    self.out("(empty session)");
694                } else {
695                    self.out(t.strip_suffix('\n').unwrap_or(&t));
696                }
697                Ok(())
698            }
699            ":type" => {
700                if rest.is_empty() {
701                    return Err(SessionError(":type expects an expression".into()));
702                }
703                let (ty, maybe_absent, diags) = self.session.type_of(&rest)?;
704                for d in &diags {
705                    self.diag(d, None);
706                }
707                self.out(&format!(
708                    "{ty}{}",
709                    if maybe_absent { "  (maybe absent)" } else { "" }
710                ));
711                Ok(())
712            }
713            ":doc" => {
714                if rest.is_empty() {
715                    return Err(SessionError(":doc expects a name".into()));
716                }
717                for l in self.session.doc_of(&rest)? {
718                    self.out(&l);
719                }
720                Ok(())
721            }
722            ":path" => {
723                if rest.is_empty() {
724                    return Err(SessionError(":path expects an expression".into()));
725                }
726                let p = self.session.path_of(&rest)?;
727                self.out(&p);
728                Ok(())
729            }
730            ":trace" => {
731                if rest.is_empty() || rest.contains(char::is_whitespace) {
732                    return Err(SessionError(":trace expects a path".into()));
733                }
734                for l in self.session.trace(&rest)? {
735                    self.out(&l);
736                }
737                Ok(())
738            }
739            ":complete" => {
740                let names = command_names();
741                let cs = self.session.complete(&rest, &names);
742                if cs.is_empty() {
743                    self.out("(no completions)");
744                }
745                for c in cs {
746                    self.out(&c);
747                }
748                Ok(())
749            }
750            ":undo" | ":redo" => {
751                let n = if rest.is_empty() {
752                    Some(1)
753                } else {
754                    js_parse_int(&rest)
755                };
756                let Some(n) = n.filter(|n| *n >= 1) else {
757                    return Err(SessionError(format!("{cmd} expects a count")));
758                };
759                let k = if cmd == ":undo" {
760                    self.session.undo(n as usize)
761                } else {
762                    self.session.redo(n as usize)
763                };
764                if k == 0 {
765                    self.out(if cmd == ":undo" {
766                        "nothing to undo"
767                    } else {
768                        "nothing to redo"
769                    });
770                }
771                Ok(())
772            }
773            ":history" => {
774                if !rest.is_empty() {
775                    let text = format!("{}\n", self.session.script_lines().join("\n"));
776                    return std::fs::write(&rest, text)
777                        .map_err(|_| SessionError(format!("cannot write {rest}")));
778                }
779                for l in self.session.history_lines() {
780                    self.out(&l);
781                }
782                Ok(())
783            }
784            ":time" => {
785                no_args(&rest)?;
786                match self.session.last_timing.get() {
787                    None => self.out("nothing evaluated yet"),
788                    Some(t) => {
789                        let step = match (t.recomputed, t.slots) {
790                            (Some(n), Some(m)) => format!(", recomputed {n} of {m} slots"),
791                            _ => String::new(),
792                        };
793                        self.out(&format!("total {:.1} ms (load {:.1} ms, check {:.1} ms, bind {:.1} ms, evaluate {:.1} ms){step}", t.total, t.load, t.check, t.bind, t.evaluate))
794                    }
795                }
796                Ok(())
797            }
798            ":set" => {
799                match rest.as_str() {
800                    "pretty" => self.compact = false,
801                    "compact" => self.compact = true,
802                    _ => return Err(SessionError(":set expects pretty or compact".into())),
803                }
804                Ok(())
805            }
806            ":help" => {
807                let want = if rest.is_empty() {
808                    None
809                } else {
810                    Some(if rest.starts_with(':') {
811                        rest.clone()
812                    } else {
813                        format!(":{rest}")
814                    })
815                };
816                let rows: Vec<&(&str, &str, &str)> = COMMANDS
817                    .iter()
818                    .filter(|c| {
819                        want.as_ref()
820                            .map(|w| c.0.split(' ').next() == Some(w.as_str()))
821                            .unwrap_or(true)
822                    })
823                    .collect();
824                if rows.is_empty() {
825                    return Err(SessionError(format!("unknown command {rest}")));
826                }
827                let mut cat = "";
828                for (form, what, c) in rows {
829                    if want.is_none() && *c != cat {
830                        cat = c;
831                        self.out(&format!("{cat}:"));
832                    }
833                    self.out(&format!("  {:<24} {what}", form));
834                }
835                Ok(())
836            }
837            ":quit" => {
838                no_args(&rest)?;
839                self.quit_requested = true;
840                Ok(())
841            }
842            _ => Err(SessionError(format!("unknown command {cmd}"))),
843        }
844    }
845}
846
847/// JavaScript's parseInt(text, 10): leading digits, or NaN (None)
848fn js_parse_int(s: &str) -> Option<i64> {
849    let t = s.trim_start();
850    let (neg, digits) = match t.strip_prefix('-') {
851        Some(r) => (true, r),
852        None => (false, t.strip_prefix('+').unwrap_or(t)),
853    };
854    let n: String = digits.chars().take_while(|c| c.is_ascii_digit()).collect();
855    if n.is_empty() {
856        return None;
857    }
858    let v: i64 = n.parse().ok()?;
859    Some(if neg { -v } else { v })
860}
861
862// ---------------- the command ----------------
863pub fn run_repl(args: Vec<String>) -> i32 {
864    let mut entry: Option<String> = None;
865    let mut script: Option<String> = None;
866    let mut compact = false;
867    let mut inputs: Vec<String> = vec![];
868    let mut i = 0;
869    while i < args.len() {
870        let a = &args[i];
871        if a == "--script" {
872            i += 1;
873            script = args.get(i).cloned();
874        } else if a == "--input" {
875            i += 1;
876            if let Some(s) = args.get(i) {
877                inputs.push(s.clone());
878            }
879        } else if a == "--compact" {
880            compact = true;
881        } else if a.starts_with("--") {
882            eprintln!("unknown option {a}");
883            return 2;
884        } else if entry.is_none() {
885            entry = Some(a.clone());
886        } else {
887            eprintln!("decl repl takes one entry file");
888            return 2;
889        }
890        i += 1;
891    }
892    if script.is_none() && entry.is_none() && !inputs.is_empty() {
893        eprintln!("--input needs an entry file");
894        return 2;
895    }
896    for spec in &inputs {
897        if !spec.contains('=') {
898            eprintln!("--input expects name=doc.json, got {spec}");
899            return 2;
900        }
901    }
902
903    let out: Box<dyn Fn(&str)> = Box::new(|l: &str| {
904        let stdout = std::io::stdout();
905        let mut h = stdout.lock();
906        let _ = writeln!(h, "{l}");
907    });
908    let mut repl = Repl::new(out, entry.as_deref());
909    repl.compact = compact;
910    for spec in &inputs {
911        repl.input(&format!(":bind {spec}"));
912    }
913
914    if let Some(script) = script {
915        let text = if script == "-" {
916            let mut s = String::new();
917            std::io::stdin().read_to_string(&mut s).map(|_| s).ok()
918        } else {
919            std::fs::read_to_string(&script).ok()
920        };
921        let Some(text) = text else {
922            eprintln!("cannot read {script}");
923            return 2;
924        };
925        let text = text.strip_suffix('\n').unwrap_or(&text).to_string();
926        for l in text.split('\n') {
927            let prompt = if repl.pending() { ". " } else { "> " };
928            println!("{prompt}{l}");
929            repl.line(l);
930            if repl.quit_requested {
931                break;
932            }
933        }
934        if repl.pending() {
935            repl.line("");
936        }
937        return if repl.errors > 0 { 1 } else { 0 };
938    }
939
940    // interactive: the line editor, with history (kept across sessions in
941    // ~/.decl_history) and completion on Tab (docs/tooling/02_repl.md §2, §7)
942    let repl = std::rc::Rc::new(std::cell::RefCell::new(repl));
943    // completion lists the candidates (the shell's way), not cycling through them
944    let config = rustyline::Config::builder()
945        .completion_type(rustyline::CompletionType::List)
946        .build();
947    let mut rl: rustyline::Editor<DeclHelper, rustyline::history::DefaultHistory> =
948        match rustyline::Editor::with_config(config) {
949            Ok(e) => e,
950            Err(_) => return run_plain(repl),
951        };
952    rl.set_helper(Some(DeclHelper { repl: repl.clone() }));
953    let history =
954        std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".decl_history"));
955    if let Some(h) = &history {
956        let _ = rl.load_history(h);
957    }
958    loop {
959        let prompt = if repl.borrow().pending() { ". " } else { "> " };
960        match rl.readline(prompt) {
961            Ok(l) => {
962                if !l.trim().is_empty() {
963                    let _ = rl.add_history_entry(l.as_str());
964                }
965                repl.borrow_mut().line(&l);
966                if repl.borrow().quit_requested {
967                    break;
968                }
969            }
970            Err(rustyline::error::ReadlineError::Interrupted) => {
971                repl.borrow_mut().discard();
972                continue;
973            }
974            Err(_) => break,
975        }
976    }
977    if let Some(h) = &history {
978        let _ = rl.save_history(h);
979    }
980    let _ = Mode::Full;
981    0
982}
983
984// without a terminal the editor cannot start: a plain line loop
985fn run_plain(repl: std::rc::Rc<std::cell::RefCell<Repl>>) -> i32 {
986    let stdin = std::io::stdin();
987    let mut lines = stdin.lock().lines();
988    loop {
989        print!("{}", if repl.borrow().pending() { ". " } else { "> " });
990        let _ = std::io::stdout().flush();
991        let Some(Ok(l)) = lines.next() else { break };
992        repl.borrow_mut().line(&l);
993        if repl.borrow().quit_requested {
994            break;
995        }
996    }
997    0
998}
999
1000/// the line editor's helper: completion from the session (the tail of the
1001/// trailing token completed, as the reference's completer does)
1002struct DeclHelper {
1003    repl: std::rc::Rc<std::cell::RefCell<Repl>>,
1004}
1005impl rustyline::completion::Completer for DeclHelper {
1006    type Candidate = rustyline::completion::Pair;
1007    fn complete(
1008        &self,
1009        line: &str,
1010        pos: usize,
1011        _ctx: &rustyline::Context<'_>,
1012    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
1013        let head = &line[..pos];
1014        let candidates: Vec<String> = self
1015            .repl
1016            .borrow_mut()
1017            .session
1018            .complete(head, &command_names())
1019            .into_iter()
1020            .map(|c| c.split("  ").next().unwrap_or("").to_string())
1021            .collect();
1022        let tok_start = head
1023            .rfind(|c: char| !(c.is_alphanumeric() || "_$:.[]\"".contains(c)))
1024            .map(|i| i + 1)
1025            .unwrap_or(0);
1026        let tok = &head[tok_start..];
1027        let tail_start = tok
1028            .rfind('.')
1029            .map(|i| tok_start + i + 1)
1030            .unwrap_or(tok_start);
1031        let tail = &head[tail_start..];
1032        let pairs = candidates
1033            .into_iter()
1034            .filter(|c| c.starts_with(tail))
1035            .map(|c| rustyline::completion::Pair {
1036                display: c.clone(),
1037                replacement: c,
1038            })
1039            .collect();
1040        Ok((tail_start, pairs))
1041    }
1042}
1043impl rustyline::hint::Hinter for DeclHelper {
1044    type Hint = String;
1045}
1046impl rustyline::highlight::Highlighter for DeclHelper {}
1047impl rustyline::validate::Validator for DeclHelper {}
1048impl rustyline::Helper for DeclHelper {}