use crate::completion;
use crate::engine::{self, Compiled};
use crate::input::{Editor, Outcome};
use crate::run::{self, Fetch};
use crate::session::{Input, MetaCmd, Session, classify};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::{Path, PathBuf};
use vim_line::history::{Recall, Store};
const PAGE_BATCH: usize = 25;
const SOLUTION_CAP: usize = 4096;
pub const PROMPT: &str = "plg> ";
pub const CONT: &str = "| ";
struct Paging {
goal: String,
solutions: Vec<String>,
pos: usize,
limit: usize,
exhausted: bool,
}
#[derive(Default)]
pub struct App {
pub session: Session,
pub input: Editor,
pub output: Vec<String>,
pub pending: String,
pub should_quit: bool,
pub should_edit: bool,
compiled: Option<Compiled>,
store: Store,
paging: Option<Paging>,
}
impl App {
pub fn new() -> Self {
let mut app = App::default();
app.load_history();
app.log("plgr — patch-prolog REPL. :help for commands, :quit to exit.");
app
}
fn log(&mut self, msg: impl Into<String>) {
self.output.push(msg.into());
}
pub fn note(&mut self, msg: impl Into<String>) {
self.log(msg);
}
pub fn apply_edit(&mut self, content: &str) {
match self.replace_session(content) {
Ok(n) => {
self.log(format!(" edited. ({n} clause(s))"));
self.recompile();
}
Err(e) => self.log(format!(" edit rejected (buffer unchanged): {e}")),
}
}
fn replace_session(&mut self, content: &str) -> Result<usize, String> {
let mut next = Session::default();
let n = next.load_source(content)?;
self.session = next;
Ok(n)
}
fn log_block(&mut self, text: &str) {
for line in text.lines() {
self.output.push(line.to_string());
}
}
pub fn is_paging(&self) -> bool {
self.paging.is_some()
}
pub fn handle_key(&mut self, key: KeyEvent) {
if self.paging.is_some() {
self.page_key(key);
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c' | 'd') if ctrl => self.should_quit = true,
KeyCode::Tab => self.complete(),
_ => match self.input.handle(key) {
Outcome::Continue => {}
Outcome::Submit(line) => self.submit(line),
Outcome::HistoryPrev => self.history_prev(),
Outcome::HistoryNext => self.history_next(),
Outcome::Cancel => self.input.clear(),
},
}
}
fn history_prev(&mut self) {
if let Some(Recall::Entry(entry)) = self.store.prev(&self.input.text()) {
let entry = entry.to_string();
self.input.set(&entry);
}
}
fn history_next(&mut self) {
let recalled = match self.store.next() {
Some(Recall::Entry(e)) | Some(Recall::Draft(e)) => Some(e.to_string()),
None => None,
};
if let Some(text) = recalled {
self.input.set(&text);
}
}
fn history_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/plgr_history"))
}
fn load_history(&mut self) {
let Some(path) = Self::history_path() else {
return;
};
if let Ok(text) = std::fs::read_to_string(&path) {
self.store.load(text.lines().filter(|l| !l.is_empty()));
}
}
pub fn save_history(&self) {
if self.store.is_empty() {
return;
}
let Some(path) = Self::history_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let body: String = self.store.entries().map(|e| format!("{e}\n")).collect();
let _ = std::fs::write(&path, body);
}
fn complete(&mut self) {
let text = self.input.text();
let prefix: String = text
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if prefix.is_empty() {
return;
}
let preds = self.session.predicate_names();
let cands = completion::candidates(&prefix, &preds);
if let Some(first) = cands.first() {
let base = &text[..text.len() - prefix.len()];
self.input.set(&format!("{base}{first}"));
}
}
fn submit(&mut self, line: String) {
if self.pending.is_empty() {
let t = line.trim();
if t.is_empty() {
return;
}
let is_meta = t.starts_with(':') && !t.starts_with(":-");
if is_meta || t.ends_with('.') {
self.dispatch(line);
} else {
self.pending = line;
}
} else {
self.pending.push('\n');
self.pending.push_str(&line);
if line.trim_end().ends_with('.') {
let entry = std::mem::take(&mut self.pending);
self.dispatch(entry);
}
}
}
fn dispatch(&mut self, entry: String) {
let kind = classify(&entry);
if matches!(kind, Input::Empty) {
return;
}
self.store.push(entry.trim());
for (i, line) in entry.trim().lines().enumerate() {
if i == 0 {
self.log(format!("{PROMPT}{line}"));
} else {
self.log(format!("{CONT}{line}"));
}
}
match kind {
Input::Empty => {}
Input::Meta(cmd) => self.meta(cmd),
Input::Query(goal) => self.run_query(&goal),
Input::Clause(c) => match self.session.load_source(&c) {
Ok(_) => {
self.recompile();
if !self.session.dirty {
self.log(format!(
" defined. ({} in session)",
self.session.clauses.len()
));
}
}
Err(e) => {
self.log(format!(" error: {e}"));
if let Some(hint) = capitalization_hint(&c) {
self.log(format!(" hint: {hint}"));
}
}
},
}
}
fn recompile(&mut self) {
match engine::compile(&self.session.source()) {
Ok(c) => {
self.compiled = Some(c);
self.session.dirty = false;
}
Err(e) => {
self.compiled = None;
self.log(" compile failed:");
self.log_block(&e);
}
}
}
fn run_query(&mut self, goal: &str) {
if self.session.dirty || self.compiled.is_none() {
self.recompile();
}
let Some(compiled) = &self.compiled else {
self.log(" no compiled program (fix the errors above)");
return;
};
match run::fetch(&compiled.binary, goal, PAGE_BATCH) {
Fetch::NoSolutions => self.log(" false."),
Fetch::Failed(e) => self.log(format!(" {e}")),
Fetch::Timeout(secs) => self.log(format!(" timed out after {secs}s")),
Fetch::Error(e) => self.log(format!(" {e}")),
Fetch::Found {
solutions,
exhausted,
} => {
self.paging = Some(Paging {
goal: goal.to_string(),
solutions,
pos: 0,
limit: PAGE_BATCH,
exhausted,
});
self.page_next();
}
}
}
fn page_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('c' | 'd')) {
self.should_quit = true;
return;
}
match key.code {
KeyCode::Char(';' | ' ') => self.page_next(),
_ => self.paging = None,
}
}
fn page_next(&mut self) {
if self.paging.is_none() {
return;
}
let need_more = {
let p = self.paging.as_ref().unwrap();
p.pos >= p.solutions.len() && !p.exhausted
};
if need_more {
let (goal, limit) = {
let p = self.paging.as_ref().unwrap();
(p.goal.clone(), p.limit)
};
let new_limit = limit.saturating_mul(2).min(SOLUTION_CAP);
if new_limit == limit {
self.log(format!(" stopped at {limit} solutions (batch cap)."));
self.paging = None;
return;
}
let Some(binary) = self.compiled.as_ref().map(|c| c.binary.clone()) else {
self.paging = None;
return;
};
match run::fetch(&binary, &goal, new_limit) {
Fetch::Found {
solutions,
exhausted,
} => {
let p = self.paging.as_mut().unwrap();
p.solutions = solutions;
p.limit = new_limit;
p.exhausted = exhausted;
}
_ => {
self.paging = None;
return;
}
}
}
let revealed = {
let p = self.paging.as_mut().unwrap();
if p.pos >= p.solutions.len() {
None
} else {
let sol = p.solutions[p.pos].clone();
p.pos += 1;
let more = p.pos < p.solutions.len() || !p.exhausted;
Some((sol, more))
}
};
match revealed {
Some((sol, more)) => {
self.log(format!(" {sol}{}", if more { " ;" } else { " ." }));
if !more {
self.paging = None;
}
}
None => {
self.log(" no more solutions.");
self.paging = None;
}
}
}
fn meta(&mut self, cmd: MetaCmd) {
match cmd {
MetaCmd::Quit => self.should_quit = true,
MetaCmd::List => {
if self.session.clauses.is_empty() {
self.log(" (empty session)");
} else {
let mut listing = String::new();
for (i, clause) in self.session.clauses.iter().enumerate() {
for (j, line) in clause.lines().enumerate() {
if j == 0 {
listing.push_str(&format!(" {:>3} {line}\n", i + 1));
} else {
listing.push_str(&format!(" {line}\n"));
}
}
}
self.log_block(&listing);
}
}
MetaCmd::Reset => {
self.session.reset();
self.compiled = None;
self.log(" session cleared");
}
MetaCmd::Load(path) => self.load_file(Path::new(&path)),
MetaCmd::Save(path) => self.save(path.as_deref()),
MetaCmd::Edit => self.should_edit = true,
MetaCmd::Help => self.log_block(HELP),
MetaCmd::Unknown(c) => self.log(format!(" unknown command: {c} (try :help)")),
}
}
pub fn load_file(&mut self, path: &Path) {
match std::fs::read_to_string(path) {
Ok(text) => match self.session.load_source(&text) {
Ok(n) => {
self.log(format!(" loaded {} ({n} clause(s))", path.display()));
self.recompile();
}
Err(e) => self.log(format!(" error loading {}: {e}", path.display())),
},
Err(e) => self.log(format!(" cannot read {}: {e}", path.display())),
}
}
fn save(&mut self, path: Option<&str>) {
let Some(path) = path else {
self.log(" :save needs a file path");
return;
};
match std::fs::write(path, self.session.source()) {
Ok(()) => self.log(format!(" saved {path}")),
Err(e) => self.log(format!(" cannot write {path}: {e}")),
}
}
}
fn capitalization_hint(entry: &str) -> Option<String> {
let head: String = entry
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let first = head.chars().next()?;
if !first.is_ascii_uppercase() {
return None;
}
let lower = head.to_lowercase();
Some(format!(
"`{head}` starts with a capital, so Prolog reads it as a variable — \
predicate names must start lowercase (did you mean `{lower}`?)"
))
}
const HELP: &str = "\
commands:
foo(a). bar(X):-foo(X). add a clause/rule (recompiles)
?- goal. run a query against the current program
:load FILE consult a .pl file into the session
:list show the session buffer
:edit edit the whole session in $EDITOR, recompile
:save FILE write the session buffer to FILE
:reset clear the session
:help / :quit this help / exit
multi-line clauses continue until a line ends with `.`";
#[cfg(test)]
mod tests {
use super::App;
#[test]
fn replace_session_rejects_bad_source_and_keeps_buffer() {
let mut app = App::default();
app.replace_session("ok(1).").unwrap();
let before = app.session.clauses.clone();
let err = app.replace_session("garbage(").unwrap_err();
assert!(!err.is_empty());
assert_eq!(
app.session.clauses, before,
"a bad edit must leave the buffer untouched"
);
}
#[test]
fn replace_session_swaps_in_split_clauses() {
let mut app = App::default();
let n = app.replace_session("foo. bar(X) :- foo.").unwrap();
assert_eq!(n, 2);
assert_eq!(app.session.clauses, ["foo.", "bar(X) :- foo."]);
}
}