use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_SECS: u64 = 10;
#[derive(Debug)]
pub enum Fetch {
Found {
solutions: Vec<String>,
exhausted: bool,
},
NoSolutions,
Failed(String),
Timeout(u64),
Error(String),
}
enum Raw {
Done {
stdout: String,
stderr: String,
code: i32,
},
Timeout(u64),
SpawnError(String),
}
fn timeout() -> Duration {
let secs = std::env::var("PLG_REPL_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
Duration::from_secs(secs)
}
fn query_args(goal: &str, limit: usize) -> [String; 6] {
[
"--query".to_string(),
goal.to_string(),
"--limit".to_string(),
limit.to_string(),
"--format".to_string(),
"text".to_string(),
]
}
pub fn fetch(binary: &Path, goal: &str, limit: usize) -> Fetch {
let raw = match run_raw(binary, &query_args(goal, limit)) {
Raw::Done {
stdout,
stderr,
code,
} => (stdout, stderr, code),
Raw::Timeout(t) => return Fetch::Timeout(t),
Raw::SpawnError(e) => return Fetch::Error(e),
};
match raw.2 {
0 => Fetch::NoSolutions,
1 => match split_text_solutions(&raw.0) {
Some(solutions) if solutions.is_empty() => Fetch::NoSolutions,
Some(solutions) => Fetch::Found {
exhausted: solutions.len() < limit,
solutions,
},
None => Fetch::Failed("malformed query output".to_string()),
},
2 | 3 => Fetch::Failed(error_message(&raw.0, &raw.1)),
other => Fetch::Failed(format!("unexpected exit code {other}")),
}
}
fn error_message(stdout: &str, stderr: &str) -> String {
if let Some(rest) = stdout.trim().strip_prefix("error: ") {
return rest.to_string();
}
if !stderr.trim().is_empty() {
return stderr.trim().to_string();
}
stdout.trim().to_string()
}
fn split_text_solutions(text: &str) -> Option<Vec<String>> {
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() || lines == ["false."] {
return Some(Vec::new());
}
if lines.iter().all(|l| *l == "true.") {
return Some(lines.iter().map(|l| l.to_string()).collect());
}
let first_lhs = lhs_of(lines.first()?)?;
let per = (1..lines.len())
.find(|&i| lhs_of(lines[i]) == Some(first_lhs))
.unwrap_or(lines.len());
if !lines.len().is_multiple_of(per) {
return None;
}
Some(lines.chunks(per).map(|c| c.join(", ")).collect())
}
fn lhs_of(line: &str) -> Option<&str> {
line.split_once(" = ").map(|(lhs, _)| lhs)
}
fn run_raw(path: &Path, args: &[String]) -> Raw {
let mut child = match Command::new(path)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return Raw::SpawnError(format!("failed to start: {e}")),
};
let limit = timeout();
let start = Instant::now();
let poll = Duration::from_millis(50);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = drain(child.stdout.take());
let stderr = drain(child.stderr.take());
return Raw::Done {
stdout,
stderr,
code: status.code().unwrap_or(-1),
};
}
Ok(None) => {
if start.elapsed() >= limit {
let _ = child.kill();
let _ = child.wait();
return Raw::Timeout(limit.as_secs());
}
std::thread::sleep(poll);
}
Err(e) => return Raw::SpawnError(format!("wait error: {e}")),
}
}
}
fn drain<R: Read>(pipe: Option<R>) -> String {
pipe.map(|mut r| {
let mut buf = String::new();
let _ = r.read_to_string(&mut buf);
buf
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{error_message, lhs_of, split_text_solutions};
use crate::engine;
use crate::run::{Fetch, fetch};
use std::path::{Path, PathBuf};
#[test]
fn splits_multi_var_solutions_into_groups() {
let text = "X = tom\nY = bob\nX = bob\nY = ann\nX = ann\nY = sue";
assert_eq!(
split_text_solutions(text).unwrap(),
["X = tom, Y = bob", "X = bob, Y = ann", "X = ann, Y = sue"]
);
}
#[test]
fn splits_single_var_one_per_line() {
assert_eq!(
split_text_solutions("X = 1\nX = 2").unwrap(),
["X = 1", "X = 2"]
);
}
#[test]
fn all_anonymous_solutions_are_true() {
assert_eq!(
split_text_solutions("true.\ntrue.").unwrap(),
["true.", "true."]
);
}
#[test]
fn no_solutions_is_empty_not_none() {
assert_eq!(
split_text_solutions("false.").unwrap(),
Vec::<String>::new()
);
assert_eq!(split_text_solutions("").unwrap(), Vec::<String>::new());
}
#[test]
fn value_containing_equals_is_kept_intact() {
assert_eq!(
split_text_solutions("X = a = b\nX = c").unwrap(),
["X = a = b", "X = c"]
);
}
#[test]
fn partial_trailing_group_is_none() {
assert_eq!(
split_text_solutions("X = 1\nY = 2\nX = 3\nY = 4\nX = 5"),
None
);
}
#[test]
fn lhs_of_splits_on_first_equals() {
assert_eq!(lhs_of("X = a"), Some("X"));
assert_eq!(lhs_of("X = a = b"), Some("X"));
assert_eq!(lhs_of("true."), None);
}
#[test]
fn error_message_prefers_stdout_then_stderr() {
assert_eq!(
error_message("error: Parse error: boom\n", ""),
"Parse error: boom"
);
assert_eq!(
error_message("", "Unknown or undeclared format: bson"),
"Unknown or undeclared format: bson"
);
assert_eq!(error_message(" spaced ", ""), "spaced");
}
fn locate_plgc() -> PathBuf {
if let Ok(p) = std::env::var("PLGC") {
return PathBuf::from(p);
}
for profile in ["debug", "release"] {
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../target")
.join(profile)
.join("plgc");
if p.exists() {
return p;
}
}
panic!("plgc not found: run `cargo build -p patch-prolog-compiler` or set $PLGC");
}
#[test]
fn fetch_runs_a_real_compiled_binary() {
unsafe {
std::env::set_var("PLGC", locate_plgc());
}
let src = "membercheck(X, [X|_]) :- !.\n\
membercheck(X, [_|L]) :- membercheck(X, L).\n";
let compiled = engine::compile(src).expect("compile session");
match fetch(&compiled.binary, "X = a, membercheck(X, [a, b, c])", 16) {
Fetch::Found {
solutions,
exhausted,
} => {
assert_eq!(solutions, ["X = a"]);
assert!(exhausted, "1 < 16 ⇒ fully explored");
}
other => panic!("bound-var query: expected Found, got {other:?}"),
}
assert!(matches!(
fetch(&compiled.binary, "membercheck(z, [a, b, c])", 16),
Fetch::NoSolutions,
));
let multi = engine::compile("f(1). f(2). f(3).").unwrap();
match fetch(&multi.binary, "f(X)", 16) {
Fetch::Found {
solutions,
exhausted,
} => {
assert_eq!(solutions, ["X = 1", "X = 2", "X = 3"]);
assert!(exhausted, "3 < 16 ⇒ fully explored");
}
other => panic!("multi: expected Found, got {other:?}"),
}
match fetch(&multi.binary, "f(X)", 2) {
Fetch::Found {
solutions,
exhausted,
} => {
assert_eq!(solutions, ["X = 1", "X = 2"]);
assert!(!exhausted, "filled the limit ⇒ maybe more");
}
other => panic!("truncated: expected Found, got {other:?}"),
}
match fetch(&multi.binary, "f(X,,", 16) {
Fetch::Failed(msg) => assert!(msg.contains("Parse"), "got: {msg}"),
other => panic!("parse error: expected Failed, got {other:?}"),
}
}
}