turbo-editors/turbo-jspublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-js.git
git clone ssh://git@rickub.com/turbo-editors/turbo-js.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

scan_test.go · 666 lines · 21.9 KBGo Blame HistoryRaw
📦 Turbo JS 91999d1 k33g 12h ago1package jslang
2
3import (
4 "strings"
5 "testing"
6
7 "rickub.com/turbo-editors/turbo-core/syntax"
8)
9
10// classAt returns the class covering a rune column on a line, and whether any
11// span covers it at all. It is how nearly every test below asks its question.
12func classAt(spans [][]syntax.Span, line, col int) (syntax.Class, bool) {
13 if line < 0 || line >= len(spans) {
14 return 0, false
15 }
16 for _, s := range spans[line] {
17 if col >= s.Start && col < s.End {
18 return s.Class, true
19 }
20 }
21 return 0, false
22}
23
24// classOfFirst returns the class of the first occurrence of word in src, as
25// coloured by a highlighter.
26func classOfFirst(t *testing.T, highlight func(string) [][]syntax.Span, src, word string) syntax.Class {
27 t.Helper()
28
29 index := strings.Index(src, word)
30 if index < 0 {
31 t.Fatalf("%q does not appear in the source", word)
32 }
33 line := strings.Count(src[:index], "\n")
34 col := len([]rune(src[strings.LastIndex(src[:index], "\n")+1 : index]))
35
36 class, ok := classAt(highlight(src), line, col)
37 if !ok {
38 t.Fatalf("no span covers %q at line %d column %d", word, line, col)
39 }
40 return class
41}
42
43// jsClassOf is classOfFirst for JavaScript.
44func jsClassOf(t *testing.T, src, word string) syntax.Class {
45 t.Helper()
46 return classOfFirst(t, Highlight, src, word)
47}
48
49// wholeWordIs fails unless every column of word, at its first occurrence on
50// line 0 of src, carries the class.
51func wholeWordIs(t *testing.T, highlight func(string) [][]syntax.Span, src, word string, want syntax.Class) {
52 t.Helper()
53
54 spans := highlight(src)
55 start := len([]rune(src[:strings.Index(src, word)]))
56 for col := start; col < start+len([]rune(word)); col++ {
57 class, ok := classAt(spans, 0, col)
58 if !ok || class != want {
59 t.Fatalf("column %d of %q is %v (covered: %v), want the whole of it to be %v", col-start, word, class, ok, want)
60 }
61 }
62}
63
64func TestHighlightReturnsOneEntryPerLine(t *testing.T) {
65 // The editor indexes the result by line number without checking, so a short
66 // result is an index out of range in the middle of a redraw.
67 tests := []struct {
68 name string
69 src string
70 want int
71 }{
72 {"empty", "", 1},
73 {"one line without a terminator", "let x = 1;", 1},
74 {"one line with a terminator", "let x = 1;\n", 2},
75 {"three lines", "a\nb\nc", 3},
76 {"a template across lines", "const t = `a\nb\nc`;", 3},
77 }
78
79 for _, tc := range tests {
80 t.Run(tc.name, func(t *testing.T) {
81 if got := len(Highlight(tc.src)); got != tc.want {
82 t.Errorf("Highlight(%q) returned %d lines, want %d", tc.src, got, tc.want)
83 }
84 })
85 }
86}
87
88func TestEachTokenClass(t *testing.T) {
89 const src = `import { readFile } from "node:fs/promises";
90
91// a comment
92/** a doc comment */
93class Greeter {
94 #name;
95 constructor(name) {
96 this.#name = name;
97 }
98}
99
100async function main() {
101 const count = 42;
102 const ratio = 1.5;
103 const text = 'hello';
104 const template = ` + "`hi ${text}`" + `;
105 const re = /wor+ld/gi;
106 console.log(text, count, ratio, template, re, undefined, null);
107 return await readFile(process.argv[2]);
108}
109`
110
111 tests := []struct {
112 word string
113 want syntax.Class
114 }{
115 {"import", syntax.ClassKeyword},
116 {"from", syntax.ClassKeyword},
117 {"class", syntax.ClassKeyword},
118 {"async", syntax.ClassKeyword},
119 {"function", syntax.ClassKeyword},
120 {"const count", syntax.ClassKeyword},
121 {"return", syntax.ClassKeyword},
122 {"await", syntax.ClassKeyword},
123 {"// a comment", syntax.ClassComment},
124 {"/** a doc comment */", syntax.ClassComment},
125 {"Greeter", syntax.ClassType},
126 {"#name", syntax.ClassIdentifier},
127 {"constructor", syntax.ClassFunction},
128 {"main", syntax.ClassFunction},
129 {"this", syntax.ClassConstant},
130 {"undefined", syntax.ClassConstant},
131 {"null", syntax.ClassConstant},
132 {`"node:fs/promises"`, syntax.ClassString},
133 {"'hello'", syntax.ClassString},
134 {"`hi ${text}`", syntax.ClassString},
135 {"/wor+ld/gi", syntax.ClassChar},
136 {"42", syntax.ClassNumber},
137 {"1.5", syntax.ClassNumber},
138 {"console", syntax.ClassBuiltin},
139 {"process", syntax.ClassBuiltin},
140 {"log", syntax.ClassFunction},
141 {"readFile", syntax.ClassIdentifier},
142 {"=", syntax.ClassOperator},
143 {";", syntax.ClassPunctuation},
144 }
145
146 for _, test := range tests {
147 t.Run(test.word, func(t *testing.T) {
148 if got := jsClassOf(t, src, test.word); got != test.want {
149 t.Errorf("%q is %v, want %v", test.word, got, test.want)
150 }
151 })
152 }
153}
154
155// --- words ------------------------------------------------------------------
156
157func TestTheNameAfterFunctionIsAFunction(t *testing.T) {
158 // By position, whatever the spelling: a generator's * sits between the
159 // keyword and the name, and async in front changes nothing.
160 tests := []struct{ src, name string }{
161 {"function parse(input) {}", "parse"},
162 {"function* generate() {}", "generate"},
163 {"async function fetchAll() {}", "fetchAll"},
164 {"const f = function named() {};", "named"},
165 }
166
167 for _, test := range tests {
168 t.Run(test.src, func(t *testing.T) {
169 if got := jsClassOf(t, test.src, test.name); got != syntax.ClassFunction {
170 t.Errorf("%s is %v, want function", test.name, got)
171 }
172 })
173 }
174}
175
176func TestTheNameAfterClassIsAType(t *testing.T) {
177 // A class named in lower case is still a class, by position rather than
178 // by spelling.
179 if got := jsClassOf(t, "class widget extends Base {}", "widget"); got != syntax.ClassType {
180 t.Errorf("widget is %v, want type", got)
181 }
182 if got := jsClassOf(t, "class widget extends Base {}", "Base"); got != syntax.ClassType {
183 t.Errorf("Base is %v, want type", got)
184 }
185}
186
187func TestACallIsAFunction(t *testing.T) {
188 if got := jsClassOf(t, "const n = compute(3);", "compute"); got != syntax.ClassFunction {
189 t.Errorf("compute is %v, want function", got)
190 }
191}
192
193func TestAnUpperCaseNameIsAType(t *testing.T) {
194 // Every JavaScript style guide capitalises a class and nothing else, so
195 // a leading capital says class more reliably than any look at the
196 // neighbouring tokens would — including in front of the parenthesis
197 // that would otherwise make it a call.
198 for _, word := range []string{"EventEmitter", "MyError", "Component"} {
199 src := "const x = new " + word + "();"
200 if got := jsClassOf(t, src, word); got != syntax.ClassType {
201 t.Errorf("%s is %v, want type", word, got)
202 }
203 }
204}
205
206func TestALowerCaseNameIsAPlainIdentifier(t *testing.T) {
207 if got := jsClassOf(t, "const total = subtotal + tax;", "subtotal"); got != syntax.ClassIdentifier {
208 t.Errorf("subtotal is %v, want identifier", got)
209 }
210}
211
212func TestDollarAndUnderscoreBeginNames(t *testing.T) {
213 const src = "const $el = _.get(obj, 'a');"
214
215 if got := jsClassOf(t, src, "$el"); got != syntax.ClassIdentifier {
216 t.Errorf("$el is %v, want identifier", got)
217 }
218 if got := jsClassOf(t, src, "_"); got != syntax.ClassIdentifier {
219 t.Errorf("_ is %v, want identifier", got)
220 }
221}
222
223func TestUnicodeNamesAreNames(t *testing.T) {
224 const src = "const café = 名前 + 1;"
225
226 if got := jsClassOf(t, src, "café"); got != syntax.ClassIdentifier {
227 t.Errorf("café is %v, want identifier", got)
228 }
229 if got := jsClassOf(t, src, "名前"); got != syntax.ClassIdentifier {
230 t.Errorf("名前 is %v, want identifier", got)
231 }
232 if got := jsClassOf(t, src, "1"); got != syntax.ClassNumber {
233 t.Errorf("the number after a Unicode name is %v, want number", got)
234 }
235}
236
237func TestTheLiteralsAreConstants(t *testing.T) {
238 for _, word := range []string{"true", "false", "null", "undefined", "NaN", "Infinity", "this"} {
239 src := "let v = " + word + ";"
240 if got := jsClassOf(t, src, word); got != syntax.ClassConstant {
241 t.Errorf("%s is %v, want constant", word, got)
242 }
243 }
244}
245
246func TestNodesGlobalsAreBuiltIn(t *testing.T) {
247 // This is a Node editor: process, Buffer and the CommonJS five are as
248 // much part of the language its user writes as Array and Promise.
249 for _, word := range []string{"process", "Buffer", "require", "module", "exports", "__dirname", "console", "setTimeout", "fetch", "Promise", "JSON"} {
250 src := "x = " + word + ";"
251 if got := jsClassOf(t, src, word); got != syntax.ClassBuiltin {
252 t.Errorf("%s is %v, want builtin", word, got)
253 }
254 }
255}
256
257func TestAWordAfterADotIsAPropertyWhateverItIsSpeltLike(t *testing.T) {
258 // map.get(k) is a call, obj.default is a field, and neither is the
259 // keyword it would be on its own. Optional chaining is a member access
260 // too.
261 tests := []struct {
262 src, word string
263 want syntax.Class
264 }{
265 {"const v = map.get(key);", "get", syntax.ClassFunction},
266 {"const d = options.default;", "default", syntax.ClassIdentifier},
267 {"promise.catch(handle);", "catch", syntax.ClassFunction},
268 {"const c = user?.class;", "class", syntax.ClassIdentifier},
269 {"const p = obj.process;", "process", syntax.ClassIdentifier},
270 {"const t = obj.true;", "true", syntax.ClassIdentifier},
271 }
272
273 for _, test := range tests {
274 t.Run(test.src, func(t *testing.T) {
275 if got := jsClassOf(t, test.src, test.word); got != test.want {
276 t.Errorf("%s is %v, want %v", test.word, got, test.want)
277 }
278 })
279 }
280}
281
282func TestAContextualKeywordIsAKeywordOnlyInPosition(t *testing.T) {
283 // get before a method's name is a keyword; get(k) with nothing in front
284 // of it is a function called get.
285 if got := jsClassOf(t, "class C { get name() { return 1; } }", "get"); got != syntax.ClassKeyword {
286 t.Errorf("get before a getter's name is %v, want keyword", got)
287 }
288 if got := jsClassOf(t, "const v = get(key);", "get"); got != syntax.ClassFunction {
289 t.Errorf("get(key) is %v, want function", got)
290 }
291 if got := jsClassOf(t, "for (const x of xs) {}", "of"); got != syntax.ClassKeyword {
292 t.Errorf("of in a for-of is %v, want keyword", got)
293 }
294 if got := jsClassOf(t, "class C { static create() {} }", "static"); got != syntax.ClassKeyword {
295 t.Errorf("static before a member is %v, want keyword", got)
296 }
297}
298
299func TestAsyncIsAKeywordEvenBeforeAParenthesis(t *testing.T) {
300 // async (x) => … is an arrow function, and async there is not a call.
301 if got := jsClassOf(t, "const f = async (x) => x;", "async"); got != syntax.ClassKeyword {
302 t.Errorf("async before an arrow's parameters is %v, want keyword", got)
303 }
304}
305
306func TestAPrivateMemberTakesItsHash(t *testing.T) {
307 // #count is one name. Colouring the # alone would make it read as the
308 // comment marker it is in half the other languages this editor colours.
309 wholeWordIs(t, Highlight, "this.#count += 1;", "#count", syntax.ClassIdentifier)
310 wholeWordIs(t, Highlight, "this.#reset();", "#reset", syntax.ClassFunction)
311}
312
313func TestADecoratorIsAnAttribute(t *testing.T) {
314 wholeWordIs(t, Highlight, "@observable.ref count = 0;", "@observable.ref", syntax.ClassAttribute)
315 if got := jsClassOf(t, "@observable.ref count = 0;", "count"); got != syntax.ClassIdentifier {
316 t.Errorf("the name after a decorator is %v, want identifier", got)
317 }
318}
319
320// --- regular expressions and division ----------------------------------------
321
322func TestARegularExpressionIsColouredAsOneWhereOneMayBegin(t *testing.T) {
323 // After a keyword, an operator, an opening bracket or a comma, a slash
324 // opens a regular expression. The literal is coloured as a character
325 // literal — the class JavaScript has no other use for — flags included.
326 tests := []struct{ src, literal string }{
327 {"return /wor+ld/gi.test(s);", "/wor+ld/gi"},
328 {"if (/^#/.test(line)) {}", "/^#/"},
329 {"const re = /a[/]b\\/c/;", "/a[/]b\\/c/"},
330 {"lines.filter(/x/.test, re);", "/x/"},
331 {"x = y ? /a/ : /b/;", "/a/"},
332 {"/^\\s*$/.test(s);", "/^\\s*$/"},
333 }
334
335 for _, test := range tests {
336 t.Run(test.src, func(t *testing.T) {
337 wholeWordIs(t, Highlight, test.src, test.literal, syntax.ClassChar)
338 })
339 }
340}
341
342func TestASlashAfterAValueDivides(t *testing.T) {
343 // After a name, a number, a string, a closing parenthesis or bracket, a
344 // slash is division, and the thing after it is code.
345 tests := []struct{ src, after string }{
346 {"const half = total / 2 / count;", "count"},
347 {"const r = (a + b) / c;", "c"},
348 {"const r = items[0] / items.length;", "items.length"},
349 {"const r = 10 / n;", "n"},
350 {"const r = this / 2;", "2"},
351 }
352
353 for _, test := range tests {
354 t.Run(test.src, func(t *testing.T) {
355 if got := jsClassOf(t, test.src, "/"); got != syntax.ClassOperator {
356 t.Errorf("the slash is %v, want operator", got)
357 }
358 word := strings.SplitN(test.after, ".", 2)[0]
359 if got := jsClassOf(t, test.src, word); got == syntax.ClassChar || got == syntax.ClassString {
360 t.Errorf("%s after the slash is %v; the division was read as a regular expression", word, got)
361 }
362 })
363 }
364}
365
366func TestASlashThatNothingClosesOnItsLineDivides(t *testing.T) {
367 // A regular expression cannot cross a line, so a slash with no closing
368 // slash on its line cannot open one — whatever came before it. That bound
369 // is what keeps a wrong guess to one line.
370 const src = "x = a /\n b;"
371 spans := Highlight(src)
372
373 class, ok := classAt(spans, 0, strings.Index(src, "/"))
374 if !ok || class != syntax.ClassOperator {
375 t.Errorf("the trailing slash is %v (covered: %v), want operator", class, ok)
376 }
377 class, ok = classAt(spans, 1, 2)
378 if !ok || class != syntax.ClassIdentifier {
379 t.Errorf("the next line is %v (covered: %v), want code", class, ok)
380 }
381}
382
383func TestARegularExpressionMayOpenALine(t *testing.T) {
384 // A statement beginning with a regular expression is more likely than a
385 // line beginning with a division, so the start of a line allows one.
386 wholeWordIs(t, Highlight, "/foo/.test(x) && go();", "/foo/", syntax.ClassChar)
387}
388
389func TestACommentIsNotARegularExpression(t *testing.T) {
390 // // and /* come first, so a comment after a keyword stays a comment.
391 if got := jsClassOf(t, "return // done\n", "// done"); got != syntax.ClassComment {
392 t.Errorf("a comment after return is %v, want comment", got)
393 }
394 if got := jsClassOf(t, "return /* nothing */ x;", "/* nothing */"); got != syntax.ClassComment {
395 t.Errorf("a block comment after return is %v, want comment", got)
396 }
397}
398
399// --- strings and templates ---------------------------------------------------
400
401func TestAnEscapedQuoteDoesNotEndAString(t *testing.T) {
402 const src = `let s = "a \" b"; let n = 1;`
403
404 if got := jsClassOf(t, src, "let n"); got != syntax.ClassKeyword {
405 t.Errorf("the code after an escaped quote is %v, want keyword", got)
406 }
407}
408
409func TestAnUnterminatedStringStopsAtItsLine(t *testing.T) {
410 // JavaScript strings do not cross lines, so the next line is code again
411 // whatever the previous one left open.
412 spans := Highlight("let s = 'unterminated\nlet n = 1;")
413
414 class, ok := classAt(spans, 1, 0)
415 if !ok || class != syntax.ClassKeyword {
416 t.Errorf("the line after an unterminated string is %v (covered: %v), want code", class, ok)
417 }
418}
419
420func TestATemplateLiteralCarriesAcrossLines(t *testing.T) {
421 spans := Highlight("const t = `one\ntwo ${x}\nthree`;\nlet n = 1;")
422
423 for line := 1; line <= 2; line++ {
424 class, ok := classAt(spans, line, 0)
425 if !ok || class != syntax.ClassString {
426 t.Errorf("line %d of the template is %v (covered: %v), want string", line, class, ok)
427 }
428 }
429 class, ok := classAt(spans, 3, 0)
430 if !ok || class != syntax.ClassKeyword {
431 t.Errorf("the line after the template is %v (covered: %v), want code", class, ok)
432 }
433}
434
435func TestATemplateThatClosesMidLineLeavesCodeAfterIt(t *testing.T) {
436 if got := jsClassOf(t, "const t = `a\nb`; let n = 1;", "let n"); got != syntax.ClassKeyword {
437 t.Errorf("the code after the closing backtick is %v, want keyword", got)
438 }
439}
440
441func TestAnInterpolationIsPartOfTheTemplate(t *testing.T) {
442 // The whole literal is a string, ${…} included: colouring the code inside
443 // means carrying a nesting depth for a construct that is usually one
444 // short expression. The reference says so.
445 wholeWordIs(t, Highlight, "const t = `hello ${name.toUpperCase()}!`;", "`hello ${name.toUpperCase()}!`", syntax.ClassString)
446}
447
448func TestAnEscapedBacktickDoesNotEndATemplate(t *testing.T) {
449 const src = "const t = `a \\` b`; let n = 1;"
450
451 if got := jsClassOf(t, src, "let n"); got != syntax.ClassKeyword {
452 t.Errorf("the code after an escaped backtick is %v, want keyword", got)
453 }
454}
455
456// --- comments ---------------------------------------------------------------
457
458func TestABlockCommentCarriesAcrossLines(t *testing.T) {
459 spans := Highlight("/* one\ntwo\n*/ let x = 1;")
460
461 for line := range 2 {
462 class, ok := classAt(spans, line, 0)
463 if !ok || class != syntax.ClassComment {
464 t.Errorf("line %d is %v (covered: %v), want comment", line, class, ok)
465 }
466 }
467 class, ok := classAt(spans, 2, 3) // the "l" of let
468 if !ok || class != syntax.ClassKeyword {
469 t.Errorf("the code after the comment is %v (covered: %v), want keyword", class, ok)
470 }
471}
472
473func TestBlockCommentsDoNotNest(t *testing.T) {
474 // JavaScript ends a block comment at the first */, whatever opened inside
475 // it. A depth here would be a claim about a different language.
476 const src = "/* a /* b */ let x = 1;"
477
478 if got := jsClassOf(t, src, "let"); got != syntax.ClassKeyword {
479 t.Errorf("the code after the first */ is %v, want keyword", got)
480 }
481}
482
483func TestAHashbangIsACommentOnTheFirstLineOnly(t *testing.T) {
484 spans := Highlight("#!/usr/bin/env node\nconst x = 1;\n#!not a hashbang")
485
486 class, ok := classAt(spans, 0, 0)
487 if !ok || class != syntax.ClassComment {
488 t.Errorf("the hashbang is %v (covered: %v), want comment", class, ok)
489 }
490 class, ok = classAt(spans, 1, 0)
491 if !ok || class != syntax.ClassKeyword {
492 t.Errorf("the line after the hashbang is %v (covered: %v), want code", class, ok)
493 }
494 if class, _ := classAt(spans, 2, 0); class == syntax.ClassComment {
495 t.Error("#! on a later line was read as a hashbang")
496 }
497}
498
499// --- numbers ----------------------------------------------------------------
500
501func TestNumbersTakeTheirSeparatorsPrefixesAndSuffixes(t *testing.T) {
502 tests := []string{"1_000", "0xFF", "0o17", "0b1010", "1.5e-3", "2E+10", ".5", "10n", "0xFFn"}
503
504 for _, literal := range tests {
505 t.Run(literal, func(t *testing.T) {
506 wholeWordIs(t, Highlight, "const n = "+literal+";", literal, syntax.ClassNumber)
507 })
508 }
509}
510
511func TestAPlusAfterAHexadecimalLiteralIsASum(t *testing.T) {
512 // 0xE ends in an E, and the + straight after it is not an exponent's
513 // sign — written without spaces, because a space already ends the
514 // number and would hide a scanner that got this wrong.
515 const src = "const n = 0xE+1;"
516
517 if got := jsClassOf(t, src, "+"); got != syntax.ClassOperator {
518 t.Errorf("the + after 0xE is %v, want operator", got)
519 }
520 if got := jsClassOf(t, src, "1;"); got != syntax.ClassNumber {
521 t.Errorf("the 1 after the + is %v, want number", got)
522 }
523}
524
525func TestANumbersDotIsOnlyADotWhenADigitFollows(t *testing.T) {
526 // 1.toString() is invalid, but it is typed; the 1 stops at the dot and
527 // the method after it is a method.
528 const src = "1.toString();"
529
530 if got := jsClassOf(t, src, "toString"); got != syntax.ClassFunction {
531 t.Errorf("toString after 1. is %v, want function", got)
532 }
533}
534
535// --- punctuation and operators ------------------------------------------------
536
537func TestASpreadIsOneThing(t *testing.T) {
538 const src = "const all = [...first, ...rest];"
539
540 wholeWordIs(t, Highlight, src, "...", syntax.ClassPunctuation)
541 if got := jsClassOf(t, src, "first"); got != syntax.ClassIdentifier {
542 t.Errorf("the name after a spread is %v, want identifier", got)
543 }
544}
545
546func TestOptionalChainingAndArrowsAreOperators(t *testing.T) {
547 const src = "const f = (x) => x?.name ?? 'none';"
548
549 wholeWordIs(t, Highlight, src, "=>", syntax.ClassOperator)
550 wholeWordIs(t, Highlight, src, "?.", syntax.ClassOperator)
551 wholeWordIs(t, Highlight, src, "??", syntax.ClassOperator)
552}
553
554// --- the contracts the editor relies on --------------------------------------
555
556func TestSpansOnALineAreOrderedAndDoNotOverlap(t *testing.T) {
557 // The editor draws them in order and assumes they do not overlap.
558 const src = "class A extends B { #x = /re/g; static of() { return this.#x / 2 ?? `${a}`; } }"
559
560 for line, onLine := range Highlight(src) {
561 previousEnd := 0
562 for _, span := range onLine {
563 if span.Start < previousEnd {
564 t.Errorf("line %d: span %+v starts before the previous one ended at %d", line, span, previousEnd)
565 }
566 if span.End <= span.Start {
567 t.Errorf("line %d: span %+v is empty or backwards", line, span)
568 }
569 previousEnd = span.End
570 }
571 }
572}
573
574func TestBrokenSourceIsStillColoured(t *testing.T) {
575 // Source under the cursor is invalid most of the time it is being typed.
576 tests := []string{
577 `let s = "unterminated`,
578 "function f( {",
579 "const re = /unterminated",
580 "class {",
581 "x = `unterminated",
582 "/* unterminated",
583 "@",
584 "#",
585 "...",
586 "const = ;",
587 }
588
589 for _, src := range tests {
590 t.Run(src, func(t *testing.T) {
591 spans := Highlight(src)
592 if len(spans) != 1 {
593 t.Fatalf("Highlight(%q) returned %d lines, want 1", src, len(spans))
594 }
595 })
596 }
597}
598
599func TestColumnsAreCountedInRunesNotBytes(t *testing.T) {
600 // A byte offset would put the spans of a line with an accent in it out of
601 // step with what is drawn.
602 const src = `let café = "thé";`
603 spans := Highlight(src)
604
605 // "thé" starts at rune column 11: l-e-t-space-c-a-f-é-space-=-space-"
606 class, ok := classAt(spans, 0, 11)
607 if !ok || class != syntax.ClassString {
608 t.Errorf("the string after an accented identifier is %v (covered: %v), want string", class, ok)
609 }
610}
611
612func TestAWholeFileOfJavaScriptColoursWithoutPanicking(t *testing.T) {
613 // A broad sweep over the constructs the scanner knows, run for its own
614 // sake: the classes are checked one at a time above.
615 const src = `#!/usr/bin/env node
616// A small HTTP server.
617import { createServer } from "node:http";
618import { readFile } from "node:fs/promises";
619
620const PORT = process.env.PORT ?? 8080;
621const ROUTE = /^\/api\/(\w+)$/;
622
623/**
624 * Answers one request.
625 * @param {import("node:http").IncomingMessage} request
626 */
627async function handle(request, response) {
628 const match = ROUTE.exec(request.url);
629 if (!match) {
630 response.writeHead(404);
631 return response.end();
632 }
633 const [, name] = match;
634 const body = await readFile(` + "`./data/${name}.json`" + `, "utf8");
635 response.writeHead(200, { "content-type": "application/json" });
636 response.end(body);
637}
638
639class Counter {
640 #count = 0n;
641 static #instances = new Set();
642
643 constructor() {
644 Counter.#instances.add(this);
645 }
646
647 get count() {
648 return this.#count;
649 }
650
651 increment(by = 1) {
652 this.#count += BigInt(by);
653 return this;
654 }
655}
656
657createServer(handle).listen(PORT, () => {
658 console.log(` + "`listening on ${PORT}`" + `);
659});
660`
661 spans := Highlight(src)
662
663 if got, want := len(spans), strings.Count(src, "\n")+1; got != want {
664 t.Fatalf("Highlight() returned %d lines, want %d", got, want)
665 }
666}