Skip to main content

decl_lang/
api.rs

1//! The high-level API of the crate: the operations the `decl` command line
2//! offers, in the same vocabulary — `evaluate` binds inputs and returns
3//! outputs, `check`, `validate`, `evaluate_source`, `format_source` — for
4//! programs that would otherwise assemble parser, checker, and engine by
5//! hand (those modules are public too). The npm package and the Python
6//! package offer the same functions with the same semantics.
7use std::collections::BTreeMap;
8use std::fmt;
9use std::path::PathBuf;
10
11use crate::checker::check_module;
12use crate::cli::{check_files, file_tag, open_universe};
13use crate::module::{run_universe, Bind, Module};
14use crate::parse::parse_source;
15use crate::pipeline::run_pipeline;
16pub use crate::pipeline::{evaluate_source, Report};
17use crate::semantics::{read_json, Diag};
18use std::rc::Rc;
19
20/// One diagnostic, in the report's field order (§12.2).
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Diagnostic {
23    pub file: String,
24    pub code: Option<String>,
25    pub id: Option<String>,
26    pub severity: String,
27    pub message: String,
28    pub path: String,
29}
30
31/// An operation failed; `diagnostics` carries the report (empty for a usage
32/// error such as an unknown input or root).
33#[derive(Clone, Debug)]
34pub struct DeclError {
35    pub message: String,
36    pub diagnostics: Vec<Diagnostic>,
37}
38impl fmt::Display for DeclError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(&self.message)
41    }
42}
43impl std::error::Error for DeclError {}
44
45/// A document to bind to an input: the path of a JSON file, or its JSON text.
46#[derive(Clone, Debug)]
47pub enum Document {
48    File(PathBuf),
49    Json(String),
50}
51
52/// What `evaluate` binds and returns.
53#[derive(Clone, Debug, Default)]
54pub struct EvaluateOptions {
55    /// documents to bind, by input name
56    pub inputs: Vec<(String, Document)>,
57    /// the roots to return — outputs, or inputs bound here or demanded
58    /// through their fallback; empty: the entry module's exported outputs (§5.5)
59    pub outputs: Vec<String>,
60}
61
62fn tagged(file: &str, d: &Diag) -> Diagnostic {
63    Diagnostic {
64        file: file.to_string(),
65        code: d.code.clone(),
66        id: d.id.clone(),
67        severity: d.severity.clone(),
68        message: d.message.clone(),
69        path: d.path.clone(),
70    }
71}
72fn fail<T>(fallback: &str, diagnostics: Vec<Diagnostic>) -> Result<T, DeclError> {
73    let message = diagnostics
74        .first()
75        .map(|d| d.message.clone())
76        .unwrap_or_else(|| fallback.to_string());
77    Err(DeclError {
78        message,
79        diagnostics,
80    })
81}
82
83// the documents to bind, each to the module that declares its input (§10)
84fn bind_inputs(
85    modules: &[Rc<Module>],
86    file: &str,
87    inputs: &[(String, Document)],
88) -> Result<Vec<Bind>, DeclError> {
89    let mut binds = vec![];
90    for (name, doc) in inputs {
91        let Some(module) = modules
92            .iter()
93            .find(|m| m.env.inputs.borrow().contains_key(name))
94        else {
95            return Err(DeclError {
96                message: format!("no input named {name}"),
97                diagnostics: vec![],
98            });
99        };
100        let e6004 = |message: String| Diagnostic {
101            file: file.to_string(),
102            code: Some("E6004".into()),
103            id: None,
104            severity: "error".into(),
105            message,
106            path: name.clone(),
107        };
108        let (text, place) = match doc {
109            Document::File(p) => match std::fs::read_to_string(p) {
110                Ok(t) => (t, p.display().to_string()),
111                Err(_) => {
112                    return fail(
113                        "",
114                        vec![e6004(format!(
115                            "bound document cannot be read: {}",
116                            p.display()
117                        ))],
118                    )
119                }
120            },
121            Document::Json(t) => (t.clone(), name.clone()),
122        };
123        let raw = match read_json(&text) {
124            Ok(v) => v,
125            Err(_) => {
126                return fail(
127                    "",
128                    vec![e6004(format!(
129                        "bound document is not well-formed JSON: {place}"
130                    ))],
131                )
132            }
133        };
134        binds.push(Bind {
135            module: Some(module.clone()),
136            input: name.clone(),
137            raw,
138        });
139    }
140    Ok(binds)
141}
142
143/// Evaluate a module: bind the input documents, run the pipeline, and return
144/// the requested roots' documents (canonical JSON text) by name. Fails with
145/// the diagnostics on any error-severity outcome.
146pub fn evaluate(path: &str, opts: &EvaluateOptions) -> Result<BTreeMap<String, String>, DeclError> {
147    let r = open_universe(path);
148    let Some(entry) = r.entry.clone() else {
149        return fail(
150            &format!("{path}: cannot be loaded"),
151            r.diags.iter().map(|d| tagged(path, d)).collect(),
152        );
153    };
154    if !r.diags.is_empty() {
155        return fail("", r.diags.iter().map(|d| tagged(path, d)).collect());
156    }
157    let checks: Vec<Diagnostic> = r
158        .modules
159        .iter()
160        .flat_map(|m| {
161            let tag = file_tag(path, Some(entry.path.as_path()), &m.path);
162            check_module(&m.decls, Some(m.env.clone()), None)
163                .iter()
164                .map(|d| tagged(&tag, d))
165                .collect::<Vec<_>>()
166        })
167        .collect();
168    if !checks.is_empty() {
169        return fail("", checks);
170    }
171    let binds = bind_inputs(&r.modules, path, &opts.inputs)?;
172    let (eng, diags) = run_universe(&r.modules, &entry, binds);
173    let report: Vec<Diagnostic> = diags.iter().map(|d| tagged(path, d)).collect();
174    if report.iter().any(|d| d.severity == "error") {
175        return fail("", report);
176    }
177    let names: Vec<String> = if opts.outputs.is_empty() {
178        entry
179            .decls
180            .iter()
181            .filter(|d| d.exported)
182            .filter_map(|d| match &d.body {
183                crate::ast::DeclBody::Output { name, .. } => Some(name.clone()),
184                _ => None,
185            })
186            .collect()
187    } else {
188        opts.outputs.clone()
189    };
190    let mut out = BTreeMap::new();
191    for n in &names {
192        let Some(v) = entry.env.root(n) else {
193            return Err(DeclError {
194                message: format!("no root named {n}"),
195                diagnostics: report,
196            });
197        };
198        out.insert(n.clone(), eng.serialize(&v, n, false));
199    }
200    Ok(out)
201}
202
203/// Parse and statically check entry files (module-aware); empty means clean.
204pub fn check(paths: &[&str]) -> Vec<Diagnostic> {
205    let owned: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
206    check_files(&owned)
207        .iter()
208        .map(|(file, d)| tagged(file, d))
209        .collect()
210}
211
212/// Validate a file: static checks, then evaluation with the input documents
213/// bound; returns every diagnostic (all severities). Fails when the file
214/// does not parse.
215pub fn validate(path: &str, inputs: &[(String, Document)]) -> Result<Vec<Diagnostic>, DeclError> {
216    let src = std::fs::read_to_string(path).map_err(|_| DeclError {
217        message: format!("{path}: cannot be read"),
218        diagnostics: vec![],
219    })?;
220    let parsed = parse_source(&src);
221    if !parsed.errors.is_empty() {
222        return Err(DeclError {
223            message: format!("{path}: {} parse error(s)", parsed.errors.len()),
224            diagnostics: vec![],
225        });
226    }
227    let checks: Vec<Diagnostic> = check_module(&parsed.decls, None, None)
228        .iter()
229        .map(|d| tagged(path, d))
230        .collect();
231    if !checks.is_empty() {
232        return Ok(checks);
233    }
234    if !inputs.is_empty() {
235        let r = open_universe(path);
236        let Some(entry) = r.entry.clone() else {
237            return fail(
238                &format!("{path}: cannot be loaded"),
239                r.diags.iter().map(|d| tagged(path, d)).collect(),
240            );
241        };
242        let binds = bind_inputs(&r.modules, path, inputs)?;
243        let (_, diags) = run_universe(&r.modules, &entry, binds);
244        return Ok(diags.iter().map(|d| tagged(path, d)).collect());
245    }
246    Ok(run_pipeline(&parsed.decls)
247        .diags
248        .iter()
249        .map(|d| tagged(path, d))
250        .collect())
251}
252
253/// The canonical formatting of a source text; fails when it does not parse.
254pub fn format_source(text: &str) -> Result<String, DeclError> {
255    crate::fmt::format(text).map_err(|message| DeclError {
256        message,
257        diagnostics: vec![],
258    })
259}