Skip to main content

plg_runtime/
entry.rs

1//! Process entry: `plg_rt_init` + `plg_rt_main`, called from the thin
2//! generated `main`. Owns argv parsing (hand-rolled — no clap inside
3//! compiled binaries), output, and the v1 exit-code contract:
4//!
5//!   0 = no solutions, 1 = solutions found,
6//!   2 = query parse error, 3 = runtime error
7
8use crate::machine::{Machine, RegistryEntry, SrcLoc};
9use crate::{query, render, solve};
10use plg_shared::StringInterner;
11use std::ffi::CStr;
12use std::os::raw::c_char;
13
14/// Build the Machine from the tables codegen baked into the binary.
15/// Re-interning the emitted atom names in id order reproduces the
16/// compiler's exact id space (the interner pre-seeds the same
17/// well-known atoms the compiler's did).
18///
19/// # Safety
20/// Called once from generated `main` with codegen-emitted tables.
21#[unsafe(no_mangle)]
22pub unsafe extern "C" fn plg_rt_init(
23    atom_strs: *const *const c_char,
24    atom_count: u32,
25    registry: *const RegistryEntry,
26    registry_len: u32,
27    srcmap: *const SrcLoc,
28    srcmap_len: u32,
29    files: *const *const c_char,
30    files_len: u32,
31) -> *mut Machine {
32    let mut atoms = StringInterner::new();
33    for i in 0..atom_count as usize {
34        let s = unsafe { CStr::from_ptr(*atom_strs.add(i)) };
35        let expected = i as u32;
36        let id = atoms.intern(&s.to_string_lossy());
37        debug_assert_eq!(id, expected, "atom table out of sync with interner");
38    }
39    let registry: Vec<RegistryEntry> =
40        unsafe { std::slice::from_raw_parts(registry, registry_len as usize) }.to_vec();
41    debug_assert!(
42        registry.is_sorted_by_key(|e| (e.functor, e.arity)),
43        "registry must be sorted for binary search"
44    );
45    // Source-location side-table (SPANS.md Layer 3). Both tables are empty
46    // (`len == 0`) for binaries built without provenance.
47    let srcmap: Vec<SrcLoc> = if srcmap_len == 0 {
48        Vec::new()
49    } else {
50        unsafe { std::slice::from_raw_parts(srcmap, srcmap_len as usize) }.to_vec()
51    };
52    let files: Vec<String> = (0..files_len as usize)
53        .map(|i| {
54            unsafe { CStr::from_ptr(*files.add(i)) }
55                .to_string_lossy()
56                .into_owned()
57        })
58        .collect();
59    let mut m = Machine::new(atoms, registry);
60    m.set_provenance(srcmap, files);
61    Box::into_raw(m)
62}
63
64struct Args {
65    query: String,
66    limit: Option<usize>,
67    format: String,
68}
69
70fn parse_args(argv: Vec<String>) -> Result<Args, String> {
71    let mut query = None;
72    let mut limit = None;
73    let mut format = "json".to_string(); // v1 default
74    let mut it = argv.into_iter().peekable();
75    while let Some(arg) = it.next() {
76        let (flag, inline_value) = match arg.split_once('=') {
77            Some((f, v)) => (f.to_string(), Some(v.to_string())),
78            None => (arg, None),
79        };
80        let value = |it: &mut std::iter::Peekable<std::vec::IntoIter<String>>| {
81            inline_value
82                .clone()
83                .or_else(|| it.next())
84                .ok_or(format!("missing value for {flag}"))
85        };
86        match flag.as_str() {
87            "-q" | "--query" => query = Some(value(&mut it)?),
88            "-l" | "--limit" => {
89                limit = Some(
90                    value(&mut it)?
91                        .parse::<usize>()
92                        .map_err(|_| "invalid --limit value".to_string())?,
93                )
94            }
95            "-f" | "--format" => format = value(&mut it)?,
96            "-h" | "--help" => {
97                return Err("usage: --query <goal> [--limit N] [--format json|text]".to_string());
98            }
99            other => return Err(format!("unexpected argument: {other}")),
100        }
101    }
102    let query = query.ok_or("missing required argument: --query <goal>".to_string())?;
103    Ok(Args {
104        query,
105        limit,
106        format,
107    })
108}
109
110/// v1's output_error: JSON errors go to stdout, text errors to stderr.
111fn output_error(format: &str, message: &str) {
112    if format == "json" {
113        println!("{{\"error\":\"{}\"}}", render::json_escape(message));
114    } else {
115        eprintln!("Error: {message}");
116    }
117}
118
119fn output_json(m: &Machine, exhausted: bool) {
120    let solutions: Vec<String> = m
121        .solutions
122        .iter()
123        .map(|sol| {
124            let fields: Vec<String> = sol
125                .bindings
126                .iter()
127                .map(|(name, json, _)| format!("\"{}\":{}", render::json_escape(name), json))
128                .collect();
129            format!("{{{}}}", fields.join(","))
130        })
131        .collect();
132    // serde_json sorted keys: count < exhausted < solutions
133    println!(
134        "{{\"count\":{},\"exhausted\":{},\"solutions\":[{}]}}",
135        m.solutions.len(),
136        exhausted,
137        solutions.join(",")
138    );
139}
140
141fn output_text(m: &Machine) {
142    if m.solutions.is_empty() {
143        println!("false.");
144        return;
145    }
146    for sol in &m.solutions {
147        if sol.bindings.is_empty() {
148            println!("true.");
149        } else {
150            for (name, _, text) in &sol.bindings {
151                println!("{name} = {text}");
152            }
153        }
154    }
155}
156
157/// Run the query named in argv against the compiled program. Returns
158/// the process exit code.
159///
160/// # Safety
161/// Called once from generated `main` with the process argc/argv.
162#[unsafe(no_mangle)]
163pub unsafe extern "C" fn plg_rt_main(
164    m: *mut Machine,
165    argc: i32,
166    argv: *const *const c_char,
167) -> i32 {
168    let m = unsafe { &mut *m };
169    let raw_args: Vec<String> = (1..argc as usize)
170        .map(|i| {
171            unsafe { CStr::from_ptr(*argv.add(i)) }
172                .to_string_lossy()
173                .into_owned()
174        })
175        .collect();
176
177    let args = match parse_args(raw_args) {
178        Ok(a) => a,
179        Err(e) => {
180            eprintln!("{e}");
181            return 2;
182        }
183    };
184    if args.format != "json" && args.format != "text" {
185        output_error("text", &format!("Unknown format: {}", args.format));
186        return 2;
187    }
188    m.solution_limit = args.limit;
189    // Documented extension over v1 (which hardcoded 10_000): the step
190    // ceiling is tunable via environment so big-but-legitimate queries
191    // can raise it without changing the CLI contract.
192    if let Ok(s) = std::env::var("PLG_MAX_STEPS")
193        && let Ok(n) = s.parse::<u64>()
194    {
195        m.step_limit = n;
196    }
197
198    let goal = match query::parse_query(m, &args.query) {
199        Ok(g) => g,
200        Err(e) => {
201            output_error(&args.format, &format!("Parse error: {e}"));
202            return 2;
203        }
204    };
205
206    match solve::solve(m, goal) {
207        solve::Outcome::Error => {
208            let msg = m.error.take().map(|e| e.message).unwrap_or_default();
209            output_error(&args.format, &format!("Runtime error: {msg}"));
210            3
211        }
212        solve::Outcome::Done => {
213            let count = m.solutions.len();
214            let exhausted = args.limit.is_none_or(|l| count < l);
215            match args.format.as_str() {
216                "json" => output_json(m, exhausted),
217                _ => output_text(m),
218            }
219            if count > 0 { 1 } else { 0 }
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn args(v: &[&str]) -> Result<Args, String> {
229        parse_args(v.iter().map(|s| s.to_string()).collect())
230    }
231
232    #[test]
233    fn parses_flags_with_space_and_equals() {
234        let a = args(&["--query", "p(X)", "--limit", "3", "--format", "text"]).unwrap();
235        assert_eq!(a.query, "p(X)");
236        assert_eq!(a.limit, Some(3));
237        assert_eq!(a.format, "text");
238
239        let a = args(&["--query=p(X)", "-l", "1"]).unwrap();
240        assert_eq!(a.query, "p(X)");
241        assert_eq!(a.limit, Some(1));
242        assert_eq!(a.format, "json", "default format is json (v1)");
243    }
244
245    #[test]
246    fn missing_query_is_an_error() {
247        assert!(args(&["--format", "json"]).is_err());
248        assert!(args(&["--query"]).is_err());
249        assert!(args(&["--bogus", "x"]).is_err());
250    }
251}