| 📦 Turbo JS 91999d1 k33g 15h ago | 1 | package jslang_test |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "rickub.com/turbo-editors/turbo-core/profile" |
| 9 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 10 | |
| 11 | "rickub.com/turbo-editors/turbo-js/internal/jslang" |
| 12 | ) |
| 13 | |
| 14 | // fixedMenuHotKeys are the hot keys turbo-core's own menus take. The toolchain |
| 15 | // menu may not claim one of them, or one of the two would be unreachable from |
| 16 | // the keyboard and nothing would say so. |
| 17 | var fixedMenuHotKeys = []rune{'F', 'E', 'S', 'R', 'C', 'O', 'W', 'N', 'H'} |
| 18 | |
| 19 | func TestProfileNamesTheEditor(t *testing.T) { |
| 20 | p := jslang.Profile() |
| 21 | |
| 22 | if p.Name != "Turbo JS" { |
| 23 | t.Errorf("Name = %q, want %q", p.Name, "Turbo JS") |
| 24 | } |
| 25 | if p.Slug != "turbo-js" { |
| 26 | t.Errorf("Slug = %q, want %q", p.Slug, "turbo-js") |
| 27 | } |
| 28 | // The About box and the status bar read this out. It names the language, |
| 29 | // not the runtime: JavaScript is what the files are written in, and Node |
| 30 | // is what the toolchain runs them on. |
| 31 | if p.Language != "JavaScript" { |
| 32 | t.Errorf("Language = %q, want %q", p.Language, "JavaScript") |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestSlugDerivesEveryPath(t *testing.T) { |
| 37 | p := jslang.Profile() |
| 38 | |
| 39 | if got := p.ProjectDir(); got != ".turbo-js" { |
| 40 | t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-js") |
| 41 | } |
| 42 | for name, got := range map[string]string{ |
| 43 | "DirEnvVar": p.DirEnvVar(), |
| 44 | "ThemeDirEnvVar": p.ThemeDirEnvVar(), |
| 45 | "SnippetDirEnvVar": p.SnippetDirEnvVar(), |
| 46 | } { |
| 47 | if !strings.HasPrefix(got, "TURBO_JS_") { |
| 48 | t.Errorf("%s() = %q, want a TURBO_JS_ prefix", name, got) |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestThemeDirFollowsItsEnvironmentVariable(t *testing.T) { |
| 54 | p := jslang.Profile() |
| 55 | want := t.TempDir() |
| 56 | t.Setenv(p.ThemeDirEnvVar(), want) |
| 57 | |
| 58 | if got := p.ThemeDir(); got != want { |
| 59 | t.Errorf("ThemeDir() = %q, want %q", got, want) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestToolsMenuHotKeyClashesWithNoFixedMenu(t *testing.T) { |
| 64 | label := jslang.Profile().ToolsMenu |
| 65 | |
| 66 | hotKey, ok := hotKeyOf(label) |
| 67 | if !ok { |
| 68 | t.Fatalf("ToolsMenu = %q, which marks no hot key between tildes", label) |
| 69 | } |
| 70 | if slices.Contains(fixedMenuHotKeys, hotKey) { |
| 71 | t.Errorf("ToolsMenu hot key %q is already taken by a fixed menu", hotKey) |
| 72 | } |
| 73 | if got := strings.ReplaceAll(label, "~", ""); got != "JavaScript" { |
| 74 | t.Errorf("ToolsMenu reads %q once the tildes are removed, want %q", got, "JavaScript") |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // hotKeyOf returns the upper-case letter a menu label marks between tildes. |
| 79 | func hotKeyOf(label string) (rune, bool) { |
| 80 | open := strings.Index(label, "~") |
| 81 | if open < 0 { |
| 82 | return 0, false |
| 83 | } |
| 84 | rest := label[open+1:] |
| 85 | shut := strings.Index(rest, "~") |
| 86 | if shut != 1 { |
| 87 | return 0, false |
| 88 | } |
| 89 | return []rune(strings.ToUpper(rest))[0], true |
| 90 | } |
| 91 | |
| 92 | func TestTheRootMarkerIsPackageJSON(t *testing.T) { |
| 93 | // package.json is a Node project's boundary, and the nearest one going up |
| 94 | // is the package being edited. main_test.go checks the walk from the |
| 95 | // other side. |
| 96 | if got := jslang.Profile().RootMarkers; !slices.Equal(got, []string{"package.json"}) { |
| 97 | t.Errorf("RootMarkers = %v, want exactly [package.json]", got) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestServerIsTypeScriptLanguageServerOverStdio(t *testing.T) { |
| 102 | server := jslang.Profile().Server |
| 103 | |
| 104 | if server.Command != "typescript-language-server" { |
| 105 | t.Errorf("Server.Command = %q, want %q", server.Command, "typescript-language-server") |
| 106 | } |
| 107 | // Without --stdio the server prints its usage and exits, which the |
| 108 | // editor reports as a server that died at start-up. |
| 109 | if !slices.Equal(server.Args, []string{"--stdio"}) { |
| 110 | t.Errorf("Server.Args = %v, want exactly [--stdio]", server.Args) |
| 111 | } |
| 112 | if server.InstallHint == "" { |
| 113 | t.Error("Server.InstallHint is empty; a missing server would say nothing useful") |
| 114 | } |
| 115 | // It is shown on the status bar, so it has to fit on a narrow line. |
| 116 | if len(server.InstallHint) > 72 { |
| 117 | t.Errorf("InstallHint is %d characters, too long for a status bar", len(server.InstallHint)) |
| 118 | } |
| 119 | // The server does not depend on the typescript package it wraps, so a |
| 120 | // hint that installs it alone leaves a server that starts and then says |
| 121 | // tsserver is missing — and so does one that installs the *current* |
| 122 | // typescript, which is the native TypeScript 7 and ships no tsserver.js. |
| 123 | // The pin to 6 is the whole difference between completion and nothing. |
| 124 | if !strings.Contains(server.InstallHint, "typescript-language-server") || !strings.HasSuffix(server.InstallHint, " typescript@6") { |
| 125 | t.Errorf("InstallHint = %q, want it to install both the server and typescript@6", server.InstallHint) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestServerArgsCannotBeAppendedToByACaller(t *testing.T) { |
| 130 | first := jslang.ServerArgs() |
| 131 | first = append(first, "--nonsense") |
| 132 | |
| 133 | if second := jslang.ServerArgs(); slices.Contains(second, "--nonsense") { |
| 134 | t.Errorf("ServerArgs() = %v after a caller appended to an earlier result", second) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestServerIsLookedForInTheSystemPrefixes(t *testing.T) { |
| 139 | // `npm install -g` with a system-wide Node writes into /usr/local/bin, or |
| 140 | // /opt/homebrew/bin under Homebrew on Apple silicon. Both are on PATH on |
| 141 | // nearly every machine, and the editor searches them anyway for the one |
| 142 | // where they are not. |
| 143 | dirs := jslang.Profile().Server.Dirs |
| 144 | |
| 145 | for _, want := range []string{"/usr/local/bin", "/opt/homebrew/bin"} { |
| 146 | if !slices.Contains(dirs, want) { |
| 147 | t.Errorf("Server.Dirs = %v, want it to contain %s", dirs, want) |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestServerDirsFollowTheVersionManagersVariables(t *testing.T) { |
| 153 | // Each variable names the one directory its tool writes global binaries |
| 154 | // into. Set, it is searched; unset, the tool is not in use and nothing is |
| 155 | // guessed for it. |
| 156 | t.Setenv("NVM_BIN", "/versions/node/v24/bin") |
| 157 | t.Setenv("PNPM_HOME", "/pnpm/home") |
| 158 | t.Setenv("VOLTA_HOME", "/volta") |
| 159 | t.Setenv("NPM_CONFIG_PREFIX", "/npm/prefix") |
| 160 | |
| 161 | dirs := jslang.ServerDirs() |
| 162 | |
| 163 | for _, want := range []string{"/versions/node/v24/bin", "/pnpm/home", "/volta/bin", "/npm/prefix/bin"} { |
| 164 | if !slices.Contains(dirs, want) { |
| 165 | t.Errorf("ServerDirs() = %v, want it to contain %s", dirs, want) |
| 166 | } |
| 167 | } |
| 168 | // The most specific answer — the version nvm has selected — comes first, |
| 169 | // because it is the one that changes when the user runs `nvm use`. |
| 170 | if dirs[0] != "/versions/node/v24/bin" { |
| 171 | t.Errorf("ServerDirs() starts with %q, want nvm's directory first", dirs[0]) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestServerDirsGuessNothingForAToolThatIsNotSetUp(t *testing.T) { |
| 176 | t.Setenv("NVM_BIN", "") |
| 177 | t.Setenv("PNPM_HOME", "") |
| 178 | |
| 179 | if got := jslang.NvmBinDir(); got != "" { |
| 180 | t.Errorf("NvmBinDir() = %q with no NVM_BIN, want nothing", got) |
| 181 | } |
| 182 | if got := jslang.PnpmHome(); got != "" { |
| 183 | t.Errorf("PnpmHome() = %q with no PNPM_HOME, want nothing", got) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestTheNpmPrefixDefaultsToTheDocumentedPerUserOne(t *testing.T) { |
| 188 | // ~/.npm-global is the prefix npm's documentation tells a user to set up |
| 189 | // so that installing globally needs no sudo. It is where the install |
| 190 | // hint's command lands on such a machine, so it is the one that matters |
| 191 | // to somebody who followed the hint and found nothing. |
| 192 | t.Setenv("NPM_CONFIG_PREFIX", "") |
| 193 | t.Setenv("VOLTA_HOME", "") |
| 194 | |
| 195 | if got := jslang.NpmPrefixBinDir(); !strings.HasSuffix(got, "/.npm-global/bin") { |
| 196 | t.Errorf("NpmPrefixBinDir() = %q, want it under ~/.npm-global/bin", got) |
| 197 | } |
| 198 | if got := jslang.VoltaBinDir(); !strings.HasSuffix(got, "/.volta/bin") { |
| 199 | t.Errorf("VoltaBinDir() = %q, want it under ~/.volta/bin", got) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestProfileHandsOutAFreshCopyEveryTime(t *testing.T) { |
| 204 | // A caller that appends to Server.Dirs must not be appending to the |
| 205 | // package's one copy, or the next caller would inherit its guess. |
| 206 | first := jslang.Profile() |
| 207 | first.Server.Dirs = append(first.Server.Dirs, "/somewhere/else") |
| 208 | |
| 209 | if second := jslang.Profile(); slices.Contains(second.Server.Dirs, "/somewhere/else") { |
| 210 | t.Errorf("Profile().Server.Dirs = %v after a caller appended to an earlier result", second.Server.Dirs) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestTemplatesAreAllFilledIn(t *testing.T) { |
| 215 | templates := jslang.Profile().Templates |
| 216 | |
| 217 | for name, template := range map[string]string{ |
| 218 | "Settings": templates.Settings, |
| 219 | "Snippets": templates.Snippets, |
| 220 | "Tools": templates.Tools, |
| 221 | "Agents": templates.Agents, |
| 222 | } { |
| 223 | if template == "" { |
| 224 | t.Errorf("Templates.%s is empty; the editor would offer to write nothing", name) |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func TestRegisterTeachesTheLibraryJavaScriptAndJSON(t *testing.T) { |
| 230 | jslang.Register() |
| 231 | |
| 232 | for _, want := range []syntax.Language{jslang.Language, jslang.LanguageJSON} { |
| 233 | if !slices.Contains(syntax.Registered(), want) { |
| 234 | t.Errorf("Registered() = %v, want it to contain %q", syntax.Registered(), want) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | for path, want := range map[string]syntax.Language{ |
| 239 | "main.js": jslang.Language, |
| 240 | "lib/server.mjs": jslang.Language, |
| 241 | "config/legacy.cjs": jslang.Language, |
| 242 | "DEEP/nested/x.JS": jslang.Language, |
| 243 | "package.json": jslang.LanguageJSON, |
| 244 | "tsconfig.json": jslang.LanguageJSON, |
| 245 | ".vscode/tasks.jsonc": jslang.LanguageJSON, |
| 246 | } { |
| 247 | if got := syntax.LanguageOf(path, ""); got != want { |
| 248 | t.Errorf("LanguageOf(%q) = %q, want %q", path, got, want) |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestRegisterReplacesTheLibrarysOwnJavaScriptScanner(t *testing.T) { |
| 254 | // The library colours JavaScript too, without regular expressions: it |
| 255 | // reads /x/ as two divisions. Registering under the same name is what |
| 256 | // puts this editor's scanner in its place, and a regular expression |
| 257 | // coming out coloured as one is the proof that it did. |
| 258 | jslang.Register() |
| 259 | |
| 260 | const src = "return /x/.test(s);" |
| 261 | spans := syntax.Highlight(jslang.Language, src) |
| 262 | at := strings.Index(src, "/x/") |
| 263 | |
| 264 | for _, span := range spans[0] { |
| 265 | if span.Start <= at && at < span.End { |
| 266 | if span.Class != syntax.ClassChar { |
| 267 | t.Errorf("/x/ is %v; the library's scanner is still in charge of JavaScript", span.Class) |
| 268 | } |
| 269 | return |
| 270 | } |
| 271 | } |
| 272 | t.Error("nothing covers the regular expression at all") |
| 273 | } |
| 274 | |
| 275 | func TestAScriptWithANodeShebangIsJavaScript(t *testing.T) { |
| 276 | // A command-line tool written in JavaScript is a file with no extension |
| 277 | // whose first line names node. Node reads that line as a comment, and |
| 278 | // the editor reads it before choosing a scanner. |
| 279 | jslang.Register() |
| 280 | |
| 281 | for _, firstLine := range []string{"#!/usr/bin/env node", "#!/usr/local/bin/node", "#!/usr/bin/env -S node --no-warnings"} { |
| 282 | if got := syntax.LanguageOf("bin/cli", firstLine); got != jslang.Language { |
| 283 | t.Errorf("LanguageOf(%q) = %q, want %q", firstLine, got, jslang.Language) |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestRegisterLeavesOtherFilesAlone(t *testing.T) { |
| 289 | jslang.Register() |
| 290 | |
| 291 | cases := map[string]syntax.Language{ |
| 292 | "README.md": syntax.LanguageMarkdown, |
| 293 | "Dockerfile": syntax.LanguageDockerfile, |
| 294 | "index.html": syntax.LanguageHTML, |
| 295 | "main.py": syntax.LanguageNone, |
| 296 | "main.rs": syntax.LanguageNone, |
| 297 | "main.go": syntax.LanguageNone, |
| 298 | // TypeScript is served by the same language server but is not |
| 299 | // coloured here: it is a different language with its own keywords, |
| 300 | // and a deliberate boundary the reference documents. |
| 301 | "main.ts": syntax.LanguageNone, |
| 302 | // A JavaScript file's neighbour with a different extension is not |
| 303 | // JavaScript however JavaScript-flavoured its name. |
| 304 | "javascript.toml": syntax.LanguageTOML, |
| 305 | } |
| 306 | for path, want := range cases { |
| 307 | if got := syntax.LanguageOf(path, ""); got != want { |
| 308 | t.Errorf("LanguageOf(%q) = %q, want %q", path, got, want) |
| 309 | } |
| 310 | } |
| 311 | // A shebang naming another interpreter is not JavaScript either. |
| 312 | if got := syntax.LanguageOf("script", "#!/usr/bin/env python3"); got == jslang.Language { |
| 313 | t.Errorf("LanguageOf with a python shebang = %q, want anything but JavaScript", got) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // A profile is a literal, so this example is the whole of how one is used. |
| 318 | func ExampleProfile() { |
| 319 | jslang.Register() |
| 320 | |
| 321 | var p profile.Profile = jslang.Profile() |
| 322 | _ = p.ProjectDir() // ".turbo-js" |
| 323 | } |