| 📦 Turbo JS 91999d1 k33g 12h ago | 1 | package jslang |
| 2 | |
| 3 | import ( |
| 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. |
| 12 | func 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. |
| 26 | func 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. |
| 44 | func 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. |
| 51 | func 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 | |
| 64 | func 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 | |
| 88 | func TestEachTokenClass(t *testing.T) { |
| 89 | const src = `import { readFile } from "node:fs/promises"; |
| 90 | |
| 91 | // a comment |
| 92 | /** a doc comment */ |
| 93 | class Greeter { |
| 94 | #name; |
| 95 | constructor(name) { |
| 96 | this.#name = name; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | async 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 | |
| 157 | func 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 | |
| 176 | func 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 | |
| 187 | func 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 | |
| 193 | func 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 | |
| 206 | func 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 | |
| 212 | func 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 | |
| 223 | func 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 | |
| 237 | func 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 | |
| 246 | func 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 | |
| 257 | func 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 | |
| 282 | func 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 | |
| 299 | func 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 | |
| 306 | func 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 | |
| 313 | func 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 | |
| 322 | func 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 | |
| 342 | func 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 | |
| 366 | func 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 | |
| 383 | func 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 | |
| 389 | func 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 | |
| 401 | func 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 | |
| 409 | func 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 | |
| 420 | func 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 | |
| 435 | func 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 | |
| 441 | func 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 | |
| 448 | func 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 | |
| 458 | func 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 | |
| 473 | func 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 | |
| 483 | func 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 | |
| 501 | func 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 | |
| 511 | func 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 | |
| 525 | func 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 | |
| 537 | func 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 | |
| 546 | func 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 | |
| 556 | func 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 | |
| 574 | func 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 | |
| 599 | func 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 | |
| 612 | func 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. |
| 617 | import { createServer } from "node:http"; |
| 618 | import { readFile } from "node:fs/promises"; |
| 619 | |
| 620 | const PORT = process.env.PORT ?? 8080; |
| 621 | const ROUTE = /^\/api\/(\w+)$/; |
| 622 | |
| 623 | /** |
| 624 | * Answers one request. |
| 625 | * @param {import("node:http").IncomingMessage} request |
| 626 | */ |
| 627 | async 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 | |
| 639 | class 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 | |
| 657 | createServer(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 | } |