Skip to main content

quarb/pipeline/
library.rs

1//! Command libraries (spec: Command Libraries). A library extends
2//! the language with commands as an adapter extends it with
3//! sources: a native unit, linked into the engine, whose commands
4//! are declared before any query runs and reached by the library's
5//! name as a prefix, `difflib:ratio("Tom")`, exactly as an
6//! environment command carries its mount alias. A library needs no
7//! mount: its commands are pure in the standard library's sense, so
8//! a query using them runs the same wherever the library is linked
9//! and is refused, naming the library, where it is not.
10//!
11//! The trait is the deliverable as much as any library: the
12//! environment kinds (`python:`, `perl:`) will fill the same
13//! synopsis for the commands they bind at mount time, and a native
14//! library of DH algorithms the same again.
15
16use crate::syntax::ast::{Arg, FnCall, Operand};
17use crate::value::Value;
18
19/// Which pipe a command rides.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Pipe {
22    /// `| lib:cmd` — once per capsa, the topic the input.
23    Plain,
24    /// `@| lib:cmd` — once over the context, the stream the input.
25    Agg,
26    Both,
27}
28
29impl Pipe {
30    pub fn admits(self, agg: bool) -> bool {
31        match self {
32            Pipe::Plain => !agg,
33            Pipe::Agg => agg,
34            Pipe::Both => true,
35        }
36    }
37}
38
39/// One operand of a command's synopsis, in order.
40#[derive(Debug, Clone, Copy)]
41pub struct OperandSpec {
42    pub name: &'static str,
43    pub doc: &'static str,
44    /// The values the operand accepts, empty for any value.
45    pub values: &'static [&'static str],
46    pub required: bool,
47}
48
49/// One option of a command's synopsis: `-n` and `--count`, and
50/// whether a value follows the `=`.
51#[derive(Debug, Clone, Copy)]
52pub struct OptionSpec {
53    pub short: Option<&'static str>,
54    /// The long name, written `--name`; the name the library sees.
55    pub long: &'static str,
56    pub takes_value: bool,
57    pub doc: &'static str,
58}
59
60impl OptionSpec {
61    fn matches(&self, written: &str) -> bool {
62        written.strip_prefix("--") == Some(self.long) || self.short == Some(written)
63    }
64
65    /// The option as a signature row spells it: `-n|--count=x?`, a
66    /// switch bare (a library's options all have defaults).
67    fn spelling(&self) -> String {
68        let mut s = String::new();
69        if let Some(short) = self.short {
70            s.push_str(short);
71            s.push('|');
72        }
73        s.push_str("--");
74        s.push_str(self.long);
75        if self.takes_value {
76            s.push_str("=x?");
77        }
78        s
79    }
80}
81
82/// Which parameter the pipe fills (spec: The input slot): an
83/// operand by position (the first, by default), or an option by its
84/// long name. `$_` among the operands or option values places the
85/// input itself, and the slot is then left to what was written.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum InputSlot {
88    Operand(usize),
89    /// The operand by position, or, when every operand is written,
90    /// the receiver: the input is then an instance of the class the
91    /// path names (a fitted estimator before `.predict(X)`).
92    OperandOrSelf(usize),
93    Option(&'static str),
94    /// The context itself, read by the engine (the graph library's
95    /// row-aligned aggregates): no operand's place is taken, and
96    /// `$_` places nothing.
97    Context,
98}
99
100impl InputSlot {
101    /// The operand position the input fills, when it fills one.
102    pub fn operand_index(&self) -> Option<usize> {
103        match self {
104            InputSlot::Operand(i) | InputSlot::OperandOrSelf(i) => Some(*i),
105            InputSlot::Option(_) | InputSlot::Context => None,
106        }
107    }
108}
109
110/// A command's declaration: what the parser checks a call against,
111/// what the manual prints, what the library receives. A library
112/// that reflects the package it wraps builds one from the
113/// function's own signature: an operand named `...` admits surplus
114/// operands (`*args`, or defaulted positional parameters), an
115/// option named `*` admits any option (`**kwargs`), and the gate
116/// then leaves what it cannot check to the function.
117#[derive(Debug, Clone, Copy)]
118pub struct Synopsis {
119    pub name: &'static str,
120    pub doc: &'static str,
121    pub pipes: Pipe,
122    pub operands: &'static [OperandSpec],
123    pub options: &'static [OptionSpec],
124    pub input: InputSlot,
125    /// What the command returns, for people (`float`, `list of
126    /// strings`).
127    pub returns: &'static str,
128}
129
130impl Synopsis {
131    /// Whether surplus operands are admitted (an operand `...`).
132    pub fn variadic(&self) -> bool {
133        self.operands.last().is_some_and(|o| o.name == "...")
134    }
135
136    /// Whether any option is admitted (an option `*`).
137    pub fn open(&self) -> bool {
138        self.options.iter().any(|o| o.long == "*")
139    }
140
141    /// The one-line signature a refusal quotes, in the notation of
142    /// the built-in rows (spec: Signatures, draft 203): the input's
143    /// parameter braced, the operands in the parenthesis (`?`
144    /// optional, `…` variadic), then the option segment
145    /// (`-n|--count=x?`; a switch bare; `--…` any keyword), and the
146    /// return after an arrow:
147    /// `difflib:close_matches{word}(candidates; -n|--count=x? --cutoff=x?) → list of strings`.
148    pub fn render(&self, prefix: &str) -> String {
149        let slot = match self.input {
150            InputSlot::Operand(i) | InputSlot::OperandOrSelf(i) => {
151                self.operands.get(i).map(|op| op.name).unwrap_or("input")
152            }
153            InputSlot::Option(long) => long,
154            InputSlot::Context => "context",
155        };
156        let braced = match self.input {
157            InputSlot::Option(long) => format!("{{--{long}}}"),
158            _ => format!("{{{slot}}}"),
159        };
160        let mut fields: Vec<String> = Vec::new();
161        for (i, op) in self.operands.iter().enumerate() {
162            if op.name == "..." {
163                fields.push("…".to_string());
164                continue;
165            }
166            if self.input.operand_index() == Some(i) {
167                continue;
168            }
169            let mut f = op.name.to_string();
170            if !op.values.is_empty() {
171                f.push('=');
172                f.push_str(&op.values.join("|"));
173            }
174            if !op.required {
175                f.push('?');
176            }
177            fields.push(f);
178        }
179        let options: Vec<String> = self
180            .options
181            .iter()
182            .filter(|o| self.input != InputSlot::Option(o.long))
183            .map(|o| {
184                if o.long == "*" {
185                    return "--…".to_string();
186                }
187                o.spelling()
188            })
189            .collect();
190        if !options.is_empty() {
191            fields.push(options.join(" "));
192        }
193        format!(
194            "{prefix}:{}{braced}({}) → {}",
195            self.name,
196            fields.join("; "),
197            self.returns
198        )
199    }
200}
201
202/// What a library command receives: the pipe it rides, the input
203/// (none when `$_` placed it among the written operands), the
204/// operands in order, and the options by their long name.
205#[derive(Debug, Clone)]
206pub struct LibCall {
207    pub agg: bool,
208    pub input: Option<Value>,
209    pub operands: Vec<Value>,
210    pub options: Vec<(String, Option<Value>)>,
211}
212
213/// Why a call gave nothing back: the call failed (an exception; null
214/// after `|`, a refusal after `@|`), or the name resolved to nothing
215/// (a refusal wherever it stands).
216#[derive(Debug, Clone)]
217pub enum LibFailure {
218    Failed(String),
219    Unknown(String),
220}
221
222impl LibFailure {
223    pub fn message(&self) -> &str {
224        match self {
225            LibFailure::Failed(m) | LibFailure::Unknown(m) => m,
226        }
227    }
228}
229
230/// A command library: its prefix, its declared commands, and the
231/// call. Pure by contract: a call reads nothing outside the values
232/// it is given.
233pub trait CommandLibrary: Send + Sync {
234    /// The declared name, the prefix every command carries.
235    fn prefix(&self) -> &str;
236    /// Every command, as the manual lists them.
237    fn commands(&self) -> &[Synopsis];
238    /// Run `name` with `call`.
239    fn call(&self, name: &str, call: LibCall) -> Result<Value, LibFailure>;
240
241    /// The run-time requirements the library declares beyond its
242    /// own code (a Python package and its dictionaries), as the
243    /// recipes' requirements line names them.
244    fn requirements(&self) -> &[&str] {
245        &[]
246    }
247
248    /// Whether the requirements are met here; `Err` names what is
249    /// missing, and a query using the library is refused with it
250    /// before the run.
251    fn available(&self) -> Result<(), String> {
252        Ok(())
253    }
254
255    /// The synopsis of `name`, when declared.
256    fn synopsis(&self, name: &str) -> Option<&Synopsis> {
257        self.commands().iter().find(|s| s.name == name)
258    }
259
260    /// The synopsis of `name` for the gate and the call: the
261    /// declared one, or, in a library that reflects the package it
262    /// wraps, the one read from that package's own signature. `Err`
263    /// is the refusal when there is none: the declared commands by
264    /// default, what the package has in a reflective library.
265    fn resolve(&self, name: &str) -> Result<&Synopsis, String> {
266        self.synopsis(name).ok_or_else(|| {
267            let names: Vec<&str> = self.commands().iter().map(|s| s.name).collect();
268            format!(
269                "library '{}' has no command '{name}'; its commands: {}",
270                self.prefix(),
271                names.join(", ")
272            )
273        })
274    }
275}
276
277/// Whether the written call takes the input at all: `$_` places it
278/// by hand, and a headless call whose operands reach the synopsis's
279/// parameters has filled the slot with its first operand (spec: The
280/// stdin slot).
281pub fn takes_input(syn: &Synopsis, call: &FnCall, operands_written: usize) -> bool {
282    if places_input(call) {
283        return false;
284    }
285    let fixed = syn.operands.len() - usize::from(syn.variadic());
286    !(call.headless && operands_written >= fixed && !matches!(syn.input, InputSlot::Context))
287}
288
289/// Whether an operand or option value is the topic variable `$_`,
290/// which places the input itself.
291pub fn places_input(call: &FnCall) -> bool {
292    call.args.iter().any(|a| match a {
293        Arg::Expr(Operand::Topic) => true,
294        Arg::Opt {
295            value: Some(Operand::Topic),
296            ..
297        } => true,
298        _ => false,
299    })
300}
301
302/// Check a written call against the library's synopsis, before the
303/// run: the command exists, rides this pipe, has no operand or
304/// option outside the synopsis and no required operand missing.
305/// The refusal quotes the signature.
306pub fn check(lib: &dyn CommandLibrary, name: &str, call: &FnCall, agg: bool) -> Result<(), String> {
307    let prefix = lib.prefix();
308    let syn = lib.resolve(name)?;
309    // The inventory's row where the language writes one (the graph
310    // library's); the reflected signature otherwise.
311    let shown = match crate::syntax::man::entry(&format!("{prefix}:{name}")) {
312        Some(row) if !row.synopsis.is_empty() => format!("{} → {}", row.synopsis, syn.returns),
313        _ => syn.render(prefix),
314    };
315    if !syn.pipes.admits(agg) {
316        return Err(format!(
317            "'{prefix}:{name}' rides {}; written after '{}'; the signature: {shown}",
318            match syn.pipes {
319                Pipe::Plain => "the plain pipe",
320                Pipe::Agg => "the aggregation pipe",
321                Pipe::Both => "either pipe",
322            },
323            if agg { "@|" } else { "|" }
324        ));
325    }
326    let written_operands = call
327        .args
328        .iter()
329        .filter(|a| !matches!(a, Arg::Opt { .. }))
330        .count();
331    // The input slot takes one operand's place unless `$_` fills it
332    // by hand, or every operand is written and the input may stand
333    // as the receiver instead.
334    let fixed = syn.operands.len() - usize::from(syn.variadic());
335    // After a pipe the pipe fills the slot; headless (the head of an
336    // expression, a subcontext body) the first operand does when the
337    // operands reach the parameters, the seed otherwise (spec: The
338    // stdin slot, ruling 2026-11-02).
339    let slot_takes = match syn.input {
340        InputSlot::Operand(_) => {
341            !places_input(call) && !(call.headless && written_operands >= fixed)
342        }
343        InputSlot::OperandOrSelf(_) => !places_input(call) && written_operands < fixed,
344        InputSlot::Option(_) | InputSlot::Context => false,
345    };
346    let room = fixed.saturating_sub(usize::from(slot_takes));
347    if written_operands > room && !syn.variadic() {
348        return Err(format!(
349            "'{prefix}:{name}' takes {room} operand{}, {written_operands} given; the signature: {shown}",
350            if room == 1 { "" } else { "s" }
351        ));
352    }
353    // Where the written operands land among the synopsis's: in
354    // order, stepping over the input's slot when the pipe fills it.
355    let slot = syn.input.operand_index().filter(|_| slot_takes);
356    let filled: Vec<usize> = (0..written_operands)
357        .map(|j| match slot {
358            Some(s) if j >= s => j + 1,
359            _ => j,
360        })
361        .collect();
362    let option_names: Vec<String> = call
363        .args
364        .iter()
365        .filter_map(|a| match a {
366            Arg::Opt { name: opt, .. } => syn
367                .options
368                .iter()
369                .find(|o| o.matches(opt))
370                .map(|o| o.long.replace('-', "_")),
371            _ => None,
372        })
373        .collect();
374    // A parameter reachable both ways (spec: "Reflected commands")
375    // is filled once: as an operand or by name, not both.
376    for &i in &filled {
377        if let Some(op) = syn.operands.get(i)
378            && option_names.iter().any(|n| n == op.name)
379        {
380            return Err(format!(
381                "'{prefix}:{name}' takes '{}' once: it is operand {} and --{}= here; the signature: {shown}",
382                op.name,
383                i + 1,
384                op.name.replace('_', "-")
385            ));
386        }
387    }
388    // A parameter without a default is filled one way or the other.
389    let missing: Vec<&str> = syn
390        .operands
391        .iter()
392        .enumerate()
393        .filter(|(i, op)| {
394            op.required
395                && op.name != "..."
396                && Some(*i) != slot
397                && !filled.contains(i)
398                && !option_names.iter().any(|n| n == op.name)
399        })
400        .map(|(_, op)| op.name)
401        .collect();
402    if !missing.is_empty() {
403        let required = syn
404            .operands
405            .iter()
406            .enumerate()
407            .filter(|(i, op)| op.required && op.name != "..." && Some(*i) != slot)
408            .count();
409        return Err(format!(
410            "'{prefix}:{name}' needs {required} operand{}, {written_operands} given ({} unfilled); the signature: {shown}",
411            if required == 1 { "" } else { "s" },
412            missing.join(", ")
413        ));
414    }
415    for arg in &call.args {
416        if let Arg::Opt { name: opt, value } = arg {
417            let Some(spec) = syn.options.iter().find(|o| o.matches(opt)) else {
418                if syn.open() {
419                    // The function takes any keyword argument; it
420                    // judges this one.
421                    continue;
422                }
423                return Err(format!(
424                    "'{prefix}:{name}' has no option '{opt}'; the signature: {shown}"
425                ));
426            };
427            if spec.takes_value && value.is_none() {
428                return Err(format!(
429                    "option '{opt}' of '{prefix}:{name}' takes a value ('{opt}=…'); the signature: {shown}"
430                ));
431            }
432            if !spec.takes_value && value.is_some() {
433                return Err(format!(
434                    "option '{opt}' of '{prefix}:{name}' is a switch and takes no value; the signature: {shown}"
435                ));
436            }
437        }
438    }
439    Ok(())
440}
441
442/// The written options by their long names, for the library.
443pub fn canonical_options(
444    syn: &Synopsis,
445    written: Vec<(String, Option<Value>)>,
446) -> Vec<(String, Option<Value>)> {
447    written
448        .into_iter()
449        .map(|(name, value)| {
450            let long = syn
451                .options
452                .iter()
453                .find(|o| o.matches(&name))
454                .map(|o| o.long.to_string())
455                .unwrap_or_else(|| name.trim_start_matches('-').to_string());
456            (long, value)
457        })
458        .collect()
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn the_signature_renders_in_the_row_notation() {
467        // the input's parameter braced, the operands with `?` when
468        // optional and `…` when variadic, the options as a row spells
469        // them, the return after the arrow
470        let syn = Synopsis {
471            name: "ttest",
472            doc: "",
473            pipes: Pipe::Agg,
474            operands: &[
475                OperandSpec {
476                    name: "stream",
477                    doc: "",
478                    values: &[],
479                    required: true,
480                },
481                OperandSpec {
482                    name: "sample",
483                    doc: "",
484                    values: &[],
485                    required: false,
486                },
487            ],
488            options: &[
489                OptionSpec {
490                    short: None,
491                    long: "popmean",
492                    takes_value: true,
493                    doc: "",
494                },
495                OptionSpec {
496                    short: Some("-p"),
497                    long: "paired",
498                    takes_value: false,
499                    doc: "",
500                },
501            ],
502            input: InputSlot::Operand(0),
503            returns: "float",
504        };
505        assert_eq!(
506            syn.render("sp"),
507            "sp:ttest{stream}(sample?; --popmean=x? -p|--paired) → float"
508        );
509        // the slot elsewhere than first, a variadic tail, any keyword
510        let syn = Synopsis {
511            name: "cross_val_score",
512            doc: "",
513            pipes: Pipe::Agg,
514            operands: &[
515                OperandSpec {
516                    name: "estimator",
517                    doc: "",
518                    values: &[],
519                    required: true,
520                },
521                OperandSpec {
522                    name: "X",
523                    doc: "",
524                    values: &[],
525                    required: true,
526                },
527                OperandSpec {
528                    name: "...",
529                    doc: "",
530                    values: &[],
531                    required: false,
532                },
533            ],
534            options: &[OptionSpec {
535                short: None,
536                long: "*",
537                takes_value: true,
538                doc: "",
539            }],
540            input: InputSlot::OperandOrSelf(1),
541            returns: "a value",
542        };
543        assert_eq!(
544            syn.render("sklearn"),
545            "sklearn:cross_val_score{X}(estimator; …; --…) → a value"
546        );
547        // the context slot of a row-aligned aggregate
548        let syn = Synopsis {
549            name: "degree",
550            doc: "",
551            pipes: Pipe::Agg,
552            operands: &[],
553            options: &[],
554            input: InputSlot::Context,
555            returns: "int per node",
556        };
557        assert_eq!(
558            syn.render("graph"),
559            "graph:degree{context}() → int per node"
560        );
561    }
562}