cove_wasm/lib.rs
1//! Cove in a browser tab: the front end and the linear-memory backend behind
2//! a C ABI, with no server and no Cove runtime anywhere but the page.
3//!
4//! [Issue #241](https://github.com/myuon/cove/issues/241) asks for a
5//! playground. This crate is the half of it that is Rust; `web/` is the half
6//! that is a page. What it exports is described in [`abi`], and it is seven
7//! functions and one import.
8//!
9//! The fifth is [`debug_json`]: the same run, watched by a [`record`]ing
10//! debugger, so that the page can scrub through a timeline of it. A browser
11//! cannot be given the debugger `cove debug` is — a Web Worker cannot block
12//! waiting for the page — and [`record`] says at length why, and what
13//! recording keeps and loses instead.
14//!
15//! The sixth is [`lex_json`], which is the front end's first stage and
16//! nothing after it: the page colours its editor by asking the compiler's own
17//! lexer what each piece of the text is. [`highlight`] argues why that is the
18//! only version of syntax highlighting worth having here.
19//!
20//! The seventh is [`lex_ir_json`], the same idea for the other text the page
21//! shows: the disassembly [`cove_ir::print`] writes. That one has no lexer to
22//! borrow, so [`highlight`] reads the line shapes the printer documents and
23//! says, in `ok`, when it met a line it did not know — which is what
24//! `web/check.mjs` holds it to, against the real disassembly of every shipped
25//! sample.
26//!
27//! # What is the same as `cove run`, and what is not
28//!
29//! The same: the parser, the checker, the lowering, the VM, the diagnostics
30//! (rendered by [`cove_diag::render`], so the browser shows the sentences the
31//! CLI shows), and the shipped host schemas — so a program that uses `http`
32//! type-checks here exactly as it does on the command line, and is refused at
33//! the boundary here exactly as it is there without a grant.
34//!
35//! Not the same, and each for a reason a browser gives:
36//!
37//! - **No filesystem.** `files` and `documents` are the in-memory hosts the
38//! differential harness uses, seeded empty; `process` is recorded; `http`
39//! and `database` are denied. Nothing here can reach anything.
40//! - **No real clock host.** `clock` is [`VirtualTime`], which is what makes
41//! `clock.sleep` finish at once and a program that measures itself
42//! deterministic. The *run's* clock is a different thing and is real: see
43//! [`RUN_LIMITS`].
44//! - **No tasks.** `spawn` is refused, with a span, in the runtime. A Cove
45//! task is a thread (ADR 0008) and one Web Worker is one thread; the
46//! alternative was to run a task's body inline, which would make this
47//! answer differently from the tree-walking oracle, and the corpus is held
48//! together by those two agreeing. `examples/tasks` does not run here, and
49//! that is the honest outcome rather than a bug.
50
51pub mod abi;
52pub mod highlight;
53mod json;
54pub mod record;
55
56use std::collections::BTreeMap;
57use std::io::Write;
58use std::path::PathBuf;
59use std::rc::Rc;
60use std::sync::{Arc, Mutex};
61use std::time::Duration;
62
63use cove_diag::{Diagnostic, Severity, SourceMap};
64use cove_runtime::{
65 Budget, Cancellation, Clock, Console, Database, Documents, Env, Files, Grants, HostRegistry,
66 Http, Limits, Process, ProcessLog, RunOutcome, Runtime, ValueCapture, VirtualTime, Vm,
67};
68use cove_sema::{Compiler, Config, HostSchemas, Module, Package, Unit};
69
70/// The module a playground's one source file belongs to, and the function
71/// that is run.
72///
73/// A package on disk takes its module names from its directory names, and a
74/// page has no directories, so one is chosen here and written into the
75/// diagnostics the reader sees. `playground.main` reads as what it is.
76pub const MODULE: &str = "playground";
77
78/// The entry function looked for in [`MODULE`].
79pub const ENTRY: &str = "main";
80
81/// The path the one source file is filed under in the [`SourceMap`], which is
82/// what a diagnostic's header names.
83const PATH: &str = "playground/main.cove";
84
85/// What a run in the playground is granted.
86///
87/// Every host is registered, as `cove run` registers every host — a grant and
88/// not a registration is what decides, and a refusal that names the missing
89/// capability is a better answer than an operation that does not exist. These
90/// five are the ones whose in-memory implementations can honestly answer:
91/// `console` prints into a buffer the page shows, `clock` is virtual, `env`
92/// is empty, and `files` and `documents` start empty and live as long as the
93/// run does.
94///
95/// `http`, `database` and `process` are absent. They are registered denied or
96/// recorded, so a program that calls them is told it was not granted the
97/// capability rather than being told the module does not exist.
98pub const GRANTS: [&str; 5] = ["console", "clock", "env", "files", "documents"];
99
100/// What bounds a run that the page did not bound itself.
101///
102/// A page can pass its own fuel and deadline; this is what it gets when it
103/// passes neither. Both are set, and deliberately: a tab that is running a
104/// Cove program is a tab that is not repainting, and the two bounds fail
105/// differently — fuel is deterministic and portable within one backend, and
106/// the deadline is what catches a program that spends its time inside one
107/// long host call rather than in a loop.
108///
109/// The deadline is enforced against the clock the embedder imports, which is
110/// `performance.now()` in a page and under node. It is a real bound and not a
111/// decoration: `cove_runtime`'s `wallclock` module says why the import is
112/// required rather than defaulted.
113pub const RUN_LIMITS: (u64, u64) = (200_000_000, 5_000);
114
115/// Bytes a Cove program printed, readable after the run.
116///
117/// The idiom is `crates/cove-cli/tests/differential.rs`'s, because the
118/// question is the same one: run a program with nothing of the machine
119/// attached and read back what it said.
120#[derive(Clone, Default)]
121struct Buffer(Arc<Mutex<Vec<u8>>>);
122
123impl Buffer {
124 fn text(&self) -> String {
125 String::from_utf8_lossy(&self.0.lock().unwrap_or_else(|held| held.into_inner()))
126 .into_owned()
127 }
128}
129
130impl Write for Buffer {
131 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
132 self.0
133 .lock()
134 .unwrap_or_else(|held| held.into_inner())
135 .extend_from_slice(buf);
136 Ok(buf.len())
137 }
138
139 fn flush(&mut self) -> std::io::Result<()> {
140 Ok(())
141 }
142}
143
144/// One source file, checked and lowered as far as it goes.
145struct Front {
146 sources: SourceMap,
147 diagnostics: Vec<Diagnostic>,
148 /// The checked program and its lowering, or neither. They are kept
149 /// together because a [`Runtime`] holds the first and the VM runs the
150 /// second, and a run needs both to exist.
151 lowered: Option<(cove_sema::resolve::Program, cove_ir::Program)>,
152}
153
154impl Front {
155 /// Whether anything stopped this from being a program that could run.
156 fn failed(&self) -> bool {
157 self.lowered.is_none()
158 }
159}
160
161/// A front end that got no further than these diagnostics.
162fn stopped(sources: SourceMap, diagnostics: Vec<Diagnostic>) -> Front {
163 Front {
164 sources,
165 diagnostics,
166 lowered: None,
167 }
168}
169
170/// Parses, checks and lowers `source`, collecting whatever diagnostics each
171/// stage produced.
172///
173/// The stages are `cove run`'s, in `cove run`'s order, against
174/// `HostSchemas::new()` — the shipped set — for both the check and the
175/// lowering. Using a narrower set for one than the other would let a program
176/// pass the checker and fail the lowering over a host neither the page nor
177/// the reader ever mentioned.
178fn front(source: &str) -> Front {
179 let mut sources = SourceMap::new();
180 let path = PathBuf::from(PATH);
181 let file = sources.add(path.clone(), source.to_string());
182
183 let ast = match cove_syntax::parse_file(&sources, file) {
184 Ok(ast) => ast,
185 Err(diagnostics) => return stopped(sources, diagnostics),
186 };
187
188 let mut modules = BTreeMap::new();
189 modules.insert(
190 MODULE.to_string(),
191 Module {
192 name: MODULE.to_string(),
193 dir: PathBuf::from(MODULE),
194 units: vec![Unit { file, path, ast }],
195 },
196 );
197 // A playground program is a package of one module built by hand rather
198 // than loaded with `cove_sema::package::load`, so it has to attach the
199 // standard library itself, the same way `load` does — a call into
200 // `Array.isEmpty` has to find `std.array.isEmpty` here exactly as it
201 // would in a package read from disk.
202 let std_modules = match cove_sema::stdlib::attach(&mut sources) {
203 Ok(std_modules) => std_modules,
204 Err(diagnostics) => return stopped(sources, diagnostics),
205 };
206 for (name, module) in std_modules {
207 modules.insert(name, module);
208 }
209 let package = Package {
210 root: PathBuf::new(),
211 config: Config::default(),
212 modules,
213 };
214
215 let schemas = HostSchemas::new();
216 let checked = match Compiler::new().with_schemas(schemas).compile(&package) {
217 Ok(checked) => checked,
218 Err(diagnostics) => return stopped(sources, diagnostics),
219 };
220
221 match cove_ir::lower_entry(&checked, &sources, &HostSchemas::new(), MODULE, ENTRY) {
222 Ok(program) => Front {
223 sources,
224 diagnostics: Vec::new(),
225 lowered: Some((checked, program)),
226 },
227 Err(diagnostics) => stopped(sources, diagnostics),
228 }
229}
230
231/// One diagnostic as JSON: what the CLI would have printed, plus the two
232/// fields a page needs in order to sort and count without re-parsing the
233/// printed form.
234fn diagnostic_json(sources: &SourceMap, diagnostic: &Diagnostic) -> String {
235 json::object([
236 (
237 "severity",
238 json::string(match diagnostic.severity {
239 Severity::Error => "error",
240 Severity::Warning => "warning",
241 Severity::Note => "note",
242 }),
243 ),
244 ("code", json::string(&diagnostic.code)),
245 ("message", json::string(&diagnostic.message)),
246 (
247 "rendered",
248 json::string(&cove_diag::render(sources, diagnostic)),
249 ),
250 ])
251}
252
253fn diagnostics_json(sources: &SourceMap, diagnostics: &[Diagnostic]) -> String {
254 json::array(
255 diagnostics
256 .iter()
257 .map(|diagnostic| diagnostic_json(sources, diagnostic)),
258 )
259}
260
261/// Checks and lowers `source`, and answers what a reader would want to see
262/// before running it.
263///
264/// ```json
265/// {"ok":bool,"diagnostics":[...],"ir":string|null}
266/// ```
267///
268/// `ok` is "nothing stopped this from running", which is not the same as "no
269/// diagnostics": a warning leaves `ok` true and is still shown. `ir` is
270/// this crate's own rendering of the lowered program's disassembly, minus
271/// the standard library, and is `null` for a source that did not reach the
272/// lowering.
273pub fn compile_json(source: &str) -> String {
274 let front = front(source);
275 json::object([
276 ("ok", (!front.failed()).to_string()),
277 (
278 "diagnostics",
279 diagnostics_json(&front.sources, &front.diagnostics),
280 ),
281 (
282 "ir",
283 json::or_null(
284 front
285 .lowered
286 .as_ref()
287 .map(|(_, program)| json::string(&disassembly(program))),
288 ),
289 ),
290 ])
291}
292
293/// The disassembly a person reads, which is [`cove_ir::print::program`]'s
294/// minus the standard library.
295///
296/// `cove_sema::stdlib::attach` puts a module in every package so a call into
297/// a builtin method that has moved into the standard library has a
298/// declaration to reach, and [`cove_ir::lower_entry`] gives *every*
299/// declaration of the package a [`cove_ir::Function`] — a stub for one
300/// nothing reaches, same as any unreached declaration a program's own author
301/// wrote. Both are right for what they are for: the package needs the
302/// module, and the lowering's whole-declaration accounting needs the stub.
303/// Neither is something a person asked to see when they open the playground
304/// and read what their own three-line program compiled to, so this is where
305/// the two facts above are read together and the standard library's
306/// functions are left out — the same reasoning `cove outline`, `cove api`
307/// and `cove check`'s summary already apply to the same module, for the
308/// same reason.
309fn disassembly(program: &cove_ir::Program) -> String {
310 let mut out = String::new();
311 for (index, function) in program.functions.iter().enumerate() {
312 if cove_sema::stdlib::module_names().contains(&&*function.module) {
313 continue;
314 }
315 if !out.is_empty() {
316 out.push('\n');
317 }
318 out.push_str(&cove_ir::print::function(
319 program,
320 cove_ir::FunctionId(index as u32),
321 ));
322 }
323 out
324}
325
326/// Lexes `source` and answers a colour for every part of it.
327///
328/// ```json
329/// {"ok":bool,"spans":[{"at":int,"len":int,"kind":string}]}
330/// ```
331///
332/// The spans *tile*: the first begins at zero, each begins where the last
333/// ended, and together they cover the source, so a page renders the whole
334/// editor by walking the list and slicing its own text. `at` and `len` count
335/// UTF-16 code units, which is what a JavaScript string is indexed in.
336///
337/// `kind` is one of `keyword`, `type`, `string`, `number`, `comment` and
338/// `plain`. [`highlight::Kind`] says what falls into each and which two are
339/// not decided by the lexer. Its seventh, `slot`, is a disassembly's and
340/// never appears here.
341///
342/// `ok` is whether the source lexed without complaint. It is false whenever
343/// the reader is part-way through typing a string literal, which is most of
344/// the time one is being typed, and the spans are still a tiling of what was
345/// sent: [`highlight`] says what a broken source is coloured as and why the
346/// answer is not "nothing".
347///
348/// This is the front end's first stage and none of the rest, so it costs a
349/// pass over the text and nothing else. That is what makes it the thing to
350/// call on every keystroke, where [`compile_json`] is not.
351pub fn lex_json(source: &str) -> String {
352 let painting = highlight::paint(source);
353 json::object([
354 ("ok", painting.ok.to_string()),
355 (
356 "spans",
357 json::array(painting.pieces.iter().map(|piece| {
358 json::object([
359 ("at", piece.at.to_string()),
360 ("len", piece.len.to_string()),
361 ("kind", json::string(piece.kind.as_str())),
362 ])
363 })),
364 ),
365 ])
366}
367
368/// Colours a disassembly and answers a colour for every part of it.
369///
370/// ```json
371/// {"ok":bool,"spans":[{"at":int,"len":int,"kind":string}]}
372/// ```
373///
374/// The same shape [`lex_json`] answers, and for the same consumer: the spans
375/// tile the text in UTF-16 code units, so a page renders the pane by walking
376/// them and slicing the string it already has.
377///
378/// `text` is what [`compile_json`]'s `ir` field held. It is passed back in
379/// rather than coloured on the way out because a colouring belongs to the
380/// text a page is *showing*, and a page shows one disassembly while a run
381/// answers another every time it is asked; sending both together would put a
382/// second copy of the tiling into every run answer for the sake of saving a
383/// call that costs one pass over a string.
384///
385/// `kind` adds `slot` to [`lex_json`]'s six. `ok` means something different
386/// here: not that the text parsed — it was written by
387/// [`cove_ir::print::program`] and always does — but that every line of it
388/// was a line shape that module documents. A false answer is a printer this
389/// module has not caught up with, and [`highlight`] says where that is
390/// asserted.
391pub fn lex_ir_json(text: &str) -> String {
392 let painting = highlight::disassembly(text);
393 json::object([
394 ("ok", painting.ok.to_string()),
395 (
396 "spans",
397 json::array(painting.pieces.iter().map(|piece| {
398 json::object([
399 ("at", piece.at.to_string()),
400 ("len", piece.len.to_string()),
401 ("kind", json::string(piece.kind.as_str())),
402 ])
403 })),
404 ),
405 ])
406}
407
408/// Checks, lowers and runs `source`, and answers what happened.
409///
410/// ```json
411/// {"ok":bool,"diagnostics":[...],"ir":string|null,"outcome":string|null,
412/// "stdout":string,"stderr":string,"answer":value|null,
413/// "instructions":int|null,"fuel":int|null}
414/// ```
415///
416/// `ir` is [`compile_json`]'s, repeated here so that one call fills every
417/// pane a page shows. A page that asked for the disassembly separately would
418/// be paying for the front end twice for one source, and the two answers
419/// could describe different text if the reader typed between them.
420///
421/// `outcome` is [`RunOutcome::as_str`], derived the way
422/// `crates/cove-cli/tests/differential.rs` derives it, so the name a page
423/// shows is the name a trace would have recorded. `answer` is the entry's
424/// value in [`cove_runtime::value_to_json`]'s encoding, which is this
425/// repository's existing answer to how a Cove value leaves Rust.
426///
427/// A source that did not compile is answered without being run, with the same
428/// `diagnostics` [`compile_json`] would have given: a page can call this one
429/// function and get both halves.
430///
431/// `fuel` and `deadline_ms` are `None` for "use [`RUN_LIMITS`]", not for "no
432/// bound". A playground that could be asked for an unbounded run would be a
433/// page with a hang button.
434pub fn run_json(source: &str, fuel: Option<u64>, deadline_ms: Option<u64>) -> String {
435 execute(source, fuel, deadline_ms, None)
436}
437
438/// Runs `source` as [`run_json`] does, watched by a [`record::Recorder`],
439/// and answers everything [`run_json`] answers plus the recording under
440/// `debug`.
441///
442/// ```json
443/// {…as run_json…,"debug":{"moments":[…],"functions":[…],"kept":int,
444/// "limit":int,"bytes":int,"truncated":…}}
445/// ```
446///
447/// `debug` is `null` for a source that did not compile, because there was no
448/// run to record and an empty recording of a program that never started
449/// reads as a program that did nothing.
450///
451/// `moments` is how many moments to keep; zero asks for
452/// [`record::MOMENTS`], and anything past [`record::MOST_MOMENTS`] is
453/// clamped to it. [`record`]'s module documentation says what a moment is,
454/// what bounds it, and why a browser gets a recording rather than a
455/// debugger it can step.
456///
457/// # One blob, not pieces
458///
459/// A recording is much larger than a compile result, so the alternative was
460/// considered and refused: a first call answering the moments' outlines and
461/// a second answering one moment's detail. Two things decided it.
462///
463/// A paged ABI needs the module to *hold* the recording between calls, and
464/// [`abi`] exists partly to have no module-level state — its length prefix
465/// replaced a "how long was the last answer?" export precisely so that two
466/// calls in flight have nothing to race over. Holding a recording would put
467/// that back, and worse, because the state would now be the size of the
468/// recording rather than of a number.
469///
470/// And the size a page actually pays is not the size paging would save.
471/// What makes a recording large is repetition, and the two repeated things —
472/// a function's disassembly and its name — are interned into `functions`
473/// once each. What is left per moment is what genuinely differs between
474/// moments. A recording of the example program is a few tens of kilobytes;
475/// see `web/README.md` for what larger ones measure. The bound that keeps it
476/// from growing without limit is [`record::BYTES`], and a bound is a better
477/// answer to "this could be huge" than an ABI that hands over a huge thing
478/// slowly.
479pub fn debug_json(
480 source: &str,
481 fuel: Option<u64>,
482 deadline_ms: Option<u64>,
483 moments: usize,
484) -> String {
485 execute(source, fuel, deadline_ms, Some(moments))
486}
487
488/// Checks, lowers and runs `source`, recording it when `moments` is `Some`.
489///
490/// One function and not two so that a debugged run and a plain one are the
491/// same run: the same hosts, the same grants, the same limits, the same
492/// classification of how it ended. A second copy of this setup would be a
493/// second playground that agreed with the first until it did not.
494fn execute(
495 source: &str,
496 fuel: Option<u64>,
497 deadline_ms: Option<u64>,
498 moments: Option<usize>,
499) -> String {
500 let recording = moments.is_some();
501 let front = front(source);
502 let Some((checked, program)) = front.lowered else {
503 let mut fields = vec![
504 ("ok", "false".to_string()),
505 (
506 "diagnostics",
507 diagnostics_json(&front.sources, &front.diagnostics),
508 ),
509 ("ir", "null".to_string()),
510 ("outcome", "null".to_string()),
511 ("stdout", json::string("")),
512 ("stderr", json::string("")),
513 ("answer", "null".to_string()),
514 ("instructions", "null".to_string()),
515 ("fuel", "null".to_string()),
516 ];
517 if recording {
518 fields.push(("debug", "null".to_string()));
519 }
520 return json::object(fields);
521 };
522
523 let out = Buffer::default();
524 let err = Buffer::default();
525
526 let mut hosts = HostRegistry::new(Grants::new(GRANTS));
527 hosts.register(Box::new(Console::new(out.clone(), err.clone())));
528 hosts.register(Box::new(Env::new(BTreeMap::new())));
529 hosts.register(Box::new(Documents::in_memory(BTreeMap::new())));
530 hosts.register(Box::new(Clock::virtual_clock(VirtualTime::new())));
531 hosts.register(Box::new(Files::in_memory(BTreeMap::new())));
532 hosts.register(Box::new(Process::recorded(
533 Vec::new(),
534 BTreeMap::new(),
535 ProcessLog::new(),
536 )));
537 hosts.register(Box::new(Database::denied()));
538 hosts.register(Box::new(Http::denied()));
539 // A refused call should not send the reader to a `cove.toml` that a page
540 // does not have and cannot be given.
541 hosts.set_grant_source(cove_runtime::GrantSource::Sealed);
542
543 let limits = Limits {
544 fuel: Some(fuel.unwrap_or(RUN_LIMITS.0)),
545 deadline: Some(Duration::from_millis(deadline_ms.unwrap_or(RUN_LIMITS.1))),
546 max_host_calls: None,
547 max_call_depth: None,
548 // Refused in the runtime and refused again here, because the two say
549 // different things: this is the bound a host chose, and the runtime's
550 // refusal is the fact that there is no thread to give. A reader who
551 // raises this one still gets the honest sentence.
552 max_tasks: Some(0),
553 };
554 hosts.set_budget(Budget::with_cancellation(limits, Cancellation::new()));
555
556 let sources = Arc::new(front.sources);
557 let runtime = Runtime::new(Arc::new(checked), Arc::clone(&sources), Arc::new(hosts));
558
559 let program_disassembly = disassembly(&program);
560 let recorder = moments.map(|moments| record::Recorder::new(Arc::clone(&sources), moments));
561 let (answer, instructions, fuel_spent) = {
562 let mut vm = match &recorder {
563 Some(recorder) => Vm::debugged(&runtime, runtime.hosts(), &program, recorder),
564 None => Vm::new(&runtime, runtime.hosts(), &program),
565 };
566 let answer = vm.run_entry(MODULE, ENTRY, Vec::<Rc<str>>::new());
567 let instructions = vm.instructions();
568 let spent = runtime
569 .hosts()
570 .with_budget(|budget| budget.meter().fuel_spent());
571 (answer, instructions, spent)
572 };
573
574 let outcome = match &answer {
575 Ok(value) if value.is_err() => RunOutcome::Error,
576 Ok(_) => RunOutcome::Success,
577 Err(error) => error.outcome,
578 };
579 let diagnostics = match &answer {
580 Ok(_) => Vec::new(),
581 Err(error) => vec![error.to_diagnostic()],
582 };
583
584 let mut fields = vec![
585 ("ok", matches!(outcome, RunOutcome::Success).to_string()),
586 ("diagnostics", diagnostics_json(&sources, &diagnostics)),
587 ("ir", json::string(&program_disassembly)),
588 ("outcome", json::string(outcome.as_str())),
589 ("stdout", json::string(&out.text())),
590 ("stderr", json::string(&err.text())),
591 (
592 "answer",
593 json::or_null(
594 answer
595 .as_ref()
596 .ok()
597 .map(|value| cove_runtime::value_to_json(value, ValueCapture::Full)),
598 ),
599 ),
600 ("instructions", instructions.to_string()),
601 (
602 "fuel",
603 json::or_null(fuel_spent.map(|spent| spent.to_string())),
604 ),
605 ];
606 if let Some(recorder) = &recorder {
607 fields.push(("debug", recorder.json()));
608 }
609 json::object(fields)
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 /// The JSON string value of `key`, unescaped only as far as these tests
617 /// need — enough to compare a rendered diagnostic or a printed line.
618 ///
619 /// A parser would be the third-party dependency this crate exists partly
620 /// to avoid. What the tests need is "does the answer say this", and a
621 /// substring search over the escaped form answers it without one.
622 fn says(json: &str, fragment: &str) -> bool {
623 json.contains(fragment)
624 }
625
626 #[test]
627 fn a_program_that_compiles_answers_its_disassembly() {
628 let json = compile_json("export fn main() -> Int { 21 * 2 }");
629 assert!(says(&json, r#""ok":true"#), "{json}");
630 assert!(says(&json, r#""diagnostics":[]"#), "{json}");
631 assert!(says(&json, "playground.main"), "{json}");
632 }
633
634 /// The point of rendering with [`cove_diag::render`] rather than printing
635 /// the message: what the page shows is the caret and the snippet the CLI
636 /// shows, over the path the source was filed under.
637 #[test]
638 fn a_program_that_does_not_parse_answers_the_rendered_diagnostic() {
639 let json = compile_json("export fn main() -> Int { 1 +");
640 assert!(says(&json, r#""ok":false"#), "{json}");
641 assert!(says(&json, r#""ir":null"#), "{json}");
642 assert!(says(&json, r#""severity":"error""#), "{json}");
643 assert!(says(&json, "playground/main.cove"), "{json}");
644 }
645
646 /// A source with no `main` is refused by name, and the refusal is a
647 /// rendered diagnostic like any other rather than a blank answer.
648 #[test]
649 fn a_source_without_the_entry_is_refused_by_name() {
650 let json = run_json("export fn other() -> Int { 1 }", None, None);
651 assert!(says(&json, r#""ok":false"#), "{json}");
652 assert!(
653 says(&json, "this package does not declare `playground.main`"),
654 "{json}"
655 );
656 }
657
658 #[test]
659 fn a_run_answers_what_the_entry_produced() {
660 let json = run_json("export fn main() -> Int { 21 * 2 }", None, None);
661 assert!(says(&json, r#""outcome":"success""#), "{json}");
662 assert!(
663 says(&json, r#""answer":{"type":"int","value":42}"#),
664 "{json}"
665 );
666 }
667
668 /// `console` is granted, and what a program prints is a string in the
669 /// answer rather than bytes that went nowhere.
670 #[test]
671 fn a_run_answers_what_the_program_printed() {
672 let json = run_json(
673 r#"use console.println
674
675export fn main() -> Result<Unit, Error> {
676 println("hello from the tab")?
677 Ok(())
678}"#,
679 None,
680 None,
681 );
682 assert!(says(&json, r#""outcome":"success""#), "{json}");
683 assert!(says(&json, r#""stdout":"hello from the tab\n""#), "{json}");
684 }
685
686 /// The bound a page can put on a loop, doing what it says.
687 #[test]
688 fn a_run_past_its_fuel_is_stopped_and_classified() {
689 let json = run_json(
690 "export fn main() -> Int {\n var n = 0\n while true { n = n + 1 }\n n\n}",
691 Some(10_000),
692 None,
693 );
694 assert!(says(&json, r#""outcome":"fuel""#), "{json}");
695 assert!(says(&json, r#""ok":false"#), "{json}");
696 assert!(says(&json, "fuel budget of 10000 exhausted"), "{json}");
697 }
698
699 /// A capability the playground does not grant is refused at the boundary,
700 /// in the runtime's own words, rather than by the module not existing.
701 ///
702 /// This is also what says the checker was given the *shipped* schemas: a
703 /// narrower set would have made this a type error instead, which is a
704 /// different sentence about a different thing.
705 #[test]
706 fn an_ungranted_capability_is_refused_at_the_boundary() {
707 let json = run_json(
708 "use http\n\nexport fn main() -> Result<http.Response, Error> {\n http.fetch(\"http://example.com\")\n}",
709 None,
710 None,
711 );
712 assert!(says(&json, r#""outcome":"host_boundary""#), "{json}");
713 assert!(says(&json, "http"), "{json}");
714 }
715
716 /// On the host a task really does get a thread, so this is the one thing
717 /// these tests cannot check: that `spawn` is refused. What they can check
718 /// is that a `spawn` past the host-chosen `max_tasks` is refused in the
719 /// same vocabulary, `RunOutcome::Concurrency`, which is what the wasm
720 /// refusal answers too. `web/check.mjs` checks the other half, in wasm,
721 /// where there is no thread to be had.
722 #[test]
723 fn a_spawn_is_refused_as_a_concurrency_stop() {
724 let json = run_json(
725 r#"export fn main() -> Int {
726 scope s {
727 let t = s.spawn { 1 }
728 t.await()
729 }
730}"#,
731 None,
732 None,
733 );
734 assert!(says(&json, r#""outcome":"concurrency""#), "{json}");
735 }
736
737 /// Every value of `key` in `json`, in the order they were written.
738 ///
739 /// A recording is a sequence, and what these tests need to say about one
740 /// is "these things, in this order". A substring search answers "is this
741 /// in there" and cannot answer that, so this is the smallest thing that
742 /// can — still not a parser, still no dependency.
743 fn every(json: &str, key: &str) -> Vec<String> {
744 let needle = format!("\"{key}\":");
745 let mut found = Vec::new();
746 let mut rest = json;
747 while let Some(at) = rest.find(&needle) {
748 rest = &rest[at + needle.len()..];
749 let value = match rest.strip_prefix('"') {
750 Some(quoted) => {
751 let end = quoted.find('"').unwrap_or(quoted.len());
752 quoted[..end].to_string()
753 }
754 None => rest
755 .chars()
756 .take_while(|c| c.is_ascii_digit() || *c == '-')
757 .collect(),
758 };
759 found.push(value);
760 }
761 found
762 }
763
764 /// A program written so that every rule in [`record`]'s capture policy
765 /// fires exactly once and in a knowable order: an entry, a new line, a
766 /// call, and the return.
767 const WALKED: &str = r#"export fn twice(n: Int) -> Int {
768 n + n
769}
770
771export fn main() -> Int {
772 let one = 21
773 let total = twice(one)
774 total
775}
776"#;
777
778 /// The recording of a known program has the moments it should have, in
779 /// the order it ran them.
780 #[test]
781 fn a_recording_holds_the_moments_the_program_ran_in_order() {
782 let json = debug_json(WALKED, None, None, 0);
783 assert!(says(&json, r#""outcome":"success""#), "{json}");
784
785 // `why` appears once per moment and nowhere else in the answer.
786 assert_eq!(
787 every(&json, "why"),
788 // Three, and it was four until `lower::inline`, and six before
789 // that until issue #302. Two of the six were copies: the callee's
790 // answer moved out of a temporary, and `main`'s moved into the
791 // location the `return` names. Neither instruction exists now —
792 // the producer writes the destination — and a `return` is written
793 // at the tail it answers rather than at the signature, so the line
794 // does not change again on the way out.
795 //
796 // The `line` went with the call. `twice` is a small leaf, so its
797 // body is written into `main`, and the instruction that used to be
798 // `main`'s `call` on line 7 is now `twice`'s `n + n` written on
799 // line 2. Line 7 has no instruction of its own left for a new-line
800 // rule to fire on. The `call` is still here, and that is the
801 // record doing its work: `Stop::depth` counts an expanded body, so
802 // the moment where one begins is still a moment where the stack
803 // got deeper.
804 ["entry", "call", "return"],
805 "{json}"
806 );
807
808 // One disassembly, held once however often it is in a moment: the
809 // interning that keeps a recording from repeating a loop body once
810 // per turn.
811 //
812 // One and not two, because `twice` is a small leaf and
813 // `lower::inline` wrote its body into `main`. There is one
814 // instruction stream and the table holds disassemblies, so a second
815 // entry could only be `main`'s four instructions under `twice`'s
816 // name — which is what keying the table on the frame's body rather
817 // than on the code's owner produced, and is the reason it does not.
818 assert_eq!(
819 every(&json, "name")
820 .iter()
821 .filter(|name| name.starts_with("playground."))
822 .count(),
823 1,
824 "{json}"
825 );
826 // Which body each frame is, though, is still recorded — on the frame,
827 // where the expansion's own record put it. Without this the backtrace
828 // of a stop inside `twice` would name `main` twice.
829 assert_eq!(
830 every(&json, "body")
831 .iter()
832 .filter(|name| *name == "playground.twice")
833 .count(),
834 1,
835 "{json}"
836 );
837 assert!(says(&json, r#""truncated":null"#), "{json}");
838 assert!(says(&json, r#""kept":3"#), "{json}");
839 }
840
841 /// The locals a moment holds are that moment's, and they change along
842 /// the timeline.
843 ///
844 /// The moment inside the callee shows `n`, and the caller's frame under
845 /// it shows the name it had already bound and not the one it is in the
846 /// middle of binding: a suspended frame is shown at the call it is
847 /// waiting on, and `total` is bound after that call answers. The moment
848 /// after the return shows it holding 42.
849 ///
850 /// It showed `total` as a zero until issue #302, which is what a caller
851 /// shown at its *resume* address answers — the slot the call is about to
852 /// write, named but not yet written. Both the destination forwarding and
853 /// the `- 1` in `Stop::frame` are that change; a test that showed 42 in
854 /// both moments would mean the recording was not per-moment at all.
855 #[test]
856 fn a_local_holds_what_it_held_at_that_moment() {
857 let json = debug_json(WALKED, None, None, 0);
858 let moments: Vec<&str> = json.split(r#"{"at":"#).collect();
859 let inside = moments
860 .iter()
861 .find(|moment| moment.contains(r#""why":"call""#))
862 .unwrap_or_else(|| panic!("a call moment: {json}"));
863 let after = moments
864 .iter()
865 .find(|moment| moment.contains(r#""why":"return""#))
866 .unwrap_or_else(|| panic!("a return moment: {json}"));
867 assert!(inside.contains(r#""name":"n","value":"21""#), "{inside}");
868 assert!(inside.contains(r#""name":"one","value":"21""#), "{inside}");
869 assert!(!inside.contains(r#""name":"total""#), "{inside}");
870 assert!(after.contains(r#""name":"total","value":"42""#), "{after}");
871 }
872
873 /// A local that names a heap object points at one the moment carries.
874 #[test]
875 fn a_local_that_names_an_object_carries_it() {
876 let json = debug_json(
877 "export fn main() -> Int {\n let greeting = \"hello\"\n greeting.length()\n}",
878 None,
879 None,
880 0,
881 );
882 assert!(says(&json, r#""outcome":"success""#), "{json}");
883 assert!(says(&json, r#""name":"String""#), "{json}");
884 assert!(says(&json, r#""name":"text","value":"hello""#), "{json}");
885 // The address on the local and the address on the object are the
886 // same number, which is what lets a Memory pane follow a name.
887 let addresses = every(&json, "at");
888 assert!(
889 addresses.iter().any(|at| at.len() > 4),
890 "an object address: {json}"
891 );
892 }
893
894 /// The span a moment carries, which is what the page marks in the editor.
895 ///
896 /// A pair of UTF-16 offsets into the source the page already holds, so
897 /// `text.slice(from, to)` is the text that ran. The em dash above the
898 /// code is what tells this apart from a pair of byte offsets: it is two
899 /// bytes wide and one code unit, so a byte offset would mark two
900 /// characters to the right of everything below it.
901 #[test]
902 fn a_moment_carries_the_span_the_page_marks() {
903 let source = "// an \u{2014} dash\nexport fn main() -> Int {\n 21 * 2\n}\n";
904 let json = debug_json(source, None, None, 0);
905 let units: Vec<u16> = source.encode_utf16().collect();
906 let read = |key| -> Vec<usize> {
907 every(&json, key)
908 .iter()
909 .map(|held| held.parse().expect("an offset is a number"))
910 .collect()
911 };
912 let (from, to) = (read("from"), read("to"));
913 assert!(!from.is_empty(), "{json}");
914 assert_eq!(from.len(), to.len(), "{json}");
915 for (from, to) in from.iter().zip(&to) {
916 assert!(from <= to && *to <= units.len(), "{from}..{to} of {json}");
917 let marked = String::from_utf16(&units[*from..*to]).expect("whole code points");
918 assert!(source.contains(&marked), "{marked:?} is in the source");
919 }
920 // The first moment is the entry, which is `21 * 2`'s first operand.
921 // The same two numbers read as byte offsets are the two spaces that
922 // indent the line, which is what a page marking by them would have
923 // drawn a band on.
924 assert_eq!(
925 String::from_utf16(&units[from[0]..to[0]]).expect("whole code points"),
926 "21",
927 "{json}"
928 );
929 assert_eq!(&source[from[0]..to[0]], " ", "{json}");
930 }
931
932 /// A recording that hit its bound says which bound, and the run it was
933 /// recording still finished and still answered.
934 ///
935 /// The second half is the point. A recorder that halted the run when it
936 /// filled up would answer a question about a program with a program that
937 /// did not run.
938 #[test]
939 fn a_recording_past_its_bound_says_so_and_the_run_goes_on() {
940 let counting =
941 "export fn main() -> Int {\n var n = 0\n while n < 100 {\n n = n + 1\n }\n n\n}";
942 let json = debug_json(counting, None, None, 4);
943 assert!(says(&json, r#""truncated":"moments""#), "{json}");
944 assert!(says(&json, r#""kept":4"#), "{json}");
945 assert!(says(&json, r#""limit":4"#), "{json}");
946 // The run reached its own end rather than the recorder's.
947 assert!(says(&json, r#""outcome":"success""#), "{json}");
948 assert!(
949 says(&json, r#""answer":{"type":"int","value":100}"#),
950 "{json}"
951 );
952 }
953
954 /// A caller cannot ask for an unbounded recording.
955 #[test]
956 fn a_recording_is_bounded_however_much_is_asked_for() {
957 let json = debug_json("export fn main() -> Int { 1 }", None, None, usize::MAX);
958 assert!(
959 says(&json, &format!(r#""limit":{}"#, record::MOST_MOMENTS)),
960 "{json}"
961 );
962 }
963
964 /// A debugged run is the same run: the recorder watches it and does not
965 /// change it.
966 #[test]
967 fn recording_does_not_change_what_the_program_did() {
968 let source = r#"use console.println
969
970export fn main() -> Result<Int, Error> {
971 println("watched")?
972 Ok(21 * 2)
973}"#;
974 let plain = run_json(source, None, None);
975 let watched = debug_json(source, None, None, 0);
976 for fragment in [
977 r#""outcome":"success""#,
978 r#""stdout":"watched\n""#,
979 r#""instructions":"#,
980 ] {
981 assert!(plain.contains(fragment), "{plain}");
982 assert!(watched.contains(fragment), "{watched}");
983 }
984 assert_eq!(
985 every(&plain, "instructions"),
986 every(&watched, "instructions")
987 );
988 }
989
990 /// A source that did not compile has no recording, rather than an empty
991 /// one that reads as a program which did nothing.
992 #[test]
993 fn a_program_that_does_not_compile_has_no_recording() {
994 let json = debug_json("export fn main() -> Int { 1 +", None, None, 0);
995 assert!(says(&json, r#""debug":null"#), "{json}");
996 }
997
998 /// The four existing entry points answer what they always answered.
999 /// `web/check.mjs` and CI depend on it.
1000 #[test]
1001 fn a_plain_run_carries_no_recording() {
1002 let json = run_json("export fn main() -> Int { 1 }", None, None);
1003 assert!(!json.contains("\"debug\""), "{json}");
1004 let json = compile_json("export fn main() -> Int { 1 }");
1005 assert!(!json.contains("\"debug\""), "{json}");
1006 }
1007
1008 /// Every answer is one JSON object and nothing else, whatever happened.
1009 #[test]
1010 fn every_answer_is_a_single_object() {
1011 for source in [
1012 "export fn main() -> Int { 1 }",
1013 "export fn main() -> Int { 1 +",
1014 "",
1015 ] {
1016 for json in [compile_json(source), run_json(source, None, None)] {
1017 assert!(json.starts_with('{') && json.ends_with('}'), "{json}");
1018 assert_eq!(json.matches("\"ok\":").count(), 1, "{json}");
1019 }
1020 }
1021 }
1022}