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}