Skip to main content

decl_lang/
cli.rs

1//! `decl check` / `decl evaluate` / `decl validate` / `decl fmt` (cli.ts). Output is byte-identical to
2//! the reference implementation's CLI so the three implementations can
3//! be diffed (tests/parity/differential.py).
4use crate::ast::DeclBody;
5use crate::checker::check_module;
6use crate::conformance::judge_corpus;
7use crate::fmt::format;
8use crate::module::{load_modules, run_universe, Bind, LoadResult, Module};
9use crate::package::{open_package_universe, verify_lock};
10use crate::semantics::{json_str, read_json, Diag};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::rc::Rc;
14
15fn usage() -> i32 {
16    eprintln!("usage:\n  decl --version\n  decl check <file>... [--json]\n  decl evaluate <file> [--input name=doc.json]... [--output name[=file]]... [--json]\n  decl validate <dir>\n  decl validate <file> [--input name=doc.json]... [--expect-errors E1,E2] [--json]\n  decl fmt <file>... [--check]\n  decl repl [file.decl] [--input name=doc.json]... [--script session.txt | --script -] [--compact]");
17    2
18}
19
20/// the module graph of an entry file inside its package universe
21/// (manifest and lock diagnostics first), as the reference CLI opens it
22pub fn open_universe(file: &str) -> LoadResult {
23    let abs = std::path::absolute(file).unwrap_or_else(|_| PathBuf::from(file));
24    let pkg = open_package_universe(&abs);
25    let mut diags: Vec<Diag> = vec![];
26    if let Some(u) = &pkg {
27        diags.extend(u.diags.clone());
28        diags.extend(verify_lock(u));
29    }
30    let r = load_modules(&abs, pkg.as_ref().map(|u| &u.resolver), None);
31    diags.extend(r.diags);
32    LoadResult {
33        modules: r.modules,
34        entry: r.entry,
35        diags,
36    }
37}
38
39fn print_diag(file: &str, d: &Diag, json: bool, collected: &mut Vec<String>) {
40    if json {
41        collected.push(d.to_json(Some(file)));
42        return;
43    }
44    eprintln!(
45        "{file}: {}{}{}{}: {}",
46        d.severity,
47        d.code
48            .as_ref()
49            .map(|c| format!(" [{c}]"))
50            .unwrap_or_default(),
51        d.id.as_ref().map(|i| format!(" {i}")).unwrap_or_default(),
52        if d.path.is_empty() {
53            String::new()
54        } else {
55            format!(" at {}", d.path)
56        },
57        d.message
58    );
59}
60
61/// the documents named by `--input`, each bound to the module that
62/// declares its input (§10): `name=doc.json`; Err carries the exit code
63/// of a usage error (already printed)
64/// the documents named by --input, each bound to the module that declares
65/// its input (§10): `name=doc.json`. A usage error (bad spec, unknown input)
66/// is printed and returned as exit 2; a document that cannot be read or is
67/// not well-formed JSON is returned as one E6004 diagnostic (exit 1)
68pub fn input_binds(
69    modules: &[Rc<Module>],
70    specs: &[String],
71) -> Result<Vec<Bind>, (i32, Option<Diag>)> {
72    let doc_error = |name: &str, message: String| -> (i32, Option<Diag>) {
73        (
74            1,
75            Some(Diag {
76                severity: "error".into(),
77                id: None,
78                message,
79                path: name.to_string(),
80                code: Some("E6004".into()),
81                loc: None,
82                by: None,
83            }),
84        )
85    };
86    let mut binds = vec![];
87    for spec in specs {
88        let Some((name, file)) = spec.split_once('=') else {
89            eprintln!("--input expects name=doc.json, got {spec}");
90            return Err((2, None));
91        };
92        let Some(module) = modules
93            .iter()
94            .find(|m| m.env.inputs.borrow().contains_key(name))
95        else {
96            eprintln!("no input named {name}");
97            return Err((2, None));
98        };
99        let text = match std::fs::read_to_string(file) {
100            Ok(t) => t,
101            Err(_) => {
102                return Err(doc_error(
103                    name,
104                    format!("bound document cannot be read: {file}"),
105                ))
106            }
107        };
108        let raw = match read_json(&text) {
109            Ok(v) => v,
110            Err(_) => {
111                return Err(doc_error(
112                    name,
113                    format!("bound document is not well-formed JSON: {file}"),
114                ));
115            }
116        };
117        binds.push(Bind {
118            module: Some(module.clone()),
119            input: name.to_string(),
120            raw,
121        });
122    }
123    Ok(binds)
124}
125
126/// `decl evaluate`: (exit code, the document for stdout, diagnostics tagged
127/// with the file each is reported against, bare stderr lines to print
128/// after them). What to emit, and where (§5.5): each `--output name[=file]`
129/// names a root — an output, or an input bound by --input or demanded
130/// through its fallback — and the file its document goes to (stdout
131/// without one); with no --output, the entry module's exported outputs, as
132/// one object keyed by name, on stdout
133pub fn evaluate(
134    file: &str,
135    outputs: &[String],
136    inputs: &[String],
137) -> (i32, Option<String>, Vec<(String, Diag)>, Vec<String>) {
138    let tag = |ds: Vec<Diag>| -> Vec<(String, Diag)> {
139        ds.into_iter().map(|d| (file.to_string(), d)).collect()
140    };
141    let mut targets: Vec<(String, Option<String>)> = vec![];
142    for spec in outputs {
143        let (name, dest) = match spec.split_once('=') {
144            Some((n, f)) => (n.to_string(), Some(f.to_string())),
145            None => (spec.clone(), None),
146        };
147        if name.is_empty() || dest.as_deref() == Some("") {
148            eprintln!("--output expects name or name=file, got {spec}");
149            return (2, None, vec![], vec![]);
150        }
151        targets.push((name, dest));
152    }
153    if targets.iter().filter(|(_, f)| f.is_none()).count() > 1 {
154        eprintln!("--output: at most one document can go to stdout");
155        return (2, None, vec![], vec![]);
156    }
157    let r = open_universe(file);
158    let Some(entry) = r.entry else {
159        return (1, None, tag(r.diags), vec![]);
160    };
161    if !r.diags.is_empty() {
162        return (1, None, tag(r.diags), vec![]);
163    }
164    let checks: Vec<(String, Diag)> = r
165        .modules
166        .iter()
167        .flat_map(|m| {
168            let path = file_tag(file, Some(entry.path.as_path()), &m.path);
169            check_module(&m.decls, Some(m.env.clone()), None)
170                .into_iter()
171                .map(move |d| (path.clone(), d))
172                .collect::<Vec<_>>()
173        })
174        .collect();
175    if !checks.is_empty() {
176        return (1, None, checks, vec![]);
177    }
178    let binds = match input_binds(&r.modules, inputs) {
179        Ok(b) => b,
180        Err((code, diag)) => {
181            return (
182                code,
183                None,
184                diag.into_iter().map(|d| (file.to_string(), d)).collect(),
185                vec![],
186            )
187        }
188    };
189    let (eng, diags) = run_universe(&r.modules, &entry, binds);
190    if diags.iter().any(|d| d.severity == "error") {
191        return (1, None, tag(diags), vec![]);
192    }
193    let names: Vec<String> = if targets.is_empty() {
194        entry
195            .decls
196            .iter()
197            .filter(|d| d.exported)
198            .filter_map(|d| match &d.body {
199                DeclBody::Output { name, .. } => Some(name.clone()),
200                _ => None,
201            })
202            .collect()
203    } else {
204        targets.iter().map(|(n, _)| n.clone()).collect()
205    };
206    let mut notes = vec![];
207    for n in &names {
208        if entry.env.root(n).is_none() {
209            notes.push(format!("no root named {n}"));
210        }
211    }
212    if !notes.is_empty() {
213        return (1, None, tag(diags), notes);
214    }
215    let doc = |n: &str| eng.serialize(&entry.env.root(n).unwrap(), n, false);
216    let mut text = None;
217    if targets.is_empty() {
218        if names.is_empty() {
219            notes.push(format!(
220                "{file}: exports no output; --output <name> selects a root"
221            ));
222        }
223        text = Some(format!(
224            "{{{}}}",
225            names
226                .iter()
227                .map(|n| format!("{}:{}", json_str(n), doc(n)))
228                .collect::<Vec<_>>()
229                .join(",")
230        ));
231    } else {
232        for (n, dest) in &targets {
233            match dest {
234                None => text = Some(doc(n)),
235                Some(path) => {
236                    if std::fs::write(path, doc(n) + "\n").is_err() {
237                        notes.push(format!("cannot write {path}"));
238                        return (1, None, tag(diags), notes);
239                    }
240                }
241            }
242        }
243    }
244    (0, text, tag(diags), notes)
245}
246
247/// single-file validation, module-aware like `check` and `evaluate`: load
248/// the universe, check every module, then evaluate with the `--input`
249/// documents bound (none bound is fine: fallbacks apply). Diagnostics come
250/// tagged with the file each is reported against; Err carries a usage exit
251/// code as a negative
252pub fn validate_file(file: &str, inputs: &[String]) -> Result<Vec<(String, Diag)>, i64> {
253    let r = open_universe(file);
254    let mut diags: Vec<(String, Diag)> = r
255        .diags
256        .iter()
257        .map(|d| (file.to_string(), d.clone()))
258        .collect();
259    let Some(entry) = r.entry else {
260        return Ok(diags);
261    };
262    if !diags.is_empty() {
263        return Ok(diags);
264    }
265    let checks: Vec<(String, Diag)> = r
266        .modules
267        .iter()
268        .flat_map(|m| {
269            let path = file_tag(file, Some(entry.path.as_path()), &m.path);
270            check_module(&m.decls, Some(m.env.clone()), None)
271                .into_iter()
272                .map(move |d| (path.clone(), d))
273                .collect::<Vec<_>>()
274        })
275        .collect();
276    if !checks.is_empty() {
277        return Ok(checks);
278    }
279    let binds = match input_binds(&r.modules, inputs) {
280        Ok(b) => b,
281        Err((_, Some(d))) => return Ok(vec![(file.to_string(), d)]),
282        Err((code, None)) => return Err(-(code as i64)),
283    };
284    diags.extend(
285        run_universe(&r.modules, &entry, binds)
286            .1
287            .into_iter()
288            .map(|d| (file.to_string(), d)),
289    );
290    Ok(diags)
291}
292
293/// `decl check`: load each entry (following imports), report load
294/// diagnostics and every module's static findings, tagged with their file
295pub fn check_files(paths: &[String]) -> Vec<(String, Diag)> {
296    let mut out = vec![];
297    for f in paths {
298        let r = open_universe(f);
299        out.extend(r.diags.into_iter().map(|d| (f.clone(), d)));
300        for m in &r.modules {
301            let path = file_tag(f, r.entry.as_ref().map(|e| e.path.as_path()), &m.path);
302            out.extend(
303                check_module(&m.decls, Some(m.env.clone()), None)
304                    .into_iter()
305                    .map(|d| (path.clone(), d)),
306            );
307        }
308    }
309    out
310}
311
312/// the file a diagnostic is reported against: the entry module by the path
313/// given on the command line, any other module by its absolute path
314pub fn file_tag(given: &str, entry: Option<&Path>, module: &Path) -> String {
315    if entry == Some(module) {
316        given.to_string()
317    } else {
318        module.display().to_string()
319    }
320}
321
322/// the command line: returns the process exit code
323pub fn main(args: Vec<String>) -> i32 {
324    let Some(cmd) = args.first().cloned() else {
325        return usage();
326    };
327    // `decl --version`: the package's version, the same string on every registry
328    if cmd == "--version" {
329        println!("decl {}", env!("CARGO_PKG_VERSION"));
330        return 0;
331    }
332    // `decl repl`: its own argument syntax (docs/tooling/02_repl.md)
333    if cmd == "repl" {
334        return crate::repl::run_repl(args[1..].to_vec());
335    }
336    let mut flags: HashMap<String, String> = HashMap::new();
337    let mut input_flags: Vec<String> = vec![]; // --input name=doc.json, repeatable
338    let mut output_flags: Vec<String> = vec![]; // --output name[=file], repeatable
339    let mut pos: Vec<String> = vec![];
340    let mut i = 1;
341    while i < args.len() {
342        let a = &args[i];
343        if let Some(name) = a.strip_prefix("--") {
344            if ["output", "input", "expect-errors"].contains(&name)
345                && i + 1 < args.len()
346                && !args[i + 1].starts_with("--")
347            {
348                if name == "input" {
349                    input_flags.push(args[i + 1].clone());
350                } else if name == "output" {
351                    output_flags.push(args[i + 1].clone());
352                } else {
353                    flags.insert(name.to_string(), args[i + 1].clone());
354                }
355                i += 2;
356                continue;
357            }
358            flags.insert(name.to_string(), "true".into());
359        } else {
360            pos.push(a.clone());
361        }
362        i += 1;
363    }
364    let json = flags.contains_key("json");
365    let mut collected: Vec<String> = vec![];
366    match cmd.as_str() {
367        "check" => {
368            if pos.is_empty() {
369                return usage();
370            }
371            let diags = check_files(&pos);
372            for (file, d) in &diags {
373                print_diag(file, d, json, &mut collected);
374            }
375            if diags.is_empty() {
376                eprintln!("ok: {} entry file(s) check clean", pos.len());
377            }
378            if json {
379                println!("[{}]", collected.join(","));
380            }
381            if diags.is_empty() {
382                0
383            } else {
384                1
385            }
386        }
387        "evaluate" => {
388            let Some(f) = pos.first() else { return usage() };
389            let (code, text, diags, notes) = evaluate(f, &output_flags, &input_flags);
390            if code == 2 {
391                return 2; // a usage error: already printed, no report
392            }
393            for (file, d) in &diags {
394                print_diag(file, d, json, &mut collected);
395            }
396            for n in &notes {
397                eprintln!("{n}");
398            }
399            if json {
400                println!(
401                    "{{\"ok\":{},\"value\":{},\"diagnostics\":[{}]}}",
402                    code == 0,
403                    text.clone().unwrap_or_else(|| "null".into()),
404                    collected.join(",")
405                );
406            } else if let Some(t) = text {
407                println!("{t}");
408            }
409            code
410        }
411        "validate" => {
412            let Some(target) = pos.first() else {
413                return usage();
414            };
415            let tp = Path::new(target);
416            if tp.is_dir() {
417                let abs = std::path::absolute(tp).unwrap_or_else(|_| tp.to_path_buf());
418                let (mut ok, mut fail) = (0, 0);
419                for v in judge_corpus(&abs) {
420                    if v.ok {
421                        ok += 1;
422                    } else {
423                        fail += 1;
424                        eprintln!("FAIL {} {}", v.file.display(), v.detail);
425                    }
426                }
427                eprintln!("{ok} ok, {fail} failed");
428                if fail > 0 {
429                    1
430                } else {
431                    0
432                }
433            } else {
434                let diags = match validate_file(target, &input_flags) {
435                    Ok(d) => d,
436                    Err(n) => return (-n) as i32,
437                };
438                for (file, d) in &diags {
439                    print_diag(file, d, json, &mut collected);
440                }
441                if json {
442                    println!("[{}]", collected.join(","));
443                }
444                let err_codes: Vec<String> = diags
445                    .iter()
446                    .filter(|(_, d)| d.severity == "error")
447                    .map(|(_, d)| d.code.clone().unwrap_or_default())
448                    .collect();
449                if let Some(expect) = flags.get("expect-errors") {
450                    let want: Vec<String> = expect
451                        .split(',')
452                        .map(|w| w.trim().to_string())
453                        .filter(|w| !w.is_empty())
454                        .collect();
455                    let missing: Vec<&String> =
456                        want.iter().filter(|w| !err_codes.contains(w)).collect();
457                    let extra: Vec<&String> =
458                        err_codes.iter().filter(|c| !want.contains(c)).collect();
459                    if !missing.is_empty() || !extra.is_empty() {
460                        if !missing.is_empty() {
461                            eprintln!(
462                                "expected error(s) not reported: {}",
463                                missing
464                                    .iter()
465                                    .map(|s| s.as_str())
466                                    .collect::<Vec<_>>()
467                                    .join(", ")
468                            );
469                        }
470                        if !extra.is_empty() {
471                            eprintln!(
472                                "unexpected error(s): {}",
473                                extra
474                                    .iter()
475                                    .map(|s| s.as_str())
476                                    .collect::<Vec<_>>()
477                                    .join(", ")
478                            );
479                        }
480                        return 1;
481                    }
482                    eprintln!(
483                        "ok: expected errors reported ({})",
484                        if want.is_empty() {
485                            "none".to_string()
486                        } else {
487                            want.join(", ")
488                        }
489                    );
490                    return 0;
491                }
492                if err_codes.is_empty() {
493                    0
494                } else {
495                    1
496                }
497            }
498        }
499        "fmt" => {
500            if pos.is_empty() {
501                return usage();
502            }
503            let (mut changed, mut bad) = (0, 0);
504            for f in &pos {
505                let Ok(src) = std::fs::read_to_string(f) else {
506                    eprintln!("{f}: cannot be read");
507                    bad += 1;
508                    continue;
509                };
510                let out = match format(&src) {
511                    Ok(o) => o,
512                    Err(e) => {
513                        eprintln!("{f}: {e}");
514                        bad += 1;
515                        continue;
516                    }
517                };
518                if out != src {
519                    changed += 1;
520                    if flags.contains_key("check") {
521                        eprintln!("would reformat {f}");
522                    } else {
523                        let _ = std::fs::write(f, out);
524                        eprintln!("reformatted {f}");
525                    }
526                }
527            }
528            if bad > 0 || (flags.contains_key("check") && changed > 0) {
529                1
530            } else {
531                0
532            }
533        }
534        _ => usage(),
535    }
536}