1use crate::syntax::ast::{Arg, FnCall, Operand};
17use crate::value::Value;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Pipe {
22 Plain,
24 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#[derive(Debug, Clone, Copy)]
41pub struct OperandSpec {
42 pub name: &'static str,
43 pub doc: &'static str,
44 pub values: &'static [&'static str],
46 pub required: bool,
47}
48
49#[derive(Debug, Clone, Copy)]
52pub struct OptionSpec {
53 pub short: Option<&'static str>,
54 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum InputSlot {
88 Operand(usize),
89 OperandOrSelf(usize),
93 Option(&'static str),
94 Context,
98}
99
100impl InputSlot {
101 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#[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 pub returns: &'static str,
128}
129
130impl Synopsis {
131 pub fn variadic(&self) -> bool {
133 self.operands.last().is_some_and(|o| o.name == "...")
134 }
135
136 pub fn open(&self) -> bool {
138 self.options.iter().any(|o| o.long == "*")
139 }
140
141 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#[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#[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
230pub trait CommandLibrary: Send + Sync {
234 fn prefix(&self) -> &str;
236 fn commands(&self) -> &[Synopsis];
238 fn call(&self, name: &str, call: LibCall) -> Result<Value, LibFailure>;
240
241 fn requirements(&self) -> &[&str] {
245 &[]
246 }
247
248 fn available(&self) -> Result<(), String> {
252 Ok(())
253 }
254
255 fn synopsis(&self, name: &str) -> Option<&Synopsis> {
257 self.commands().iter().find(|s| s.name == name)
258 }
259
260 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
277pub 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
289pub 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
302pub 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 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 let fixed = syn.operands.len() - usize::from(syn.variadic());
335 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 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 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 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 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
442pub 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 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 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 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}