cove_wasm/highlight.rs
1//! Colouring source the way the compiler reads it, and a disassembly the
2//! way its printer writes it.
3//!
4//! # Why there is no tokenizer in the page
5//!
6//! The obvious way to highlight a `<textarea>` is a regular expression and a
7//! list of keywords in JavaScript. It is also the way that goes wrong
8//! silently: the list is a second, informal specification of the language,
9//! nothing compares it against the first, and the day `opaque` or `..<` is
10//! added the page keeps colouring the old language and no test anywhere has
11//! an opinion. A playground that lied about what Cove is would be worse than
12//! one that showed plain text.
13//!
14//! The module a page already loads contains the whole front end, so the
15//! lexer is *there*. [`paint`] calls it. The keywords are
16//! [`cove_syntax::token::Keyword`]'s, the numbers are the ones
17//! [`cove_syntax::lexer`] accepts including `500ms`, and a token kind the
18//! language grows arrives here as a token kind. Agreement is by construction
19//! rather than by discipline.
20//!
21//! # What comes out
22//!
23//! A *tiling*: a list of pieces that between them cover every UTF-16 code
24//! unit of the source exactly once, in order. The page renders it by walking
25//! the list and slicing the text — no offsets to reconcile, no gaps to
26//! guess, and a check that the pieces tile is a check the whole thing is
27//! sound.
28//!
29//! UTF-16 and not bytes because the consumer is JavaScript, where a string is
30//! indexed in UTF-16 code units. Two of the shipped samples contain an em
31//! dash, so this is not a hypothetical: byte offsets would have misaligned
32//! every colour after the first `—`.
33//!
34//! # Six categories for source, and the two that are not token kinds
35//!
36//! [`Kind`] is deliberately short. A playground wants a reader to see the
37//! shape of a program, and twenty colours is a wall rather than a shape. Six
38//! of its seven are what source is cut into; the seventh, `slot`, belongs to
39//! the disassembly below and is argued there.
40//!
41//! Two of the six do not come from a [`TokenKind`], and both are named here
42//! because they are the parts a reader should check rather than trust:
43//!
44//! - **`type`** is an identifier that begins with an uppercase letter. The
45//! lexer does not know what a type is — it answers
46//! [`TokenKind::Ident`] for `Int` and for `total` alike. Uppercase is not
47//! only a convention in Cove, though: the parser's `parse_pattern` decides
48//! that `Ok(value)` is a variant and `other` is a binding on exactly this
49//! rule, so the page is colouring by something the grammar already reads.
50//! It is still a heuristic about *names*, and a struct someone called
51//! `Total` is coloured as a type because it is one.
52//!
53//! - **`comment`** for a `//` or `/* */` comment, which is not a token at
54//! all: the lexer discards them. They are recovered from the *gaps* between
55//! tokens, which in a source that lexes hold nothing else. The rule and its
56//! one wrong answer are written out where it is applied.
57//!
58//! # Source that does not lex
59//!
60//! Constantly, because the reader is typing. One open quote and the file has
61//! a lexical error, and that is the state a string literal is in for as long
62//! as it takes to write one.
63//!
64//! [`cove_syntax::lexer::lex_recovered`] exists for this: the tokens before
65//! the error are real tokens and are answered, so the colouring is of the
66//! text that is actually in the box rather than a stale picture of the text
67//! that was there two keystrokes ago. `ok` reports whether the source lexed
68//! cleanly, and it is the *page's* text either way — nothing here ever
69//! answers a tiling of something the caller did not send.
70//!
71//! # The other text on the page: a disassembly
72//!
73//! [`disassembly`] colours what [`cove_ir::print`] emits, and it exists for
74//! the reason above rather than in spite of it. The page shows that text
75//! already; a wall of one colour is what it was. The obvious way to fix that
76//! would have been a regular expression in the page, and it would have been
77//! the same mistake: an informal second reader of a format, drifting from the
78//! format with nothing watching.
79//!
80//! What is honest to say is that this *is* a second reader. There is no lexer
81//! for a disassembly to borrow, and making the printer emit spans would mean
82//! rewriting two hundred lines of `format!` in `cove-ir` for a colour on a
83//! playground pane. So the reader is here, in Rust, next to the crate that
84//! prints the text and inside the module the page already takes tilings from,
85//! and the drift is caught by a test rather than by discipline:
86//! `web/check.mjs` colours the **real disassembly of all nine shipped
87//! samples** and fails the build if a single line of any of them is one this
88//! reader does not recognise. That is what `ok` is for here — not "the text
89//! is well formed", which it always is, but "every line of it was a line
90//! shape [`cove_ir::print`] documents".
91//!
92//! It reads the six line shapes that module writes and nothing about any
93//! particular instruction: a header, a frame, a capture, a local, a blank
94//! line, and `pc opcode operands`. Inside an instruction it goes by the
95//! shape of each token — `s3:int` is a slot, `"…"` is a literal, digits are a
96//! number, a name before ` (` is a callee and any other name is a layout.
97//! Adding an instruction to the language therefore needs nothing here;
98//! changing how *operands* are written does, and that is what the nine
99//! samples are asserted for.
100
101use cove_diag::SourceMap;
102use cove_syntax::lexer::lex_recovered;
103use cove_syntax::token::TokenKind;
104
105use crate::PATH;
106
107/// What one piece of a text is coloured as.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum Kind {
110 /// A keyword, and `true` / `false` with them: they are spelled like
111 /// keywords, a reader reads them as keywords, and a category of their own
112 /// would buy a colour nobody needs.
113 Keyword,
114 /// An identifier beginning with an uppercase letter. See the module
115 /// documentation: this is the one category the lexer does not decide.
116 Type,
117 /// A string literal, interpolations and all. `"Hello, {name}!"` is one
118 /// piece and not three — the interpolated expression is not re-lexed,
119 /// because a second colour inside a string is a detail a playground can
120 /// do without.
121 Str,
122 /// An integer, a float, or a duration such as `500ms`. In a
123 /// [`disassembly`] it is also a program counter, a jump target and a
124 /// function's id: an index into the program is the same kind of fact as a
125 /// number written in it, and a reader scanning for "where" wants them one
126 /// colour rather than two.
127 Number,
128 /// A slot and what it is annotated with, `s3:int`, in a [`disassembly`].
129 ///
130 /// The one category source has no use for, and the one the disassembly
131 /// could not do without: a slot number is the thing a reader follows from
132 /// line to line, and it is written in more places than any other token.
133 /// It is one piece and not three because the annotation is what makes the
134 /// number mean something — `s3` on its own says nothing about whether the
135 /// instruction moved a word or a `Point`.
136 Slot,
137 /// A `//`, `/* */` or `///` comment.
138 Comment,
139 /// Everything else: punctuation, operators, ordinary names, whitespace.
140 Plain,
141}
142
143impl Kind {
144 /// The name the page's CSS class is built from.
145 pub fn as_str(self) -> &'static str {
146 match self {
147 Kind::Keyword => "keyword",
148 Kind::Type => "type",
149 Kind::Str => "string",
150 Kind::Number => "number",
151 Kind::Slot => "slot",
152 Kind::Comment => "comment",
153 Kind::Plain => "plain",
154 }
155 }
156}
157
158/// One run of text that is all one colour.
159///
160/// `at` and `len` are in UTF-16 code units, which is what `String.prototype
161/// .slice` counts in.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub struct Piece {
164 pub at: usize,
165 pub len: usize,
166 pub kind: Kind,
167}
168
169/// A tiling of one text, and whether the thing that read it had a complaint.
170#[derive(Debug)]
171pub struct Painting {
172 pub pieces: Vec<Piece>,
173 /// False when the reader had something to say: for [`paint`], that the
174 /// lexer did; for [`disassembly`], that a line was not one of the shapes
175 /// [`cove_ir::print`] writes. The pieces are still a tiling of the whole
176 /// text either way; this says how much to trust their colours.
177 pub ok: bool,
178}
179
180/// Which colour a token gets.
181fn category(kind: &TokenKind) -> Kind {
182 match kind {
183 TokenKind::Keyword(_) | TokenKind::Bool(_) => Kind::Keyword,
184 TokenKind::Int(_) | TokenKind::Float(_) | TokenKind::Duration(_) => Kind::Number,
185 TokenKind::Str(_) => Kind::Str,
186 TokenKind::DocComment(_) => Kind::Comment,
187 TokenKind::Ident(name) => {
188 if name.chars().next().is_some_and(char::is_uppercase) {
189 Kind::Type
190 } else {
191 Kind::Plain
192 }
193 }
194 _ => Kind::Plain,
195 }
196}
197
198/// The tiling being built, and where in the source it has got to.
199struct Tiling<'a> {
200 source: &'a str,
201 pieces: Vec<Piece>,
202 /// How many UTF-16 code units the pieces so far cover, which is the `at`
203 /// of the next one.
204 utf16: usize,
205}
206
207impl Tiling<'_> {
208 /// Adds `source[start..end]` as one piece, merging it into the previous
209 /// piece when they are the same colour.
210 ///
211 /// Merging is not only for the payload's size, though a run of
212 /// punctuation and spaces is most of a program and this halves it. It is
213 /// so that the page builds one DOM node per *visible* run rather than one
214 /// per token, on every keystroke.
215 fn take(&mut self, start: usize, end: usize, kind: Kind) {
216 if start >= end {
217 return;
218 }
219 let len = self.source[start..end].encode_utf16().count();
220 match self.pieces.last_mut() {
221 Some(last) if last.kind == kind => last.len += len,
222 _ => self.pieces.push(Piece {
223 at: self.utf16,
224 len,
225 kind,
226 }),
227 }
228 self.utf16 += len;
229 }
230
231 /// Adds the text between two tokens.
232 ///
233 /// Whitespace is the whole of a gap in almost every one, and whitespace
234 /// has no colour. What is left is what the lexer *skipped*, and in a
235 /// source that lexes there is exactly one thing it skips: a comment. So a
236 /// gap with anything in it is a comment, and that is how comments are
237 /// coloured at all without the lexer producing a token for them.
238 ///
239 /// In a source that does not lex there is one more thing it skips, and it
240 /// is the common one while typing: an unterminated string, from its
241 /// opening quote to end of file. A gap opening with `"` is that, and
242 /// colouring it as a string is what makes the rest of the file stop
243 /// changing colour on every character typed inside one.
244 ///
245 /// Anything else a broken source skipped — a stray `;`, a `@` — is left
246 /// plain, which is the honest answer for text the lexer refused to read.
247 /// So is a gap that opens with a comment and then goes wrong: the whole
248 /// of it is coloured as the comment it began as, and that is a wrong
249 /// colour on text that is already an error.
250 fn gap(&mut self, start: usize, end: usize) {
251 let text = &self.source[start..end];
252 let body = text.trim_start();
253 let opens = start + (text.len() - body.len());
254 let closes = opens + body.trim_end().len();
255
256 let kind = match body.as_bytes().first() {
257 Some(b'/') => Kind::Comment,
258 Some(b'"') => Kind::Str,
259 _ => Kind::Plain,
260 };
261 self.take(start, opens, Kind::Plain);
262 self.take(opens, closes, kind);
263 self.take(closes, end, Kind::Plain);
264 }
265}
266
267/// Lexes `source` and answers a colour for every part of it.
268///
269/// The pieces tile: `pieces[0].at` is zero, each one begins where the last
270/// ended, and together they cover the source. That holds for a source that
271/// does not lex too — see the module documentation for why that case is the
272/// normal one rather than the exception.
273pub fn paint(source: &str) -> Painting {
274 let mut sources = SourceMap::new();
275 let file = sources.add(PATH, source.to_string());
276 let (tokens, diagnostics) = lex_recovered(&sources, file);
277
278 let mut tiling = Tiling {
279 source,
280 pieces: Vec::new(),
281 utf16: 0,
282 };
283 let mut covered = 0usize;
284 for token in &tokens {
285 let start = (token.span.start as usize).min(source.len()).max(covered);
286 let end = (token.span.end as usize).min(source.len()).max(start);
287 tiling.gap(covered, start);
288 tiling.take(start, end, category(&token.kind));
289 covered = end;
290 }
291 tiling.gap(covered, source.len());
292
293 Painting {
294 pieces: tiling.pieces,
295 ok: diagnostics.is_empty(),
296 }
297}
298
299/// Colours a disassembly and answers a colour for every part of it.
300///
301/// The pieces tile, as [`paint`]'s do. `ok` is whether every line was one of
302/// the shapes [`cove_ir::print`] writes; a line that was not is left entirely
303/// plain and turns `ok` false, which is the signal a check reads to find out
304/// that the printer has grown a line this reader does not know. See the
305/// module documentation for why that check is where the agreement lives.
306pub fn disassembly(text: &str) -> Painting {
307 let mut tiling = Tiling {
308 source: text,
309 pieces: Vec::new(),
310 utf16: 0,
311 };
312 let mut ok = true;
313 let mut at = 0;
314 while at < text.len() {
315 let end = text[at..].find('\n').map_or(text.len(), |n| at + n + 1);
316 let body = if text[..end].ends_with('\n') {
317 end - 1
318 } else {
319 end
320 };
321 ok &= line(&mut tiling, text, at, body);
322 // The line terminator belongs to no line's colouring.
323 tiling.take(body, end, Kind::Plain);
324 at = end;
325 }
326 Painting {
327 pieces: tiling.pieces,
328 ok,
329 }
330}
331
332/// The words [`cove_ir::print`] writes as words rather than as an operand.
333///
334/// Four, and every one of them is in that module's source as a string
335/// literal: `else` separates a `switch`'s table from its default, `async`
336/// ends the header of an async function, and `true` and `false` are how a
337/// `bool` immediate is spelled. Without this list `else` would be read as a
338/// layout name, because in that position everything else is one.
339const WORDS: [&str; 4] = ["else", "async", "true", "false"];
340
341/// Colours one line of a disassembly, without its terminator.
342///
343/// Answers whether it was recognised. The four shapes with an indent are told
344/// apart by their first character — a digit begins an instruction, a letter
345/// begins one of the three headings — and a line with no indent is the
346/// function header itself.
347fn line(tiling: &mut Tiling, text: &str, start: usize, end: usize) -> bool {
348 let held = &text[start..end];
349 if held.trim().is_empty() {
350 tiling.take(start, end, Kind::Plain);
351 return true;
352 }
353 if !held.starts_with(' ') {
354 return header(tiling, text, start, end);
355 }
356 let body = held.trim_start();
357 let at = end - body.len();
358 for word in ["frame", "capture", "local"] {
359 if let Some(rest) = body.strip_prefix(word) {
360 if rest.starts_with(' ') {
361 return heading(tiling, text, start, at, end, word);
362 }
363 }
364 }
365 if body.starts_with(|c: char| c.is_ascii_digit()) {
366 return instruction(tiling, text, start, at, end);
367 }
368 tiling.take(start, end, Kind::Plain);
369 false
370}
371
372/// `fn @playground.main(Int) -> Int`, optionally ` async`.
373///
374/// The name is left plain as a whole, generic arguments and all: it is one
375/// name however many angle brackets are in it, and cutting it up would say
376/// that `playground.headline<playground.Booking>` is two things.
377///
378/// It was `fn @playground.main(...)` until issue #275 made a definition name
379/// itself instead of stating its position in the function table. The `fn `
380/// and the `@` are what this reads; everything after the `@` is the name.
381fn header(tiling: &mut Tiling, text: &str, start: usize, end: usize) -> bool {
382 let held = &text[start..end];
383 let id = held.starts_with("fn @").then_some(2);
384 let (Some(id), Some(close)) = (id, held.rfind(") -> ")) else {
385 tiling.take(start, end, Kind::Plain);
386 return false;
387 };
388 let Some(open) = held[..close].find('(') else {
389 tiling.take(start, end, Kind::Plain);
390 return false;
391 };
392 // `fn` is the heading word here, the way `frame`, `capture` and `local`
393 // are on the lines below it. What follows the `@` is one name and is
394 // left plain, which is what tells it from the layouts around it.
395 tiling.take(start, start + id, Kind::Keyword);
396 tiling.take(start + id, start + open + 1, Kind::Plain);
397 operands(tiling, text, start + open + 1, start + close);
398 tiling.take(start + close, start + close + 5, Kind::Plain);
399 match held.strip_suffix(" async") {
400 Some(front) => {
401 operands(tiling, text, start + close + 5, start + front.len());
402 tiling.take(start + front.len(), end - 5, Kind::Plain);
403 tiling.take(end - 5, end, Kind::Keyword);
404 }
405 None => operands(tiling, text, start + close + 5, end),
406 }
407 true
408}
409
410/// ` frame 4: s0!:int …`, ` capture text -> s0:String`, or
411/// ` local n -> s1:Int [1, 4)`.
412///
413/// The two with an arrow name a *source* name, which is neither a layout nor
414/// a callee and is left plain; everything after the arrow is operands.
415fn heading(
416 tiling: &mut Tiling,
417 text: &str,
418 start: usize,
419 at: usize,
420 end: usize,
421 word: &str,
422) -> bool {
423 tiling.take(start, at, Kind::Plain);
424 tiling.take(at, at + word.len(), Kind::Keyword);
425 let rest = at + word.len();
426 if word == "frame" {
427 operands(tiling, text, rest, end);
428 return true;
429 }
430 let Some(arrow) = text[rest..end].find(" -> ").map(|n| rest + n + 4) else {
431 tiling.take(rest, end, Kind::Plain);
432 return false;
433 };
434 tiling.take(rest, arrow, Kind::Plain);
435 operands(tiling, text, arrow, end);
436 true
437}
438
439/// ` 0 int s1:int 21`: a program counter, then an opcode, then operands.
440fn instruction(tiling: &mut Tiling, text: &str, start: usize, at: usize, end: usize) -> bool {
441 let bytes = text.as_bytes();
442 let mut pc = at;
443 while pc < end && bytes[pc].is_ascii_digit() {
444 pc += 1;
445 }
446 let mut gap = pc;
447 while gap < end && bytes[gap] == b' ' {
448 gap += 1;
449 }
450 let mut op = gap;
451 while op < end && (bytes[op].is_ascii_lowercase() || matches!(bytes[op], b'.' | b'-')) {
452 op += 1;
453 }
454 if gap == pc || op == gap {
455 tiling.take(start, end, Kind::Plain);
456 return false;
457 }
458 tiling.take(start, at, Kind::Plain);
459 tiling.take(at, pc, Kind::Number);
460 tiling.take(pc, gap, Kind::Plain);
461 tiling.take(gap, op, Kind::Keyword);
462 operands(tiling, text, op, end);
463 true
464}
465
466/// Colours `text[from..to]` by the shape of each token in it.
467///
468/// This is the part that knows nothing about which instruction it is in, and
469/// deliberately: an instruction added to `cove-ir` writes its operands in the
470/// same six spellings every other one does, so it arrives here already
471/// coloured. What a new *spelling* would do is arrive as a layout name, which
472/// is the fallback, and that is the drift the nine samples are asserted
473/// against.
474fn operands(tiling: &mut Tiling, text: &str, from: usize, to: usize) {
475 let bytes = text.as_bytes();
476 let mut at = from;
477 // Where the last name ended, so that the `<` of `Array<array>` — a shape,
478 // written against its layout — is told from the `<` of `<addr>`, which is
479 // a layout name that is spelled in brackets.
480 let mut name = usize::MAX;
481 while at < to {
482 let c = bytes[at];
483 if c == b' ' {
484 let end = (at..to).find(|i| bytes[*i] != b' ').unwrap_or(to);
485 tiling.take(at, end, Kind::Plain);
486 at = end;
487 } else if c == b'"' {
488 let end = literal(text, at, to);
489 tiling.take(at, end, Kind::Str);
490 at = end;
491 } else if let Some(end) = slot(bytes, at, to) {
492 tiling.take(at, end, Kind::Slot);
493 at = end;
494 } else if c == b'x' && at + 1 < to && counted(bytes, at + 1, to) {
495 // The `x` of `alloc s10:ref Array<array> x3`, which is a mark on
496 // the count rather than a name of its own.
497 tiling.take(at, at + 1, Kind::Plain);
498 at += 1;
499 } else if c.is_ascii_digit()
500 || (matches!(c, b'+' | b'-') && at + 1 < to && bytes[at + 1].is_ascii_digit())
501 {
502 let mut end = at + 1;
503 while end < to
504 && (bytes[end].is_ascii_digit()
505 || (bytes[end] == b'.' && end + 1 < to && bytes[end + 1].is_ascii_digit()))
506 {
507 end += 1;
508 }
509 tiling.take(at, end, Kind::Number);
510 at = end;
511 } else if c.is_ascii_alphabetic() || c == b'_' {
512 let mut end = at;
513 while end < to
514 && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'.' | b'#'))
515 {
516 end += 1;
517 }
518 let word = &text[at..end];
519 tiling.take(
520 at,
521 end,
522 if WORDS.contains(&word) {
523 Kind::Keyword
524 } else if matches!(word, "inf" | "NaN") {
525 Kind::Number
526 } else if text[end..to].starts_with(" (") {
527 // A callee: the printer writes every call's argument list
528 // as ` (…)`, and nothing else is followed by one.
529 Kind::Plain
530 } else {
531 Kind::Type
532 },
533 );
534 name = end;
535 at = end;
536 } else if c == b'<' {
537 let end = text[at..to].find('>').map_or(to, |n| at + n + 1);
538 let kind = if name == at { Kind::Plain } else { Kind::Type };
539 tiling.take(at, end, kind);
540 at = end;
541 } else {
542 // A UTF-8 character and not a byte: a string literal is the only
543 // place a non-ASCII one can be, but slicing one in half panics.
544 let step = text[at..].chars().next().map_or(1, char::len_utf8);
545 tiling.take(at, (at + step).min(to), Kind::Plain);
546 at += step;
547 }
548 }
549}
550
551/// The end of the string literal starting at `at`, or `to` if it is unclosed.
552fn literal(text: &str, at: usize, to: usize) -> usize {
553 let mut escaped = false;
554 for (off, c) in text[at + 1..to].char_indices() {
555 if escaped {
556 escaped = false;
557 } else if c == '\\' {
558 escaped = true;
559 } else if c == '"' {
560 return at + 1 + off + 1;
561 }
562 }
563 to
564}
565
566/// The end of the slot starting at `at`, if one starts there.
567///
568/// `s3:int`, `s0!:ref` in a frame line where `!` marks a parameter, `s10:?`
569/// where the frame is too short to say what the word holds, `s10:<addr>`, and
570/// `s3:playground.Point` in an argument list where the annotation is the
571/// layout rather than the `Repr`.
572///
573/// `s5..s7:Result` is one of these and not three tokens. A value location
574/// wider than one word names its whole run, and the whole run is one operand
575/// — colouring the `..` as punctuation between two slots would say the
576/// instruction touched two locations rather than one.
577fn slot(bytes: &[u8], at: usize, to: usize) -> Option<usize> {
578 if bytes[at] != b's' {
579 return None;
580 }
581 let mut end = at + 1;
582 while end < to && bytes[end].is_ascii_digit() {
583 end += 1;
584 }
585 if end == at + 1 {
586 return None;
587 }
588 if end + 2 < to && &bytes[end..end + 3] == b"..s" {
589 let last = end;
590 end += 3;
591 while end < to && bytes[end].is_ascii_digit() {
592 end += 1;
593 }
594 if end == last + 3 {
595 return None;
596 }
597 }
598 if end < to && bytes[end] == b'!' {
599 end += 1;
600 }
601 if end >= to || bytes[end] != b':' {
602 return None;
603 }
604 end += 1;
605 if end < to && bytes[end] == b'<' {
606 while end < to && bytes[end] != b'>' {
607 end += 1;
608 }
609 return Some((end + 1).min(to));
610 }
611 if end < to && bytes[end] == b'?' {
612 return Some(end + 1);
613 }
614 let annotation = end;
615 while end < to
616 && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'.' | b'#'))
617 {
618 end += 1;
619 }
620 (end > annotation).then_some(end)
621}
622
623/// Whether `at` begins the count of an `alloc`, which is a number or a slot.
624fn counted(bytes: &[u8], at: usize, to: usize) -> bool {
625 bytes[at].is_ascii_digit() || slot(bytes, at, to).is_some()
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 /// The tiling property, checked against the source it is of: every piece
633 /// begins where the last ended, and the last ends at the end.
634 fn tiles(source: &str, painting: &Painting) {
635 let mut at = 0;
636 for piece in &painting.pieces {
637 assert_eq!(piece.at, at, "a piece begins where the last ended");
638 assert!(piece.len > 0, "no empty piece");
639 at += piece.len;
640 }
641 assert_eq!(at, source.encode_utf16().count(), "the pieces cover it all");
642 }
643
644 /// `[(text, kind)]`, which is what a colouring actually looks like.
645 fn coloured(source: &str) -> Vec<(String, Kind)> {
646 let painting = paint(source);
647 tiles(source, &painting);
648 let units: Vec<u16> = source.encode_utf16().collect();
649 painting
650 .pieces
651 .iter()
652 .map(|piece| {
653 (
654 String::from_utf16(&units[piece.at..piece.at + piece.len])
655 .expect("a piece is whole code points"),
656 piece.kind,
657 )
658 })
659 .collect()
660 }
661
662 #[test]
663 fn a_declaration_is_coloured_by_what_the_lexer_called_each_token() {
664 assert_eq!(
665 coloured("export fn main() -> Int { 42 }"),
666 vec![
667 ("export".into(), Kind::Keyword),
668 (" ".into(), Kind::Plain),
669 ("fn".into(), Kind::Keyword),
670 (" main() -> ".into(), Kind::Plain),
671 ("Int".into(), Kind::Type),
672 (" { ".into(), Kind::Plain),
673 ("42".into(), Kind::Number),
674 (" }".into(), Kind::Plain),
675 ]
676 );
677 }
678
679 #[test]
680 fn a_comment_is_the_gap_the_lexer_left() {
681 assert_eq!(
682 coloured("let n = 1 // why\nlet m = 2"),
683 vec![
684 ("let".into(), Kind::Keyword),
685 (" n = ".into(), Kind::Plain),
686 ("1".into(), Kind::Number),
687 (" ".into(), Kind::Plain),
688 ("// why".into(), Kind::Comment),
689 ("\n".into(), Kind::Plain),
690 ("let".into(), Kind::Keyword),
691 (" m = ".into(), Kind::Plain),
692 ("2".into(), Kind::Number),
693 ]
694 );
695 }
696
697 #[test]
698 fn a_block_comment_and_a_doc_comment_are_both_comments() {
699 let held = coloured("/* out */\n/// in\nfn f() {}");
700 assert_eq!(held[0].1, Kind::Comment);
701 assert_eq!(held[0].0, "/* out */");
702 assert!(
703 held.iter()
704 .any(|(text, kind)| text.contains("/// in") && *kind == Kind::Comment),
705 "{held:?}"
706 );
707 }
708
709 #[test]
710 fn a_string_is_one_piece_interpolation_and_all() {
711 assert_eq!(
712 coloured("\"Hello, {name}!\""),
713 vec![("\"Hello, {name}!\"".into(), Kind::Str)]
714 );
715 }
716
717 #[test]
718 fn a_duration_and_a_float_are_numbers() {
719 assert_eq!(
720 coloured("500ms 1.5 0xff"),
721 vec![
722 ("500ms".into(), Kind::Number),
723 (" ".into(), Kind::Plain),
724 ("1.5".into(), Kind::Number),
725 (" ".into(), Kind::Plain),
726 ("0xff".into(), Kind::Number),
727 ]
728 );
729 }
730
731 #[test]
732 fn true_and_false_are_coloured_as_the_keywords_they_are_spelled_as() {
733 assert_eq!(coloured("true"), vec![("true".into(), Kind::Keyword)]);
734 }
735
736 /// The state the editor is in for as long as it takes to type a string.
737 /// Everything before the quote keeps its colours, and the open literal is
738 /// a string to end of file rather than a hole.
739 #[test]
740 fn an_open_quote_still_colours_the_whole_file() {
741 let source = "let n = 1\nlet greeting = \"open";
742 let painting = paint(source);
743 assert!(!painting.ok, "it does not lex, and says so");
744 tiles(source, &painting);
745 assert_eq!(
746 coloured(source).last(),
747 Some(&("\"open".to_string(), Kind::Str))
748 );
749 }
750
751 #[test]
752 fn a_stray_character_is_left_plain_rather_than_guessed_at() {
753 let painting = paint("let n = 1;");
754 assert!(!painting.ok);
755 assert_eq!(
756 coloured("let n = 1;").last(),
757 Some(&(";".to_string(), Kind::Plain))
758 );
759 }
760
761 /// Offsets are UTF-16 because JavaScript's are. An em dash is one code
762 /// point, two UTF-8 bytes more than an ASCII character, and one UTF-16
763 /// code unit; a tiling counted in bytes would put every colour after it
764 /// in the wrong place.
765 #[test]
766 fn offsets_are_counted_the_way_a_javascript_string_is() {
767 let source = "// an — dash\nlet n = 1";
768 let painting = paint(source);
769 tiles(source, &painting);
770 assert_eq!(
771 painting.pieces[0].len,
772 "// an — dash".encode_utf16().count()
773 );
774 assert_eq!(painting.pieces[0].len, 12);
775 }
776
777 #[test]
778 fn an_empty_source_is_an_empty_tiling() {
779 let painting = paint("");
780 assert!(painting.ok);
781 assert!(painting.pieces.is_empty());
782 }
783
784 #[test]
785 fn no_two_neighbours_share_a_colour() {
786 let source = "export fn main() -> Int {\n let n = 1 // one\n n\n}\n";
787 let painting = paint(source);
788 tiles(source, &painting);
789 for pair in painting.pieces.windows(2) {
790 assert_ne!(pair[0].kind, pair[1].kind, "{:?}", painting.pieces);
791 }
792 }
793
794 // ---- the disassembly ------------------------------------------------
795 //
796 // `web/check.mjs` is where this is held against the real output of
797 // `cove_ir::print`, on all nine shipped samples, because only the real
798 // thing can catch the printer growing a line this file does not know.
799 // What is here is the line shapes it is meant to read, written out so
800 // that a change to one of them fails at `cargo t` rather than at the end
801 // of a wasm build.
802
803 /// `[(text, kind)]` for a disassembly, with the plain runs dropped: it is
804 /// the coloured pieces that are the claim, and the punctuation between
805 /// them is noise in an assertion.
806 fn lit(text: &str) -> Vec<(String, Kind)> {
807 let painting = disassembly(text);
808 assert!(painting.ok, "every line is one this reader knows: {text}");
809 tiles(text, &painting);
810 let units: Vec<u16> = text.encode_utf16().collect();
811 painting
812 .pieces
813 .iter()
814 .filter(|piece| piece.kind != Kind::Plain)
815 .map(|piece| {
816 (
817 String::from_utf16(&units[piece.at..piece.at + piece.len])
818 .expect("a piece is whole code points"),
819 piece.kind,
820 )
821 })
822 .collect()
823 }
824
825 #[test]
826 fn a_header_names_its_layouts_and_leaves_the_function_plain() {
827 assert_eq!(
828 lit("fn @playground.Point.shift(<addr> Int Int) -> Unit\n"),
829 vec![
830 ("fn".into(), Kind::Keyword),
831 ("<addr>".into(), Kind::Type),
832 ("Int".into(), Kind::Type),
833 ("Int".into(), Kind::Type),
834 ("Unit".into(), Kind::Type),
835 ]
836 );
837 }
838
839 /// A generic instantiation is written into the name, and it is still one
840 /// name: the brackets do not turn it into a layout half-way through.
841 #[test]
842 fn a_generic_header_is_one_name() {
843 assert_eq!(
844 lit("fn @playground.headline<playground.Booking>(playground.Booking) -> String\n"),
845 vec![
846 ("fn".into(), Kind::Keyword),
847 ("playground.Booking".into(), Kind::Type),
848 ("String".into(), Kind::Type),
849 ]
850 );
851 }
852
853 #[test]
854 fn an_async_header_says_so_in_the_word_the_printer_wrote() {
855 let held = lit("fn @playground.main() -> Int async\n");
856 assert_eq!(held.last(), Some(&("async".to_string(), Kind::Keyword)));
857 }
858
859 #[test]
860 fn a_frame_is_its_slots_and_a_parameter_keeps_its_mark() {
861 assert_eq!(
862 lit(" frame 3: s0!:int s1:ref s2:?\n"),
863 vec![
864 ("frame".into(), Kind::Keyword),
865 ("3".into(), Kind::Number),
866 ("s0!:int".into(), Kind::Slot),
867 ("s1:ref".into(), Kind::Slot),
868 ("s2:?".into(), Kind::Slot),
869 ]
870 );
871 }
872
873 /// The name a `local` binds is the source's own, and is neither a layout
874 /// nor a callee. The pc range it holds the slot over is a pair of numbers
875 /// like any other.
876 #[test]
877 fn a_local_names_a_slot_over_a_range_of_program_counters() {
878 assert_eq!(
879 lit(" local count -> s3:Int [4, 11)\n"),
880 vec![
881 ("local".into(), Kind::Keyword),
882 ("s3:Int".into(), Kind::Slot),
883 ("4".into(), Kind::Number),
884 ("11".into(), Kind::Number),
885 ]
886 );
887 }
888
889 /// A location wider than one word names its whole run, and the run is
890 /// one operand: `..` between two slot numbers is part of the token and
891 /// not punctuation between two of them.
892 #[test]
893 fn a_local_over_several_words_is_one_operand() {
894 assert_eq!(
895 lit(" local wide -> s5..s7:playground.Shape [5, 5)\n"),
896 vec![
897 ("local".into(), Kind::Keyword),
898 ("s5..s7:playground.Shape".into(), Kind::Slot),
899 ("5".into(), Kind::Number),
900 ("5".into(), Kind::Number),
901 ]
902 );
903 }
904
905 #[test]
906 fn a_capture_is_a_name_and_a_slot() {
907 assert_eq!(
908 lit(" capture text -> s0:String\n"),
909 vec![
910 ("capture".into(), Kind::Keyword),
911 ("s0:String".into(), Kind::Slot),
912 ]
913 );
914 }
915
916 #[test]
917 fn an_instruction_is_a_program_counter_an_opcode_and_operands() {
918 assert_eq!(
919 lit(" 2 mul.int s3:int s1:int s2:int\n"),
920 vec![
921 ("2".into(), Kind::Number),
922 ("mul.int".into(), Kind::Keyword),
923 ("s3:int".into(), Kind::Slot),
924 ("s1:int".into(), Kind::Slot),
925 ("s2:int".into(), Kind::Slot),
926 ]
927 );
928 }
929
930 /// A callee is a name and not a layout, and the two are told apart by
931 /// the argument list the printer writes after one of them and never
932 /// after the other.
933 ///
934 /// This line used to end in a bare `String`, the layout of what the call
935 /// answered, and it was there that the two were most easily confused.
936 /// Issue #299 moved that layout onto the destination it describes, so
937 /// what is left for a reader to get wrong is the callee alone — it is
938 /// still the one name in an instruction that is not a type.
939 #[test]
940 fn a_callee_is_a_name_and_everything_else_that_is_named_is_a_layout() {
941 assert_eq!(
942 lit(" 1 call s4:String playground.greeting (s3:String)\n"),
943 vec![
944 ("1".into(), Kind::Number),
945 ("call".into(), Kind::Keyword),
946 ("s4:String".into(), Kind::Slot),
947 ("s3:String".into(), Kind::Slot),
948 ]
949 );
950 // Where a bare layout is still written it is a type: `spawn` names
951 // the answer of the body it starts, which is not a location in this
952 // frame and so is not written on one.
953 assert_eq!(
954 lit(" 7 spawn s3:task s2:scope s4:ref Int\n").last(),
955 Some(&("Int".to_string(), Kind::Type))
956 );
957 }
958
959 /// The shape issue #299 is about: a destination three words wide, and
960 /// both ends of the run in the operand.
961 #[test]
962 fn a_multi_word_location_is_one_operand_and_not_two_slots() {
963 assert_eq!(
964 lit(" 14 call-host s4..s6:Result console.println (s14:String)\n"),
965 vec![
966 ("14".into(), Kind::Number),
967 ("call-host".into(), Kind::Keyword),
968 ("s4..s6:Result".into(), Kind::Slot),
969 ("s14:String".into(), Kind::Slot),
970 ]
971 );
972 assert_eq!(
973 lit(" 8 copy s0..s2:playground.Shape s5..s7:playground.Shape\n"),
974 vec![
975 ("8".into(), Kind::Number),
976 ("copy".into(), Kind::Keyword),
977 ("s0..s2:playground.Shape".into(), Kind::Slot),
978 ("s5..s7:playground.Shape".into(), Kind::Slot),
979 ]
980 );
981 }
982
983 #[test]
984 fn a_string_literal_is_one_piece_spaces_escapes_and_all() {
985 assert_eq!(
986 lit(" 0 str s2:ref \"Hello, \\\"you\\\"!\"\n").last(),
987 Some(&("\"Hello, \\\"you\\\"!\"".to_string(), Kind::Str))
988 );
989 }
990
991 /// `alloc` writes the shape against its layout and then a count that is
992 /// either a number or a slot. The `x` is a mark on the count and not a
993 /// name, which is the one place a bare letter appears in an operand.
994 #[test]
995 fn an_alloc_carries_a_shape_and_a_count() {
996 assert_eq!(
997 lit(" 12 alloc s14:ref Array<array> x3\n"),
998 vec![
999 ("12".into(), Kind::Number),
1000 ("alloc".into(), Kind::Keyword),
1001 ("s14:ref".into(), Kind::Slot),
1002 ("Array".into(), Kind::Type),
1003 ("3".into(), Kind::Number),
1004 ]
1005 );
1006 assert_eq!(
1007 lit(" 170 alloc s10:ref Array<array> xs4:int\n").last(),
1008 Some(&("s4:int".to_string(), Kind::Slot))
1009 );
1010 }
1011
1012 /// A layout the table names in brackets, which is what a bare `ref` or
1013 /// `addr` is called. It is a layout name and not a shape, and the two are
1014 /// told apart by whether a name is written against the bracket.
1015 ///
1016 /// On a location the brackets are inside the operand — `s13:<ref>` is one
1017 /// token, the way `s13:String` is. A bare one is what a header writes,
1018 /// and `a_header_names_its_layouts_and_leaves_the_function_plain` has it.
1019 #[test]
1020 fn a_bracketed_layout_is_a_layout_and_a_bracketed_shape_is_not() {
1021 assert_eq!(
1022 lit(" 18 clear s13:<ref>\n").last(),
1023 Some(&("s13:<ref>".to_string(), Kind::Slot))
1024 );
1025 // A layout name with a space in it — `closure playground.reading#0`
1026 // is one name — is coloured as the layout it is, both halves of it,
1027 // and the shape written against it is not.
1028 assert_eq!(
1029 lit(" 1 alloc s12:ref closure playground.reading#0<closure>\n"),
1030 vec![
1031 ("1".into(), Kind::Number),
1032 ("alloc".into(), Kind::Keyword),
1033 ("s12:ref".into(), Kind::Slot),
1034 ("closure".into(), Kind::Type),
1035 ("playground.reading#0".into(), Kind::Type),
1036 ]
1037 );
1038 }
1039
1040 #[test]
1041 fn a_switch_keeps_its_table_apart_from_its_default() {
1042 assert_eq!(
1043 lit(" 0 switch s0:int [1 7 12] else 15\n"),
1044 vec![
1045 ("0".into(), Kind::Number),
1046 ("switch".into(), Kind::Keyword),
1047 ("s0:int".into(), Kind::Slot),
1048 ("1".into(), Kind::Number),
1049 ("7".into(), Kind::Number),
1050 ("12".into(), Kind::Number),
1051 ("else".into(), Kind::Keyword),
1052 ("15".into(), Kind::Number),
1053 ]
1054 );
1055 }
1056
1057 #[test]
1058 fn an_immediate_is_a_number_whatever_it_is_spelled_like() {
1059 assert_eq!(
1060 lit(" 3 int s1:int -21\n").last(),
1061 Some(&("-21".to_string(), Kind::Number))
1062 );
1063 assert_eq!(
1064 lit(" 4 float s2:float 1.5\n").last(),
1065 Some(&("1.5".to_string(), Kind::Number))
1066 );
1067 assert_eq!(
1068 lit(" 5 bool s3:bool true\n").last(),
1069 Some(&("true".to_string(), Kind::Keyword))
1070 );
1071 assert_eq!(
1072 lit(" 6 store-field s1:ref +2 s0:Int\n")
1073 .iter()
1074 .map(|(text, _)| text.as_str())
1075 .collect::<Vec<_>>(),
1076 vec!["6", "store-field", "s1:ref", "+2", "s0:Int"]
1077 );
1078 }
1079
1080 /// The signal a check reads. A line the printer never wrote is coloured
1081 /// as nothing rather than guessed at, and the whole answer says so.
1082 #[test]
1083 fn a_line_this_reader_does_not_know_is_left_plain_and_reported() {
1084 let text = "fn @playground.main() -> Int\n something new\n 0 unit s0:unit\n";
1085 let painting = disassembly(text);
1086 assert!(!painting.ok);
1087 tiles(text, &painting);
1088 assert!(
1089 painting
1090 .pieces
1091 .iter()
1092 .any(|piece| piece.kind == Kind::Keyword),
1093 "the lines it did know are still coloured"
1094 );
1095 }
1096
1097 #[test]
1098 fn an_empty_disassembly_is_an_empty_tiling() {
1099 let painting = disassembly("");
1100 assert!(painting.ok);
1101 assert!(painting.pieces.is_empty());
1102 }
1103
1104 /// The whole of a small program, which is what a reader actually sees:
1105 /// the pieces tile, the blank line between two functions is a line like
1106 /// any other, and nothing in it went unrecognised.
1107 #[test]
1108 fn a_whole_disassembly_tiles_and_is_understood() {
1109 let text = "fn @playground.twice(Int) -> Int\n\
1110 \x20 frame 3: s0!:int s1:int s2:int\n\
1111 \x20 local n -> s0:Int [0, 3)\n\
1112 \x20 0 add.int s2:int s0:int s0:int\n\
1113 \x20 1 copy s1:Int s2:Int\n\
1114 \x20 2 return s1:Int\n\
1115 \n\
1116 fn @playground.main() -> Int\n\
1117 \x20 frame 2: s0:int s1:int\n\
1118 \x20 0 int s1:int 21\n\
1119 \x20 1 call s0:Int playground.twice (s1:Int)\n\
1120 \x20 2 return s0:Int\n";
1121 let painting = disassembly(text);
1122 assert!(painting.ok, "{:?}", painting.pieces);
1123 tiles(text, &painting);
1124 for pair in painting.pieces.windows(2) {
1125 assert_ne!(pair[0].kind, pair[1].kind, "{:?}", painting.pieces);
1126 }
1127 }
1128}