Skip to main content

panproto_repl/
lib.rs

1//! Interactive REPL driver for panproto theories, terms, and morphisms.
2//!
3//! The binary (`panproto-repl`) wires [`Repl::handle_line`] to
4//! `rustyline`. Tests drive the same entry point with a scripted input
5//! stream so no terminal is required.
6//!
7//! ## Commands
8//!
9//! Commands start with `:`. Bare input (no leading `:`) is treated as a
10//! term to typecheck in the active theory.
11//!
12//! - `:load <path>`: load a theory document.
13//! - `:theories`: list loaded theories.
14//! - `:use <name>`: switch the active theory.
15//! - `:sorts`, `:ops`: list the active theory's sorts and ops.
16//! - `:type <term>`: infer the sort of a term.
17//! - `:normalize <term>`: normalize a term under the active theory's
18//!   directed equations.
19//! - `:model [depth]`: print terms in each fiber from the active
20//!   theory's free model.
21//! - `:instance <class> in <target> { <bindings> }`: compile an ad-hoc
22//!   instance morphism.
23//! - `:quit` / `:q`: leave the REPL.
24
25#![allow(clippy::too_many_lines)]
26
27use std::collections::HashMap;
28use std::path::Path;
29
30use panproto_gat::{
31    FreeModelConfig, Theory, TheoryMorphism, VarContext, free_model, normalize, typecheck_term,
32};
33use panproto_theory_dsl::compile_theory::parse_term;
34use panproto_theory_dsl::{builtin_resolver, load_and_compile};
35
36/// The REPL driver state. Holds loaded theories, the currently active
37/// theory name, and any compiled morphisms.
38#[derive(Default)]
39pub struct Repl {
40    /// Loaded theories, keyed by name.
41    pub theories: HashMap<String, Theory>,
42    /// Loaded morphisms, keyed by name.
43    pub morphisms: HashMap<String, TheoryMorphism>,
44    /// Currently active theory name, if any.
45    pub active: Option<String>,
46}
47
48/// Outcome of processing a single line.
49#[derive(Debug, Clone)]
50pub enum ReplOutcome {
51    /// Normal output to display.
52    Output(String),
53    /// An error message to display.
54    Error(String),
55    /// The user asked the REPL to quit.
56    Quit,
57}
58
59impl Repl {
60    /// Construct a fresh, empty REPL.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Process a single input line. Returns the outcome that the host
67    /// should display.
68    #[must_use]
69    pub fn handle_line(&mut self, line: &str) -> ReplOutcome {
70        let trimmed = line.trim();
71        if trimmed.is_empty() {
72            return ReplOutcome::Output(String::new());
73        }
74        if let Some(rest) = trimmed.strip_prefix(':') {
75            self.handle_command(rest.trim())
76        } else {
77            self.handle_term_typecheck(trimmed)
78        }
79    }
80
81    fn handle_command(&mut self, rest: &str) -> ReplOutcome {
82        let (head, tail) = split_first_word(rest);
83        match head {
84            "quit" | "q" => ReplOutcome::Quit,
85            "load" => self.cmd_load(tail),
86            "theories" => self.cmd_theories(),
87            "use" => self.cmd_use(tail),
88            "sorts" => self.cmd_sorts(),
89            "ops" => self.cmd_ops(),
90            "type" => self.cmd_type(tail),
91            "normalize" => self.cmd_normalize(tail),
92            "model" => self.cmd_model(tail),
93            "instance" => self.cmd_instance(tail),
94            other => ReplOutcome::Error(format!("unknown command :{other}")),
95        }
96    }
97
98    fn cmd_load(&mut self, path: &str) -> ReplOutcome {
99        if path.is_empty() {
100            return ReplOutcome::Error("usage: :load <path>".to_string());
101        }
102        let p = Path::new(path);
103        let resolver = builtin_resolver();
104        match load_and_compile(p, &resolver) {
105            Ok(set) => {
106                let mut loaded: Vec<String> = Vec::new();
107                for (name, theory) in set.theories {
108                    loaded.push(name.clone());
109                    self.theories.insert(name, theory);
110                }
111                for (name, morph) in set.morphisms {
112                    self.morphisms.insert(name, morph);
113                }
114                if self.active.is_none() {
115                    self.active = loaded.first().cloned();
116                }
117                let msg = if loaded.is_empty() {
118                    "loaded no theories".to_string()
119                } else {
120                    format!("loaded: {}", loaded.join(", "))
121                };
122                ReplOutcome::Output(msg)
123            }
124            Err(e) => ReplOutcome::Error(format!("load failed: {e}")),
125        }
126    }
127
128    fn cmd_theories(&self) -> ReplOutcome {
129        if self.theories.is_empty() {
130            return ReplOutcome::Output("(no theories loaded)".to_string());
131        }
132        let mut names: Vec<&String> = self.theories.keys().collect();
133        names.sort();
134        let marked: Vec<String> = names
135            .into_iter()
136            .map(|n| {
137                if self.active.as_deref() == Some(n.as_str()) {
138                    format!("* {n}")
139                } else {
140                    format!("  {n}")
141                }
142            })
143            .collect();
144        ReplOutcome::Output(marked.join("\n"))
145    }
146
147    fn cmd_use(&mut self, name: &str) -> ReplOutcome {
148        if name.is_empty() {
149            return ReplOutcome::Error("usage: :use <theory>".to_string());
150        }
151        if !self.theories.contains_key(name) {
152            return ReplOutcome::Error(format!("theory '{name}' not loaded"));
153        }
154        self.active = Some(name.to_string());
155        ReplOutcome::Output(format!("active theory: {name}"))
156    }
157
158    fn active_theory(&self) -> Result<&Theory, ReplOutcome> {
159        let name = self.active.as_ref().ok_or_else(|| {
160            ReplOutcome::Error("no active theory; use :load and :use".to_string())
161        })?;
162        self.theories
163            .get(name)
164            .ok_or_else(|| ReplOutcome::Error(format!("active theory '{name}' missing")))
165    }
166
167    fn cmd_sorts(&self) -> ReplOutcome {
168        let theory = match self.active_theory() {
169            Ok(t) => t,
170            Err(e) => return e,
171        };
172        if theory.sorts.is_empty() {
173            return ReplOutcome::Output("(no sorts)".to_string());
174        }
175        let lines: Vec<String> = theory
176            .sorts
177            .iter()
178            .map(|s| {
179                if s.params.is_empty() {
180                    s.name.to_string()
181                } else {
182                    let params: Vec<String> = s
183                        .params
184                        .iter()
185                        .map(|p| format!("{}: {}", p.name, p.sort))
186                        .collect();
187                    format!("{}({})", s.name, params.join(", "))
188                }
189            })
190            .collect();
191        ReplOutcome::Output(lines.join("\n"))
192    }
193
194    fn cmd_ops(&self) -> ReplOutcome {
195        let theory = match self.active_theory() {
196            Ok(t) => t,
197            Err(e) => return e,
198        };
199        if theory.ops.is_empty() {
200            return ReplOutcome::Output("(no ops)".to_string());
201        }
202        let lines: Vec<String> = theory
203            .ops
204            .iter()
205            .map(|op| {
206                let inputs: Vec<String> = op
207                    .inputs
208                    .iter()
209                    .map(|(n, s, _)| format!("{n}: {s}"))
210                    .collect();
211                format!("{}({}) -> {}", op.name, inputs.join(", "), op.output)
212            })
213            .collect();
214        ReplOutcome::Output(lines.join("\n"))
215    }
216
217    fn cmd_type(&self, term_src: &str) -> ReplOutcome {
218        let theory = match self.active_theory() {
219            Ok(t) => t,
220            Err(e) => return e,
221        };
222        let term = match parse_term(term_src) {
223            Ok(t) => t,
224            Err(e) => return ReplOutcome::Error(format!("parse: {e}")),
225        };
226        let ctx = VarContext::default();
227        match typecheck_term(&term, &ctx, theory) {
228            Ok(sort) => ReplOutcome::Output(format!("{term} : {sort}")),
229            Err(e) => ReplOutcome::Error(format!("typecheck: {e}")),
230        }
231    }
232
233    fn cmd_normalize(&self, term_src: &str) -> ReplOutcome {
234        let theory = match self.active_theory() {
235            Ok(t) => t,
236            Err(e) => return e,
237        };
238        let term = match parse_term(term_src) {
239            Ok(t) => t,
240            Err(e) => return ReplOutcome::Error(format!("parse: {e}")),
241        };
242        let nf = normalize(&term, &theory.directed_eqs, 1000);
243        ReplOutcome::Output(format!("{nf}"))
244    }
245
246    fn cmd_model(&self, tail: &str) -> ReplOutcome {
247        const MAX_MODEL_DEPTH: usize = 10;
248        let theory = match self.active_theory() {
249            Ok(t) => t,
250            Err(e) => return e,
251        };
252        let depth = if tail.is_empty() {
253            3
254        } else {
255            match tail.parse::<usize>() {
256                Ok(d) if d <= MAX_MODEL_DEPTH => d,
257                Ok(d) => {
258                    return ReplOutcome::Error(format!(
259                        "depth {d} exceeds maximum {MAX_MODEL_DEPTH}; free-model expansion is \
260                         exponential so :model caps the depth to keep the REPL responsive",
261                    ));
262                }
263                Err(_) => return ReplOutcome::Error(format!("invalid depth: {tail}")),
264            }
265        };
266        let cfg = FreeModelConfig {
267            max_depth: depth,
268            ..FreeModelConfig::default()
269        };
270        match free_model(theory, &cfg) {
271            Ok(result) => {
272                let mut lines: Vec<String> = Vec::new();
273                for (sort_name, carrier) in &result.model.sort_interp {
274                    let preview: Vec<String> =
275                        carrier.iter().take(5).map(|v| format!("{v:?}")).collect();
276                    lines.push(format!(
277                        "{sort_name}: [{}{suffix}]",
278                        preview.join(", "),
279                        suffix = if carrier.len() > 5 { ", ..." } else { "" },
280                    ));
281                }
282                ReplOutcome::Output(lines.join("\n"))
283            }
284            Err(e) => ReplOutcome::Error(format!("free model failed: {e}")),
285        }
286    }
287
288    fn cmd_instance(&mut self, rest: &str) -> ReplOutcome {
289        // Parse the surface syntax `<class> in <target> { key = value; ... }`
290        // into an InstanceSpec and delegate to the DSL's compile_instance
291        // rather than duplicating the class-sort / class-op classification
292        // logic here.
293        let Some(brace_pos) = rest.find('{') else {
294            return ReplOutcome::Error(
295                "usage: :instance <class> in <target> { binding = target; ... }".to_string(),
296            );
297        };
298        let header = rest[..brace_pos].trim();
299        let body = rest[brace_pos + 1..].trim().trim_end_matches('}');
300        let Some(in_pos) = header.find(" in ") else {
301            return ReplOutcome::Error("expected `<class> in <target>` before `{`".to_string());
302        };
303        let class_name = header[..in_pos].trim();
304        let target_name = header[in_pos + 4..].trim();
305
306        let mut bindings: HashMap<String, String> = HashMap::new();
307        for entry in body.split(';') {
308            let e = entry.trim();
309            if e.is_empty() {
310                continue;
311            }
312            let Some(eq_pos) = e.find('=') else {
313                return ReplOutcome::Error(format!("binding missing `=`: {e}"));
314            };
315            let from = e[..eq_pos].trim();
316            let to = e[eq_pos + 1..].trim();
317            bindings.insert(from.to_string(), to.to_string());
318        }
319
320        let instance_name = format!("{class_name}_to_{target_name}");
321        let spec = panproto_theory_dsl::document::InstanceSpec {
322            instance: instance_name,
323            class: class_name.to_string(),
324            target: target_name.to_string(),
325            bindings,
326        };
327        let resolver = |name: &str| self.theories.get(name).cloned();
328        match panproto_theory_dsl::compile_instance::compile_instance(&spec, &resolver) {
329            Ok(morphism) => {
330                let name = morphism.name.to_string();
331                self.morphisms.insert(name.clone(), morphism);
332                ReplOutcome::Output(format!("instance {name} ok"))
333            }
334            Err(e) => ReplOutcome::Error(format!("instance check failed: {e}")),
335        }
336    }
337
338    fn handle_term_typecheck(&self, src: &str) -> ReplOutcome {
339        self.cmd_type(src)
340    }
341}
342
343fn split_first_word(s: &str) -> (&str, &str) {
344    s.find(char::is_whitespace)
345        .map_or((s, ""), |i| (s[..i].trim(), s[i..].trim()))
346}