package jslang_test import ( "slices" "strings" "testing" "rickub.com/turbo-editors/turbo-core/profile" "rickub.com/turbo-editors/turbo-core/syntax" "rickub.com/turbo-editors/turbo-js/internal/jslang" ) // fixedMenuHotKeys are the hot keys turbo-core's own menus take. The toolchain // menu may not claim one of them, or one of the two would be unreachable from // the keyboard and nothing would say so. var fixedMenuHotKeys = []rune{'F', 'E', 'S', 'R', 'C', 'O', 'W', 'N', 'H'} func TestProfileNamesTheEditor(t *testing.T) { p := jslang.Profile() if p.Name != "Turbo JS" { t.Errorf("Name = %q, want %q", p.Name, "Turbo JS") } if p.Slug != "turbo-js" { t.Errorf("Slug = %q, want %q", p.Slug, "turbo-js") } // The About box and the status bar read this out. It names the language, // not the runtime: JavaScript is what the files are written in, and Node // is what the toolchain runs them on. if p.Language != "JavaScript" { t.Errorf("Language = %q, want %q", p.Language, "JavaScript") } } func TestSlugDerivesEveryPath(t *testing.T) { p := jslang.Profile() if got := p.ProjectDir(); got != ".turbo-js" { t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-js") } for name, got := range map[string]string{ "DirEnvVar": p.DirEnvVar(), "ThemeDirEnvVar": p.ThemeDirEnvVar(), "SnippetDirEnvVar": p.SnippetDirEnvVar(), } { if !strings.HasPrefix(got, "TURBO_JS_") { t.Errorf("%s() = %q, want a TURBO_JS_ prefix", name, got) } } } func TestThemeDirFollowsItsEnvironmentVariable(t *testing.T) { p := jslang.Profile() want := t.TempDir() t.Setenv(p.ThemeDirEnvVar(), want) if got := p.ThemeDir(); got != want { t.Errorf("ThemeDir() = %q, want %q", got, want) } } func TestToolsMenuHotKeyClashesWithNoFixedMenu(t *testing.T) { label := jslang.Profile().ToolsMenu hotKey, ok := hotKeyOf(label) if !ok { t.Fatalf("ToolsMenu = %q, which marks no hot key between tildes", label) } if slices.Contains(fixedMenuHotKeys, hotKey) { t.Errorf("ToolsMenu hot key %q is already taken by a fixed menu", hotKey) } if got := strings.ReplaceAll(label, "~", ""); got != "JavaScript" { t.Errorf("ToolsMenu reads %q once the tildes are removed, want %q", got, "JavaScript") } } // hotKeyOf returns the upper-case letter a menu label marks between tildes. func hotKeyOf(label string) (rune, bool) { open := strings.Index(label, "~") if open < 0 { return 0, false } rest := label[open+1:] shut := strings.Index(rest, "~") if shut != 1 { return 0, false } return []rune(strings.ToUpper(rest))[0], true } func TestTheRootMarkerIsPackageJSON(t *testing.T) { // package.json is a Node project's boundary, and the nearest one going up // is the package being edited. main_test.go checks the walk from the // other side. if got := jslang.Profile().RootMarkers; !slices.Equal(got, []string{"package.json"}) { t.Errorf("RootMarkers = %v, want exactly [package.json]", got) } } func TestServerIsTypeScriptLanguageServerOverStdio(t *testing.T) { server := jslang.Profile().Server if server.Command != "typescript-language-server" { t.Errorf("Server.Command = %q, want %q", server.Command, "typescript-language-server") } // Without --stdio the server prints its usage and exits, which the // editor reports as a server that died at start-up. if !slices.Equal(server.Args, []string{"--stdio"}) { t.Errorf("Server.Args = %v, want exactly [--stdio]", server.Args) } if server.InstallHint == "" { t.Error("Server.InstallHint is empty; a missing server would say nothing useful") } // It is shown on the status bar, so it has to fit on a narrow line. if len(server.InstallHint) > 72 { t.Errorf("InstallHint is %d characters, too long for a status bar", len(server.InstallHint)) } // The server does not depend on the typescript package it wraps, so a // hint that installs it alone leaves a server that starts and then says // tsserver is missing — and so does one that installs the *current* // typescript, which is the native TypeScript 7 and ships no tsserver.js. // The pin to 6 is the whole difference between completion and nothing. if !strings.Contains(server.InstallHint, "typescript-language-server") || !strings.HasSuffix(server.InstallHint, " typescript@6") { t.Errorf("InstallHint = %q, want it to install both the server and typescript@6", server.InstallHint) } } func TestServerArgsCannotBeAppendedToByACaller(t *testing.T) { first := jslang.ServerArgs() first = append(first, "--nonsense") if second := jslang.ServerArgs(); slices.Contains(second, "--nonsense") { t.Errorf("ServerArgs() = %v after a caller appended to an earlier result", second) } } func TestServerIsLookedForInTheSystemPrefixes(t *testing.T) { // `npm install -g` with a system-wide Node writes into /usr/local/bin, or // /opt/homebrew/bin under Homebrew on Apple silicon. Both are on PATH on // nearly every machine, and the editor searches them anyway for the one // where they are not. dirs := jslang.Profile().Server.Dirs for _, want := range []string{"/usr/local/bin", "/opt/homebrew/bin"} { if !slices.Contains(dirs, want) { t.Errorf("Server.Dirs = %v, want it to contain %s", dirs, want) } } } func TestServerDirsFollowTheVersionManagersVariables(t *testing.T) { // Each variable names the one directory its tool writes global binaries // into. Set, it is searched; unset, the tool is not in use and nothing is // guessed for it. t.Setenv("NVM_BIN", "/versions/node/v24/bin") t.Setenv("PNPM_HOME", "/pnpm/home") t.Setenv("VOLTA_HOME", "/volta") t.Setenv("NPM_CONFIG_PREFIX", "/npm/prefix") dirs := jslang.ServerDirs() for _, want := range []string{"/versions/node/v24/bin", "/pnpm/home", "/volta/bin", "/npm/prefix/bin"} { if !slices.Contains(dirs, want) { t.Errorf("ServerDirs() = %v, want it to contain %s", dirs, want) } } // The most specific answer — the version nvm has selected — comes first, // because it is the one that changes when the user runs `nvm use`. if dirs[0] != "/versions/node/v24/bin" { t.Errorf("ServerDirs() starts with %q, want nvm's directory first", dirs[0]) } } func TestServerDirsGuessNothingForAToolThatIsNotSetUp(t *testing.T) { t.Setenv("NVM_BIN", "") t.Setenv("PNPM_HOME", "") if got := jslang.NvmBinDir(); got != "" { t.Errorf("NvmBinDir() = %q with no NVM_BIN, want nothing", got) } if got := jslang.PnpmHome(); got != "" { t.Errorf("PnpmHome() = %q with no PNPM_HOME, want nothing", got) } } func TestTheNpmPrefixDefaultsToTheDocumentedPerUserOne(t *testing.T) { // ~/.npm-global is the prefix npm's documentation tells a user to set up // so that installing globally needs no sudo. It is where the install // hint's command lands on such a machine, so it is the one that matters // to somebody who followed the hint and found nothing. t.Setenv("NPM_CONFIG_PREFIX", "") t.Setenv("VOLTA_HOME", "") if got := jslang.NpmPrefixBinDir(); !strings.HasSuffix(got, "/.npm-global/bin") { t.Errorf("NpmPrefixBinDir() = %q, want it under ~/.npm-global/bin", got) } if got := jslang.VoltaBinDir(); !strings.HasSuffix(got, "/.volta/bin") { t.Errorf("VoltaBinDir() = %q, want it under ~/.volta/bin", got) } } func TestProfileHandsOutAFreshCopyEveryTime(t *testing.T) { // A caller that appends to Server.Dirs must not be appending to the // package's one copy, or the next caller would inherit its guess. first := jslang.Profile() first.Server.Dirs = append(first.Server.Dirs, "/somewhere/else") if second := jslang.Profile(); slices.Contains(second.Server.Dirs, "/somewhere/else") { t.Errorf("Profile().Server.Dirs = %v after a caller appended to an earlier result", second.Server.Dirs) } } func TestTemplatesAreAllFilledIn(t *testing.T) { templates := jslang.Profile().Templates for name, template := range map[string]string{ "Settings": templates.Settings, "Snippets": templates.Snippets, "Tools": templates.Tools, "Agents": templates.Agents, } { if template == "" { t.Errorf("Templates.%s is empty; the editor would offer to write nothing", name) } } } func TestRegisterTeachesTheLibraryJavaScriptAndJSON(t *testing.T) { jslang.Register() for _, want := range []syntax.Language{jslang.Language, jslang.LanguageJSON} { if !slices.Contains(syntax.Registered(), want) { t.Errorf("Registered() = %v, want it to contain %q", syntax.Registered(), want) } } for path, want := range map[string]syntax.Language{ "main.js": jslang.Language, "lib/server.mjs": jslang.Language, "config/legacy.cjs": jslang.Language, "DEEP/nested/x.JS": jslang.Language, "package.json": jslang.LanguageJSON, "tsconfig.json": jslang.LanguageJSON, ".vscode/tasks.jsonc": jslang.LanguageJSON, } { if got := syntax.LanguageOf(path, ""); got != want { t.Errorf("LanguageOf(%q) = %q, want %q", path, got, want) } } } func TestRegisterReplacesTheLibrarysOwnJavaScriptScanner(t *testing.T) { // The library colours JavaScript too, without regular expressions: it // reads /x/ as two divisions. Registering under the same name is what // puts this editor's scanner in its place, and a regular expression // coming out coloured as one is the proof that it did. jslang.Register() const src = "return /x/.test(s);" spans := syntax.Highlight(jslang.Language, src) at := strings.Index(src, "/x/") for _, span := range spans[0] { if span.Start <= at && at < span.End { if span.Class != syntax.ClassChar { t.Errorf("/x/ is %v; the library's scanner is still in charge of JavaScript", span.Class) } return } } t.Error("nothing covers the regular expression at all") } func TestAScriptWithANodeShebangIsJavaScript(t *testing.T) { // A command-line tool written in JavaScript is a file with no extension // whose first line names node. Node reads that line as a comment, and // the editor reads it before choosing a scanner. jslang.Register() for _, firstLine := range []string{"#!/usr/bin/env node", "#!/usr/local/bin/node", "#!/usr/bin/env -S node --no-warnings"} { if got := syntax.LanguageOf("bin/cli", firstLine); got != jslang.Language { t.Errorf("LanguageOf(%q) = %q, want %q", firstLine, got, jslang.Language) } } } func TestRegisterLeavesOtherFilesAlone(t *testing.T) { jslang.Register() cases := map[string]syntax.Language{ "README.md": syntax.LanguageMarkdown, "Dockerfile": syntax.LanguageDockerfile, "index.html": syntax.LanguageHTML, "main.py": syntax.LanguageNone, "main.rs": syntax.LanguageNone, "main.go": syntax.LanguageNone, // TypeScript is served by the same language server but is not // coloured here: it is a different language with its own keywords, // and a deliberate boundary the reference documents. "main.ts": syntax.LanguageNone, // A JavaScript file's neighbour with a different extension is not // JavaScript however JavaScript-flavoured its name. "javascript.toml": syntax.LanguageTOML, } for path, want := range cases { if got := syntax.LanguageOf(path, ""); got != want { t.Errorf("LanguageOf(%q) = %q, want %q", path, got, want) } } // A shebang naming another interpreter is not JavaScript either. if got := syntax.LanguageOf("script", "#!/usr/bin/env python3"); got == jslang.Language { t.Errorf("LanguageOf with a python shebang = %q, want anything but JavaScript", got) } } // A profile is a literal, so this example is the whole of how one is used. func ExampleProfile() { jslang.Register() var p profile.Profile = jslang.Profile() _ = p.ProjectDir() // ".turbo-js" }