Skip to main content

cove_sema/
compile.rs

1//! The checking pipeline, and the one thing an embedder configures about it.
2//!
3//! `cove check` reads the Host API schemas of the modules the toolchain
4//! ships, so a call into `http.fetch` is checked at the call site: its
5//! arity, its argument types, the type it produces, the fields of the types
6//! it names, and the capability it costs. A module an embedder registers
7//! used to get none of that. It exists only at run time, so the checker had
8//! nothing to read, every call into it produced an unknown type, and the
9//! Host API boundary was the first thing that looked at the call at all.
10//!
11//! Embedding is a primary use of Cove, so that was a gap rather than a
12//! design: an embedder can already write a perfectly precise
13//! [`ModuleSchema`], and what was missing was somewhere to hand it. A
14//! [`Compiler`] is that somewhere.
15//!
16//! ```no_run
17//! # use cove_schema::ModuleSchema;
18//! # use cove_sema::{package, Compiler};
19//! # fn main() -> Result<(), Vec<cove_diag::Diagnostic>> {
20//! # const COMPANY: ModuleSchema = ModuleSchema {
21//! #     name: "company", capability: "company",
22//! #     operations: &[], types: &[], resources: &[],
23//! # };
24//! # let mut sources = cove_diag::SourceMap::new();
25//! # let package = package::load(std::path::Path::new("."), &mut sources)?;
26//! let program = Compiler::new().with_host_schema(COMPANY).compile(&package)?;
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! The value handed over is the same `ModuleSchema` the module registers
32//! with at run time — `cove_runtime::HostApi::module_schema` answers with
33//! it, and `HostRegistry` enforces it — so the description the checker reads
34//! and the one the boundary holds a call to cannot drift apart. Restating
35//! one of them is what drift is made of.
36//!
37//! # No `cove` command can be handed one, and none should be
38//!
39//! This used to end "nothing is serialized: a format for describing a host
40//! module out of process should be invented when something outside a process
41//! needs to read one".
42//! [Issue #151](https://github.com/myuon/cove/issues/151) is a candidate for
43//! that something, and the answer it settled on is no. It is worth stating
44//! here rather than in an issue, because this is where an embedder meets the
45//! question.
46//!
47//! The complaint is real. A rule package written against an embedder's module
48//! can be checked by the embedder, in Rust, and cannot be checked by the
49//! toolchain the person who wrote it has: `cove check` reports
50//! `cove::resolve::unchecked_host` and stops at the boundary, and `cove test`
51//! cannot run a test that touches the module at all. The rule author's whole
52//! toolchain is `cove fmt`, `cove check` and `cove test`, and two of the three
53//! stop where the embedder begins.
54//!
55//! What would not fix it is a `[hosts]` key in `cove.toml` naming a serialized
56//! schema. The whole of what makes a [`ModuleSchema`] worth anything is that
57//! the value the checker reads and the value the boundary enforces are one
58//! value; a description in a config file is a second one, written by hand in
59//! another vocabulary, kept true by whoever remembered. A checker reading the
60//! second while the run enforces the first reports exactly the failure ADR
61//! 0017 exists to prevent, with the authority of having checked. Generating
62//! the file from the `const` removes the drift and leaves the rest: the copy
63//! is stale the moment the embedder rebuilds, and nothing in the package can
64//! tell.
65//!
66//! `cove test` is what settles it, though, and it settles it for any format.
67//! A schema lets the checker *check* a call into `reviews`; it lets nothing
68//! *run* one, because what answers a call is an implementation, an
69//! implementation is Rust, and no description carries one. A `cove` that had
70//! been handed a schema would check a rule package it still could not test —
71//! one of the two commands the issue is about, and the one a rule author uses
72//! most.
73//!
74//! So the toolchain for a package written against an embedder's module is the
75//! embedder's to provide, and providing it is one line, because the value that
76//! describes the module is the value the registry was registered with:
77//!
78//! ```ignore
79//! let package = cove_sema::package::load(root, &mut sources)?;
80//! let program = Compiler::new()
81//!     .with_host_schemas(hosts.module_schemas())
82//!     .compile(&package)?;
83//! for notice in &program.notices {
84//!     eprint!("{}", cove_diag::render(&sources, notice));
85//! }
86//! ```
87//!
88//! `examples/rules/host/src/bin/check.rs` is that, whole: it is the `cove
89//! check` of an application that embeds Cove, and it reads the same `REVIEWS`
90//! its `HostApi` answers with, so the two cannot drift. An embedder that wants
91//! the test runner too registers its hosts beside the schemas and runs the
92//! package's `test fn` declarations against them — which needs the
93//! implementation, and is the same conclusion reached from the other end.
94//!
95//! The `unchecked_host` warning stays, and it is accurate: a `cove check` that
96//! was handed no description has not checked those calls, and saying so is
97//! better than a silence that reads like a proof. Its `help` already names the
98//! API to hand the schema to; what it cannot say in one line, and what this
99//! section is, is why there is no flag to hand it to instead.
100
101use cove_diag::Diagnostic;
102use cove_schema::{HostSchemas, ModuleSchema};
103
104use crate::package::Package;
105use crate::resolve::Program;
106use crate::{resolve, typeck};
107
108/// A checking pipeline, and the Host API schemas it reads.
109///
110/// The default reads the shipped schemas and nothing else, which is what
111/// every `cove` command does. An embedder adds the modules it registers.
112#[derive(Clone, Debug, Default)]
113pub struct Compiler {
114    schemas: HostSchemas,
115}
116
117impl Compiler {
118    /// A pipeline that reads the shipped Host API schemas.
119    pub fn new() -> Compiler {
120        Compiler::default()
121    }
122
123    /// Adds one host module's description.
124    ///
125    /// The schema is checked against exactly as a shipped module's is: the
126    /// module may be named by a `use`, no package module may shadow it,
127    /// calls into it are checked at the call site, its types may be written
128    /// and initialized, its resources answer the operations it declares, and
129    /// a function reaching it requires the capability the schema names.
130    pub fn with_host_schema(mut self, schema: ModuleSchema) -> Compiler {
131        self.schemas.insert(schema);
132        self
133    }
134
135    /// Adds every host module in `schemas`.
136    ///
137    /// This is what pairs a checker with a set of registered hosts in one
138    /// line: `cove_runtime::HostRegistry::module_schemas` hands back the
139    /// table every registered module declared itself with, and passing it
140    /// here checks the program against the same descriptions the run will
141    /// enforce.
142    pub fn with_host_schemas(
143        mut self,
144        schemas: impl IntoIterator<Item = ModuleSchema>,
145    ) -> Compiler {
146        self.schemas.extend(schemas);
147        self
148    }
149
150    /// Reads `schemas` and nothing else, replacing whatever this pipeline
151    /// was reading.
152    ///
153    /// This is how an embedding whose registry is its own says so.
154    /// [`with_host_schema`](Compiler::with_host_schema) and
155    /// [`with_host_schemas`](Compiler::with_host_schemas) *add* to the
156    /// shipped tables, which is right for a run that registers the shipped
157    /// hosts and some of its own. A run that registers neither wants
158    /// `cove_schema::HostSchemas::only`, so that a `use files` in a program
159    /// it is about to run is reported by the checker rather than by the
160    /// boundary:
161    ///
162    /// ```ignore
163    /// let program = Compiler::new()
164    ///     .with_schemas(HostSchemas::only(hosts.module_schemas()))
165    ///     .compile(&package)?;
166    /// ```
167    pub fn with_schemas(mut self, schemas: HostSchemas) -> Compiler {
168        self.schemas = schemas;
169        self
170    }
171
172    /// The host modules this pipeline can see.
173    pub fn host_schemas(&self) -> &HostSchemas {
174        &self.schemas
175    }
176
177    /// Resolves `package`: names, imports, capabilities, and the call graph.
178    pub fn resolve(&self, package: &Package) -> Result<Program, Vec<Diagnostic>> {
179        resolve::resolve_with(package, &self.schemas)
180    }
181
182    /// Type-checks an already resolved `package`, reporting errors and
183    /// warnings together.
184    pub fn check(&self, package: &Package, program: &Program) -> Vec<Diagnostic> {
185        typeck::check_with(package, program, &self.schemas)
186    }
187
188    /// Resolves and type-checks `package`, which is what `cove check` does.
189    ///
190    /// The returned program carries [`Program::facts`]: the type the checker
191    /// settled for every expression, and the declaration each resolved
192    /// method call reaches.
193    ///
194    /// Warnings and notes from both halves are carried on the returned
195    /// program's [`Program::notices`] rather than mixed into its errors, so
196    /// a caller can report them without having to decide which of them
197    /// stopped anything. A failure reports the errors first and the warnings
198    /// after, because a reader looking for what went wrong should not have
199    /// to read past what merely could.
200    pub fn compile(&self, package: &Package) -> Result<Program, Vec<Diagnostic>> {
201        if let Some(diagnostic) = missing_stdlib_diagnostic(package) {
202            return Err(vec![diagnostic]);
203        }
204        let mut program = self.resolve(package)?;
205        let (diagnostics, facts) = typeck::check_facts(package, &program, &self.schemas);
206        let (errors, warnings): (Vec<Diagnostic>, Vec<Diagnostic>) = diagnostics
207            .into_iter()
208            .partition(|d| d.severity == cove_diag::Severity::Error);
209        if !errors.is_empty() {
210            let mut items = errors;
211            items.extend(warnings);
212            return Err(items);
213        }
214        program.notices.extend(warnings);
215        // A program that checked carries what the check worked out. Nothing
216        // downstream has to walk the tree again to learn a type, which ADR
217        // 0019 makes the rule for the lowering and which holds for anything
218        // else reading a checked package.
219        program.facts = facts;
220        Ok(program)
221    }
222}
223
224/// The one diagnostic for a package a caller forgot to attach the standard
225/// library to.
226///
227/// `cove_schema::builtins::STANDARD_LIBRARY` names the modules a builtin
228/// method's body now lives in, and `cove_ir`'s lowering trusts that a call
229/// into one of them resolves: it lowers `items.isEmpty()` to an ordinary
230/// call on `std.array.isEmpty` the same way it lowers a call to any other
231/// declared function, with no fallback. A `Package` built by hand — an
232/// embedder's, or a test harness's — that skipped
233/// `cove_sema::stdlib::attach` would resolve that call to nothing and fail
234/// somewhere past this point with no hint of why. This is `compile`'s one
235/// check before either half of the pipeline runs, so the diagnostic names
236/// the fix rather than leaving it to be rediscovered downstream.
237///
238/// `compile` cannot attach the standard library itself: [`stdlib::attach`]
239/// has to add its sources to the same [`SourceMap`](cove_diag::SourceMap)
240/// the rest of the package's units are in, and `compile` is handed a
241/// [`Package`] with no map of its own to add to.
242fn missing_stdlib_diagnostic(package: &Package) -> Option<Diagnostic> {
243    use std::collections::BTreeSet;
244
245    let missing: BTreeSet<&str> = cove_schema::builtins::standard_library()
246        .iter()
247        .map(|binding| binding.module)
248        .filter(|module| !package.modules.contains_key(*module))
249        .collect();
250    if missing.is_empty() {
251        return None;
252    }
253    let missing: Vec<&str> = missing.into_iter().collect();
254    Some(
255        Diagnostic::error(
256            "cove::compile::missing_stdlib",
257            format!(
258                "the standard library module{} {} {} missing from this package",
259                if missing.len() == 1 { "" } else { "s" },
260                missing
261                    .iter()
262                    .map(|name| format!("`{name}`"))
263                    .collect::<Vec<_>>()
264                    .join(", "),
265                if missing.len() == 1 { "is" } else { "are" },
266            ),
267        )
268        .rule(
269            "A package must include every module `cove_schema::builtins::STANDARD_LIBRARY` \
270             names, because lowering a builtin method whose body has moved into the standard \
271             library depends on finding it there.",
272        )
273        .help(
274            "Call `cove_sema::stdlib::attach` on the package's `SourceMap` and insert the \
275             modules it returns, the way `cove_sema::package::load` does.",
276        ),
277    )
278}
279
280#[cfg(test)]
281mod tests {
282    use std::collections::BTreeMap;
283    use std::path::PathBuf;
284
285    use cove_diag::{render, Severity, SourceMap};
286    use cove_schema::{Effect, FieldSchema, HostType, OperationSchema, ResourceSchema, TypeSchema};
287
288    use super::*;
289    use crate::capability::Capability;
290    use crate::config::Config;
291    use crate::package::{Module, Unit};
292
293    /// A host module no toolchain ships: one operation, one resource, one
294    /// type of its own, and a capability that is not its own name.
295    const COMPANY: ModuleSchema = ModuleSchema {
296        name: "company",
297        capability: "directory",
298        operations: &[
299            OperationSchema {
300                name: "employee",
301                params: &[HostType::String],
302                variadic: false,
303                result: HostType::Result(&HostType::Named("company.Employee"), &HostType::Error),
304                capability: "directory",
305                effect: Effect::Read,
306                cancellable: false,
307                recordable: true,
308                result_is_task_safe: true,
309            },
310            OperationSchema {
311                name: "roster",
312                params: &[],
313                variadic: false,
314                result: HostType::Result(&HostType::Named("company.Roster"), &HostType::Error),
315                capability: "directory",
316                effect: Effect::Read,
317                cancellable: false,
318                recordable: true,
319                result_is_task_safe: true,
320            },
321        ],
322        types: &[TypeSchema {
323            name: "Employee",
324            cases: &[],
325            fields: &[
326                FieldSchema {
327                    name: "name",
328                    ty: HostType::String,
329                },
330                FieldSchema {
331                    name: "seniority",
332                    ty: HostType::Int,
333                },
334            ],
335        }],
336        resources: &[ResourceSchema {
337            name: "Roster",
338            task_safe: true,
339            operations: &[OperationSchema {
340                name: "count",
341                params: &[],
342                variadic: false,
343                result: HostType::Int,
344                capability: "directory",
345                effect: Effect::Read,
346                cancellable: false,
347                recordable: true,
348                result_is_task_safe: true,
349            }],
350        }],
351    };
352
353    /// Builds a one-module package out of `text`, without touching disk.
354    fn package_of(text: &str) -> (SourceMap, Package) {
355        package_of_modules(&[("app", text)])
356    }
357
358    /// Builds a package of one file per named module, without touching disk.
359    fn package_of_modules(modules: &[(&str, &str)]) -> (SourceMap, Package) {
360        let mut sources = SourceMap::new();
361        let mut loaded = BTreeMap::new();
362        for (name, text) in modules {
363            let path = PathBuf::from(format!("{name}/main.cove"));
364            let file = sources.add(path.clone(), *text);
365            let ast = cove_syntax::parse_file(&sources, file).expect("the fixture parses");
366            loaded.insert(
367                (*name).to_string(),
368                Module {
369                    name: (*name).to_string(),
370                    dir: PathBuf::from(name),
371                    units: vec![Unit { file, path, ast }],
372                },
373            );
374        }
375        for (name, module) in crate::stdlib::attach(&mut sources).expect("stdlib parses") {
376            loaded.insert(name, module);
377        }
378        (
379            sources,
380            Package {
381                root: PathBuf::new(),
382                config: Config::default(),
383                modules: loaded,
384            },
385        )
386    }
387
388    const WELL_TYPED: &str = "\
389use company
390
391/// Reports how senior one employee is.
392export fn seniority(id: String) -> Result<Int, Error> {
393  let found = company.employee(id)?
394  Ok(found.seniority)
395}
396";
397
398    #[test]
399    fn a_supplied_schema_checks_a_call_into_a_module_nothing_ships() {
400        let (_, package) = package_of(WELL_TYPED);
401        let program = Compiler::new()
402            .with_host_schema(COMPANY)
403            .compile(&package)
404            .expect("a well-typed program against a supplied schema checks");
405        assert!(
406            program.notices.is_empty(),
407            "a described module warns about nothing: {:?}",
408            program.notices
409        );
410    }
411
412    /// The capability a call requires is the one the schema declares, not the
413    /// module's name: `company` is gated on `directory`.
414    ///
415    /// This is also what `cove outline` prints as a function's `requires`
416    /// line, which reads `required_capabilities` and nothing else, so a
417    /// custom module appears there for the same reason a shipped one does.
418    #[test]
419    fn a_supplied_schema_declares_the_capability_a_call_requires() {
420        let (_, package) = package_of(WELL_TYPED);
421        let program = Compiler::new()
422            .with_host_schema(COMPANY)
423            .compile(&package)
424            .expect("checks");
425        assert_eq!(
426            program.modules["app"].functions["seniority"].required_capabilities,
427            [Capability::new("directory")].into_iter().collect()
428        );
429    }
430
431    /// The whole point: a mistake in a call into a custom module is an error
432    /// at its call site, exactly as it is for a shipped one.
433    #[test]
434    fn a_supplied_schema_reports_a_mistake_at_the_call_site() {
435        let (sources, package) = package_of(
436            "\
437use company
438
439/// Passes an `Int` where the schema declares a `String`.
440export fn seniority(id: Int) -> Result<Int, Error> {
441  let found = company.employee(id)?
442  Ok(found.tenure)
443}
444",
445        );
446        let items = Compiler::new()
447            .with_host_schema(COMPANY)
448            .compile(&package)
449            .expect_err("an argument the schema does not declare is an error");
450        let rendered: String = items.iter().map(|item| render(&sources, item)).collect();
451        assert!(
452            rendered.contains("expected `String`, found `Int`")
453                && rendered.contains("argument `#1` is `String`"),
454            "{rendered}"
455        );
456        assert!(
457            rendered.contains("`company.Employee` has no field `tenure`"),
458            "{rendered}"
459        );
460    }
461
462    /// A supplied module owns its name as completely as a shipped one does.
463    /// Modules resolve before hosts, so a package module named `company`
464    /// would make the embedder's module unreachable for the whole package,
465    /// silently -- which is the reason shipped names are refused too.
466    #[test]
467    fn a_package_module_may_not_shadow_a_supplied_host_module() {
468        let (sources, package) = package_of_modules(&[
469            ("company", "/// Does something.\nexport fn thing() {\n}\n"),
470            ("app", "use company.thing\n"),
471        ]);
472        let items = Compiler::new()
473            .with_host_schema(COMPANY)
474            .compile(&package)
475            .expect_err("a package module named after a host module is refused");
476        let rendered: String = items.iter().map(|item| render(&sources, item)).collect();
477        assert!(
478            rendered.contains("module `company` has the same name as the host module `company`"),
479            "{rendered}"
480        );
481    }
482
483    /// A handle a supplied schema declares answers the operations that
484    /// schema gives it, and the checker knows what each of them produces.
485    #[test]
486    fn a_supplied_schema_checks_an_operation_on_a_resource_it_declares() {
487        let (_, package) = package_of(
488            "\
489use company
490
491/// Counts the whole directory through a handle the host keeps.
492export fn size() -> Result<Int, Error> {
493  let roster = company.roster()?
494  Ok(roster.count())
495}
496",
497        );
498        Compiler::new()
499            .with_host_schema(COMPANY)
500            .compile(&package)
501            .expect("a resource operation the schema declares checks");
502    }
503
504    #[test]
505    fn a_supplied_schema_reports_an_operation_its_resource_does_not_answer() {
506        let (sources, package) = package_of(
507            "\
508use company
509
510/// Calls an operation `company.Roster` does not answer.
511export fn size() -> Result<Int, Error> {
512  let roster = company.roster()?
513  Ok(roster.total())
514}
515",
516        );
517        let items = Compiler::new()
518            .with_host_schema(COMPANY)
519            .compile(&package)
520            .expect_err("an operation the resource does not answer is an error");
521        let rendered: String = items.iter().map(|item| render(&sources, item)).collect();
522        assert!(
523            rendered.contains("`company.Roster` has no operation `total`")
524                && rendered.contains("answers `count`"),
525            "{rendered}"
526        );
527    }
528
529    /// Without the schema the same program still compiles, because a module
530    /// the checker cannot see is left to the boundary. It says so, though.
531    #[test]
532    fn a_module_no_schema_describes_is_left_to_the_boundary_with_a_warning() {
533        let (sources, package) = package_of(WELL_TYPED);
534        let program = Compiler::new()
535            .compile(&package)
536            .expect("an unknown host module is not an error");
537        let rendered: String = program
538            .notices
539            .iter()
540            .map(|item| render(&sources, item))
541            .collect();
542        assert!(
543            rendered.contains("no Host API schema describes the host module `company`"),
544            "{rendered}"
545        );
546        assert!(
547            program
548                .notices
549                .iter()
550                .all(|item| item.severity == Severity::Warning),
551            "an undescribed host module warns rather than fails"
552        );
553    }
554}