use plg_frontend::{Parser, SourceMap, TokenKind, Tokenizer};
use plg_shared::StringInterner;
pub enum Input {
Clause(String),
Query(String),
Meta(MetaCmd),
Empty,
}
pub enum MetaCmd {
Load(String),
List,
Reset,
Save(Option<String>),
Edit,
Help,
Quit,
Unknown(String),
}
pub struct CommandSpec {
pub name: &'static str,
pub aliases: &'static [&'static str],
}
impl CommandSpec {
fn matches(&self, token: &str) -> bool {
self.name == token || self.aliases.contains(&token)
}
}
pub const COMMANDS: &[CommandSpec] = &[
CommandSpec {
name: "quit",
aliases: &["q"],
},
CommandSpec {
name: "load",
aliases: &["l"],
},
CommandSpec {
name: "list",
aliases: &["ls"],
},
CommandSpec {
name: "reset",
aliases: &[],
},
CommandSpec {
name: "save",
aliases: &[],
},
CommandSpec {
name: "edit",
aliases: &["e"],
},
CommandSpec {
name: "help",
aliases: &["h"],
},
];
pub fn classify(entry: &str) -> Input {
let t = entry.trim();
if t.is_empty() {
return Input::Empty;
}
if let Some(rest) = t.strip_prefix("?-") {
let goal = rest.trim().trim_end_matches('.').trim();
return Input::Query(goal.to_string());
}
if t.starts_with(":-") {
return Input::Clause(t.to_string());
}
if let Some(rest) = t.strip_prefix(':') {
return Input::Meta(parse_meta(rest));
}
Input::Clause(t.to_string())
}
fn parse_meta(s: &str) -> MetaCmd {
let mut it = s.split_whitespace();
let arg = |it: &mut std::str::SplitWhitespace| it.next().map(str::to_string);
let head = it.next().unwrap_or("");
let Some(spec) = COMMANDS.iter().find(|c| c.matches(head)) else {
return MetaCmd::Unknown(head.to_string());
};
match spec.name {
"quit" => MetaCmd::Quit,
"load" => match arg(&mut it) {
Some(f) => MetaCmd::Load(f),
None => MetaCmd::Unknown(":load needs a file path".into()),
},
"list" => MetaCmd::List,
"reset" => MetaCmd::Reset,
"save" => MetaCmd::Save(arg(&mut it)),
"edit" => MetaCmd::Edit,
"help" => MetaCmd::Help,
_ => unreachable!("COMMANDS names a command parse_meta doesn't handle"),
}
}
#[derive(Default)]
pub struct Session {
pub clauses: Vec<String>,
pub dirty: bool,
}
impl Session {
pub fn source(&self) -> String {
let mut s = self.clauses.join("\n");
s.push('\n');
s
}
pub fn load_source(&mut self, text: &str) -> Result<usize, String> {
let mut interner = StringInterner::new();
Parser::parse_program_with_directives(text, &mut interner).map_err(|e| {
let (line, col) = SourceMap::new(text).line_col(e.span.lo);
format!("line {line} col {col}: {}", e.message)
})?;
let clauses = split_clauses(text);
let n = clauses.len();
self.clauses.extend(clauses);
if n > 0 {
self.dirty = true;
}
Ok(n)
}
pub fn predicate_names(&self) -> Vec<String> {
let mut interner = StringInterner::new();
let Ok((clauses, directives)) =
Parser::parse_program_with_directives(&self.source(), &mut interner)
else {
return Vec::new();
};
let mut names: Vec<String> = clauses
.iter()
.filter_map(|c| c.head.functor_arity())
.map(|(id, _)| interner.resolve(id).to_string())
.chain(
directives
.dynamic
.iter()
.map(|(id, _)| interner.resolve(*id).to_string()),
)
.collect();
names.sort();
names.dedup();
names
}
pub fn reset(&mut self) {
self.clauses.clear();
self.dirty = false;
}
}
pub fn split_clauses(src: &str) -> Vec<String> {
let Ok(tokens) = Tokenizer::tokenize(src) else {
return vec![src.trim().to_string()];
};
let mut clauses = Vec::new();
let mut start = 0;
let mut has_content = false;
for tok in &tokens {
match tok.kind {
TokenKind::Dot => {
let end = byte_offset(src, tok.line, tok.col) + 1;
if has_content {
let chunk = src[start..end].trim();
if !chunk.is_empty() {
clauses.push(chunk.to_string());
}
}
start = end;
has_content = false;
}
TokenKind::Eof => break,
_ => has_content = true,
}
}
clauses
}
fn byte_offset(src: &str, line: usize, col: usize) -> usize {
let mut offset = 0;
for (i, l) in src.split_inclusive('\n').enumerate() {
if i + 1 == line {
return offset + col - 1;
}
offset += l.len();
}
offset
}
#[cfg(test)]
mod tests {
use super::{COMMANDS, CommandSpec, MetaCmd, Session, parse_meta, split_clauses};
#[test]
fn predicate_names_cover_heads_and_dynamic_decls() {
let mut s = Session::default();
s.load_source(
"parent(tom, bob).\nancestor(X, Y) :- parent(X, Y).\n:- dynamic(extra/1).\ntest.",
)
.unwrap();
assert_eq!(s.predicate_names(), ["ancestor", "extra", "parent", "test"]);
}
#[test]
fn splits_multiple_clauses_in_order() {
let got = split_clauses("fact(1).\nfact(2).\ntest :- fact(1).\n");
assert_eq!(got, ["fact(1).", "fact(2).", "test :- fact(1)."]);
}
#[test]
fn directive_is_its_own_clause() {
let got = split_clauses(":- dynamic(fruit/1).\nfruit(apple).");
assert_eq!(got, [":- dynamic(fruit/1).", "fruit(apple)."]);
}
#[test]
fn float_dot_is_not_a_boundary() {
assert_eq!(
split_clauses("pi(3.14).\ne(2.71)."),
["pi(3.14).", "e(2.71)."]
);
}
#[test]
fn dot_inside_quoted_atom_is_not_a_boundary() {
assert_eq!(split_clauses("p('a.b').\nq(x)."), ["p('a.b').", "q(x)."]);
}
#[test]
fn keeps_clauses_spanning_multiple_lines() {
let got = split_clauses("a(X) :-\n b(X),\n c(X).\nd.");
assert_eq!(got, ["a(X) :-\n b(X),\n c(X).", "d."]);
}
#[test]
fn skips_comment_only_and_blank_spans() {
let got = split_clauses("% header\nfoo.\n% trailing\n");
assert_eq!(got, ["% header\nfoo."]);
}
#[test]
fn block_comment_does_not_split_and_rides_with_clause() {
assert_eq!(
split_clauses("p. /* between */ q."),
["p.", "/* between */ q."]
);
assert_eq!(split_clauses("p(/* . */ x)."), ["p(/* . */ x)."]);
}
#[test]
fn commands_and_parse_meta_are_in_sync() {
for spec in COMMANDS {
for token in std::iter::once(spec.name).chain(spec.aliases.iter().copied()) {
let cmd = parse_meta(&format!("{token} _arg"));
assert!(!matches!(cmd, MetaCmd::Unknown(_)), "{token:?} → Unknown");
assert_eq!(canonical_of(cmd), spec.name, "{token:?} → wrong canonical");
}
}
assert!(matches!(parse_meta("nope _arg"), MetaCmd::Unknown(_)));
}
fn canonical_of(cmd: MetaCmd) -> &'static str {
match cmd {
MetaCmd::Quit => "quit",
MetaCmd::Load(_) => "load",
MetaCmd::List => "list",
MetaCmd::Reset => "reset",
MetaCmd::Save(_) => "save",
MetaCmd::Edit => "edit",
MetaCmd::Help => "help",
MetaCmd::Unknown(_) => "",
}
}
#[test]
fn commands_table_is_the_documented_surface() {
let names: Vec<&str> = COMMANDS.iter().map(|c| c.name).collect();
assert_eq!(
names,
["quit", "load", "list", "reset", "save", "edit", "help"]
);
let load = CommandSpec {
name: "load",
aliases: &["l"],
};
assert!(load.matches("load") && load.matches("l"));
assert!(!load.matches("list"));
}
}