Skip to main content

aura_lang/
facade.rs

1//! A high-level facade for embedding Aura in Rust applications.
2//!
3//! ```no_run
4//! let opts = aura_lang::facade::EvalOptions {
5//!     allow_read: vec!["config/".into()],
6//!     ..Default::default()
7//! };
8//! let out = aura_lang::facade::eval_file("config/app.aura".as_ref(), &opts).unwrap();
9//! let cfg: serde_json::Value = out.json; // or serde_json::from_value::<MyConfig>(out.json)
10//! ```
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::analysis::has_blocking;
16use crate::error::{Diagnostic, Severity};
17use crate::eval::{DenyFs, EnvCap, FileAccess, Interpreter, MemFs, Options, RealFs};
18use crate::source::SourceCache;
19use crate::span::Span;
20use crate::vfs::loader::Loader;
21use crate::vfs::lockfile::Lockfile;
22use crate::vfs::{FileResolver, ImportSpec, LocalFsResolver, MemoryResolver};
23
24#[derive(Debug, Clone, Default)]
25pub struct EvalOptions {
26    pub strict: bool,
27    /// Directories allowed for read_file() (D1); empty = denied.
28    pub allow_read: Vec<PathBuf>,
29    /// env() capabilities; denied by default.
30    pub allow_env: EnvCap,
31    pub allow_imports_io: bool,
32    /// Registry cache directory; None = ~/.aura/registry.
33    pub registry_dir: Option<PathBuf>,
34    /// Resolve strictly via aura.lock (E0403 on a mismatch).
35    pub frozen: bool,
36    /// Deny all I/O statically: `env()` and `read_file()` become E0505 in every
37    /// module, so `check` alone proves the manifest touches nothing. Setting this
38    /// alongside `allow_read` or `allow_env` is a contradiction — the grants are
39    /// ignored, and the CLI refuses the combination outright.
40    pub hermetic: bool,
41    /// Values `env()` sees before the process environment is consulted. A host
42    /// without a process environment — wasm in a browser — supplies them here.
43    pub env_overrides: HashMap<String, String>,
44}
45
46/// A plain-form diagnostic: the host renders it itself, with no dependency on ariadne.
47#[derive(Debug, Clone)]
48pub struct Report {
49    pub code: &'static str,
50    pub severity: Severity,
51    pub message: String,
52    pub file: String,
53    /// 1-based; 0 means the position is unknown (e.g. a serialization error).
54    pub line: u32,
55    pub column: u32,
56    pub help: Option<String>,
57}
58
59impl std::fmt::Display for Report {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        let sev = match self.severity {
62            Severity::Error => "error",
63            Severity::Warning => "warning",
64        };
65        write!(f, "{sev}[{}]: {}", self.code, self.message)?;
66        if self.line > 0 {
67            write!(f, " at {}:{}:{}", self.file, self.line, self.column)?;
68        }
69        if let Some(h) = &self.help {
70            write!(f, "\n  help: {h}")?;
71        }
72        Ok(())
73    }
74}
75
76#[derive(Debug)]
77pub struct Evaluated {
78    pub json: serde_json::Value,
79    /// Analysis warnings from all modules (under strict their presence would already be an error).
80    pub warnings: Vec<Report>,
81    /// The updated aura.lock, if new entries appeared (the host decides whether to write it).
82    pub updated_lockfile: Option<String>,
83}
84
85/// Evaluates a manifest with all its imports and returns a JSON representation.
86///
87/// Errors are a `Vec<Report>` (the first is the reason for stopping, the rest are related).
88pub fn eval_file(path: &Path, opts: &EvalOptions) -> Result<Evaluated, Vec<Report>> {
89    let cache = SourceCache::new();
90    let entry = std::fs::canonicalize(path)
91        .map_err(|e| vec![io_report(format!("cannot open {}: {e}", path.display()))])?;
92    let root = entry.parent().unwrap_or(Path::new(".")).to_path_buf();
93    let registry_dir = opts.registry_dir.clone().unwrap_or_else(|| {
94        std::env::var_os("USERPROFILE")
95            .or_else(|| std::env::var_os("HOME"))
96            .map(PathBuf::from)
97            .unwrap_or_default()
98            .join(".aura")
99            .join("registry")
100    });
101    let resolver = LocalFsResolver {
102        root: root.clone(),
103        registry_dir,
104    };
105
106    let lock = match std::fs::read_to_string(root.join("aura.lock")) {
107        Ok(text) => Lockfile::parse(&text)
108            .map_err(|e| vec![io_report(format!("invalid aura.lock: {e}"))])?,
109        Err(_) => Lockfile::default(),
110    };
111    let fs: Box<dyn FileAccess> = if opts.hermetic || opts.allow_read.is_empty() {
112        Box::new(DenyFs)
113    } else {
114        Box::new(RealFs {
115            allowed: opts.allow_read.clone(),
116        })
117    };
118    let file_name = entry
119        .file_name()
120        .unwrap_or_default()
121        .to_string_lossy()
122        .to_string();
123    run(&cache, &resolver, lock, fs, &file_name, opts)
124}
125
126/// Evaluate a manifest that exists only in memory: `files` maps a name to its
127/// text, `entry` is the one to start from. Imports resolve inside that map, so a
128/// multi-file example works without touching a disk — this is what a browser
129/// playground or a test harness needs.
130///
131/// `read_file()` reads the same map, and is still capability-gated exactly as on
132/// disk: with an empty `allow_read` it is denied. The paths in `allow_read` are
133/// not consulted otherwise, since there is no filesystem to confine.
134pub fn eval_source(
135    files: HashMap<String, String>,
136    entry: &str,
137    opts: &EvalOptions,
138) -> Result<Evaluated, Vec<Report>> {
139    let cache = SourceCache::new();
140    let resolver = MemoryResolver {
141        files: files.clone(),
142    };
143    let fs: Box<dyn FileAccess> = if opts.hermetic || opts.allow_read.is_empty() {
144        Box::new(DenyFs)
145    } else {
146        Box::new(MemFs(files))
147    };
148    run(&cache, &resolver, Lockfile::default(), fs, entry, opts)
149}
150
151/// The part `eval_file` and `eval_source` share: everything after deciding where
152/// modules and file reads come from.
153fn run(
154    cache: &SourceCache,
155    resolver: &dyn FileResolver,
156    lock: Lockfile,
157    fs: Box<dyn FileAccess>,
158    file_name: &str,
159    opts: &EvalOptions,
160) -> Result<Evaluated, Vec<Report>> {
161    let mut loader = Loader::new(cache, resolver);
162    loader.frozen = opts.frozen;
163    loader.hermetic = opts.hermetic;
164    loader.lock = lock;
165
166    let mut interp = Interpreter::new(Options {
167        strict: opts.strict,
168        dry_run: false,
169    });
170    interp.fs = fs;
171    // Belt as well as braces: analysis already rejects the calls, but a host that
172    // sets `hermetic` should not be able to leave a capability open by accident.
173    interp.env_cap = if opts.hermetic {
174        EnvCap::Deny
175    } else {
176        opts.allow_env.clone()
177    };
178    interp.allow_imports_io = opts.allow_imports_io;
179    interp.env_overrides = opts.env_overrides.clone();
180
181    let result = interp_eval(&mut loader, &mut interp, file_name);
182
183    let mut reports: Vec<Report> = loader.diags.iter().map(|d| to_report(d, cache)).collect();
184    let value = match result {
185        Ok(v) => v,
186        Err(d) => {
187            reports.insert(0, to_report(&d, cache));
188            return Err(reports);
189        }
190    };
191    if has_blocking(&loader.diags, opts.strict) {
192        return Err(reports);
193    }
194
195    let json = crate::serialize::to_json(&value).map_err(|d| {
196        reports.insert(0, to_report(&d, cache));
197        reports.clone()
198    })?;
199    let updated_lockfile =
200        (loader.lock.dirty && !opts.frozen).then(|| loader.lock.to_toml_string());
201    Ok(Evaluated {
202        json,
203        warnings: reports,
204        updated_lockfile,
205    })
206}
207
208fn interp_eval<'a>(
209    loader: &mut Loader<'a, '_>,
210    interp: &mut Interpreter<'a>,
211    file_name: &str,
212) -> Result<crate::eval::value::Value<'a>, Diagnostic> {
213    loader.eval_entry(interp, &ImportSpec::File(file_name))
214}
215
216fn to_report(d: &Diagnostic, cache: &SourceCache) -> Report {
217    let (file, line, column) = locate(d.primary.0, cache);
218    Report {
219        code: d.code,
220        severity: d.severity,
221        message: d.message.clone(),
222        file,
223        line,
224        column,
225        help: d.help.clone(),
226    }
227}
228
229fn locate(span: Span, cache: &SourceCache) -> (String, u32, u32) {
230    let name = cache.name(span.source).unwrap_or_default();
231    let Some(text) = cache.text(span.source) else {
232        return (name, 0, 0);
233    };
234    let upto = &text[..(span.start as usize).min(text.len())];
235    let line = upto.matches('\n').count() as u32 + 1;
236    let column = upto.rsplit('\n').next().map_or(0, |l| l.chars().count()) as u32 + 1;
237    (name, line, column)
238}
239
240fn io_report(message: String) -> Report {
241    Report {
242        code: "E0001",
243        severity: Severity::Error,
244        message,
245        file: String::new(),
246        line: 0,
247        column: 0,
248        help: None,
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn files(pairs: &[(&str, &str)]) -> HashMap<String, String> {
257        pairs
258            .iter()
259            .map(|(k, v)| (k.to_string(), v.to_string()))
260            .collect()
261    }
262
263    /// The playground scenario: several buffers, an import between them, no disk.
264    #[test]
265    fn eval_source_resolves_imports_between_in_memory_files() {
266        let fs = files(&[
267            (
268                "main.aura",
269                "import \"lib.aura\" as lib\nport: lib.default_port\nname: lib.label(\"api\")\n",
270            ),
271            (
272                "lib.aura",
273                "pub def label(n)\n  service: n\nend\n\ndefault_port: 8080\n",
274            ),
275        ]);
276        let out = eval_source(fs, "main.aura", &EvalOptions::default())
277            .unwrap_or_else(|e| panic!("{e:?}"));
278        assert_eq!(out.json["port"], 8080);
279        assert_eq!(out.json["name"]["service"], "api");
280    }
281
282    /// Capabilities behave exactly as on disk: denied unless granted, and when
283    /// granted the "filesystem" is the same set of buffers.
284    #[test]
285    fn read_file_is_gated_and_reads_the_same_buffers() {
286        let fs = files(&[
287            ("main.aura", "data: read_file(\"data.json\").parse_json()\n"),
288            ("data.json", "{\"k\": 1}"),
289        ]);
290
291        let denied = eval_source(fs.clone(), "main.aura", &EvalOptions::default());
292        let reports = denied.expect_err("read_file must be denied without a grant");
293        assert!(
294            reports.iter().any(|r| r.code == "E0310"),
295            "expected E0310, got {reports:?}"
296        );
297
298        let allowed = eval_source(
299            fs,
300            "main.aura",
301            &EvalOptions {
302                allow_read: vec![".".into()],
303                ..Default::default()
304            },
305        )
306        .unwrap_or_else(|e| panic!("{e:?}"));
307        assert_eq!(allowed.json["data"]["k"], 1);
308    }
309
310    /// Diagnostics still carry the buffer name and a position, which the
311    /// playground needs to put a marker in the right editor tab.
312    #[test]
313    fn errors_point_at_the_buffer_they_came_from() {
314        let fs = files(&[
315            ("main.aura", "import \"lib.aura\" as lib\nx: lib.boom\n"),
316            ("lib.aura", "pub def boom()\n  y: undefined_name\nend\n"),
317        ]);
318        let reports = eval_source(fs, "main.aura", &EvalOptions::default())
319            .expect_err("undefined name must fail");
320        let first = &reports[0];
321        assert_eq!(first.code, "E0504", "{reports:?}");
322        assert_eq!(first.file, "lib.aura", "must name the buffer");
323        assert!(first.line > 0, "must carry a line");
324    }
325
326    /// The capability boundary holds in memory too: an imported buffer gets no
327    /// file access even when the root was granted it (D1).
328    #[test]
329    fn imports_get_no_file_access_in_memory_either() {
330        let fs = files(&[
331            ("main.aura", "import \"dep.aura\" as dep\nx: dep.data\n"),
332            ("dep.aura", "data: read_file(\"secret.txt\")\n"),
333            ("secret.txt", "s3cr3t"),
334        ]);
335        let reports = eval_source(
336            fs,
337            "main.aura",
338            &EvalOptions {
339                allow_read: vec![".".into()],
340                ..Default::default()
341            },
342        )
343        .expect_err("an import must not read files");
344        assert!(
345            reports.iter().any(|r| r.code == "E0310"),
346            "expected E0310, got {reports:?}"
347        );
348    }
349
350    /// Hermetic mode refuses the call itself, not the access — the difference
351    /// between E0505 and E0310 is the whole point, since only the former is
352    /// decidable without running the branch.
353    #[test]
354    fn hermetic_turns_effectful_calls_into_analysis_errors() {
355        for source in ["x: env(\"HOME\", \"/\")\n", "x: read_file(\"data.json\")\n"] {
356            let fs = files(&[("main.aura", source), ("data.json", "{}")]);
357            let reports = eval_source(
358                fs,
359                "main.aura",
360                &EvalOptions {
361                    hermetic: true,
362                    ..Default::default()
363                },
364            )
365            .expect_err("hermetic mode must refuse effectful calls");
366            assert!(
367                reports.iter().any(|r| r.code == "E0505"),
368                "expected E0505 for {source:?}, got {reports:?}"
369            );
370        }
371    }
372
373    /// A host that sets `hermetic` alongside grants gets the hermetic answer. The
374    /// CLI rejects the combination outright, but an embedder assembling options
375    /// from config could produce it, and the safe reading is the restrictive one.
376    #[test]
377    fn hermetic_wins_over_grants_that_contradict_it() {
378        let fs = files(&[
379            ("main.aura", "data: read_file(\"data.json\")\n"),
380            ("data.json", "{}"),
381        ]);
382        let reports = eval_source(
383            fs,
384            "main.aura",
385            &EvalOptions {
386                hermetic: true,
387                allow_read: vec![".".into()],
388                allow_env: EnvCap::AllowAll,
389                ..Default::default()
390            },
391        )
392        .expect_err("a grant must not re-open a hermetic evaluation");
393        assert!(
394            reports.iter().any(|r| r.code == "E0505"),
395            "expected E0505, got {reports:?}"
396        );
397    }
398
399    /// It reaches imports too: a dependency that reads the environment cannot be
400    /// used in a hermetic build, and that is reported before evaluation of the
401    /// root gets anywhere near the value.
402    #[test]
403    fn hermetic_reaches_imported_modules() {
404        let fs = files(&[
405            ("main.aura", "import \"dep.aura\" as dep\nx: dep.who\n"),
406            ("dep.aura", "who: env(\"USER\", \"nobody\")\n"),
407        ]);
408        let reports = eval_source(
409            fs,
410            "main.aura",
411            &EvalOptions {
412                hermetic: true,
413                ..Default::default()
414            },
415        )
416        .expect_err("an import must not perform I/O in hermetic mode");
417        assert!(
418            reports.iter().any(|r| r.code == "E0505"),
419            "expected E0505, got {reports:?}"
420        );
421    }
422
423    /// And a manifest that needs nothing is unaffected: the mode must not become a
424    /// reason to avoid using it.
425    #[test]
426    fn hermetic_leaves_a_pure_manifest_alone() {
427        let fs = files(&[("main.aura", "base = 8000\napi:\n  port: base + 80\nend\n")]);
428        let out = eval_source(
429            fs,
430            "main.aura",
431            &EvalOptions {
432                hermetic: true,
433                ..Default::default()
434            },
435        )
436        .unwrap_or_else(|e| panic!("{e:?}"));
437        assert_eq!(out.json["api"]["port"], 8080);
438    }
439}