Skip to main content

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}