// Package jslang is everything about Turbo JS that is about *JavaScript*: how // the editor names itself, which language server it talks to, what a Node.js // project's starter files say, and how JavaScript and JSON are coloured. // // Everything else the editor does lives in turbo-core, which knows nothing // about Node. This package is the whole of the difference between Turbo JS // and Turbo Python, which is what makes a seventh editor a matter of writing // one of these rather than forking anything. // // jslang.Register() // teach the library this editor's JavaScript, and JSON // editor := app.New(screen, name, jslang.Profile()) package jslang import ( "os" "path/filepath" "rickub.com/turbo-editors/turbo-core/profile" "rickub.com/turbo-editors/turbo-core/syntax" ) // Name and Slug are what the editor calls itself. The slug is also its binary, // its project directory (as .turbo-js) and the stem of its environment // variables (as TURBO_JS_…), so it is not free to change. const ( Name = "Turbo JS" Slug = "turbo-js" ) // Language is the name JavaScript is known by: the value LanguageOf returns // for a .js file, and what a snippets file writes in its languages key. // // It is turbo-core's own constant rather than a string of this package's, // because the library already colours JavaScript for every editor in the // family — a README's code fence, a web page's script — and this editor // registers a fuller scanner under the **same** name. Registering the same // name is what makes the replacement: the later registration wins, snippets // written for "javascript" keep working, and a ```js fence in an agent window // is coloured by the scanner this editor uses for its own files. const Language = syntax.LanguageJavaScript // LanguageJSON is the name JSON is known by. JSON is not one of the eight // languages turbo-core colours, and a Node project cannot be edited without // it: package.json is the project's manifest and its root marker. const LanguageJSON syntax.Language = "json" // ServerCommand is the language server Turbo JS talks to, and InstallHint the // single command that installs it. // // typescript-language-server wraps tsserver, the engine behind every editor's // JavaScript support, and it serves plain JavaScript as well as TypeScript: // with no configuration file at all it infers a project from the files it is // shown and answers all nine of turbo-core's questions — completion, hover, // definition, type definition, implementation, references, the file's symbols // and a project-wide symbol search — and publishes diagnostics unasked. // // The hint names both packages because the server is a thin layer over the // typescript package and does not depend on it: installed alone it starts and // then reports that tsserver is missing. **The @6 is not optional.** TypeScript // 7 is the native port, and its package ships a compiler with a language // server of its own inside it (`tsc --lsp --stdio`) but no `tsserver.js` — // so typescript-language-server, which looks for that file, refuses to start // beside it: "Could not find a valid TypeScript installation". TypeScript 6 // is the last version that ships it. The hint has to fit on a status bar. // // TypeScript 7's own server was measured against the same tests as this one: // it answers all eight requests, four times faster, and publishes **no** // diagnostics — it offers them pull-style, through textDocument/diagnostic, // which turbo-core does not ask for. An editor whose gutter is blank because // the server waits to be asked looks exactly like one with nothing to report, // which is why the mature pair is the one named here. The day the library // learns to pull, `tsc --lsp --stdio` from a single `npm install -g typescript` // is the better answer. const ( ServerCommand = "typescript-language-server" InstallHint = "npm install -g typescript-language-server typescript@6" ) // RootMarker is the file that marks the root of a Node project. The language // server is started in the nearest directory at or above the file being // edited that holds one. const RootMarker = "package.json" // ServerArgs is what the server is started with. // // The --stdio is not optional: without it typescript-language-server prints // its usage and exits, which the editor reports as a server that died at // start-up. It is a function rather than a variable so that no caller can // append to the package's own slice. func ServerArgs() []string { return []string{"--stdio"} } // Profile returns the editor Turbo JS is. // // It is a function rather than a variable because Server.Dirs is worked out // from the environment, and a variable would freeze whatever NVM_BIN said when // the package was linked — which for a Node tool is the one value most likely // to change between two shells. func Profile() profile.Profile { return profile.Profile{ Name: Name, Slug: Slug, // What the About box and the status bar read out. The language is // JavaScript; Node.js is the runtime this editor's toolchain runs it // on, the way Go is the language and gc the compiler. Language: "JavaScript", // J is free: the fixed menus take F, E, S, R, C, O, W, N and H — // which rules out the S, the C and the R of JavaScript but not its // first letter — so the hot key lands where a reader expects it. The // menu is named after the language and not after npm or node, because // it holds whatever the project put in its tools file, and the first // tools file anybody writes outgrows the language's own toolchain. ToolsMenu: "~J~avaScript", // package.json is a Node project's boundary — its name, its // dependencies, its scripts — and in a monorepo the nearest one going // up is the package being edited, which is the root the server should // resolve imports from. RootMarkers: []string{RootMarker}, Server: profile.Server{ Command: ServerCommand, Args: ServerArgs(), InstallHint: InstallHint, Dirs: ServerDirs(), }, Templates: profile.Templates{ Settings: settingsTemplate, Snippets: snippetsTemplate, Tools: toolsTemplate, Agents: agentsTemplate, }, } } // Register teaches turbo-core this editor's JavaScript, and JSON. // // It is called explicitly at start-up rather than from an init function so that // "which languages does this editor know?" is answered by reading main, not by // working out which packages were imported. // // JavaScript is registered under the library's own name, replacing the scanner // turbo-core ships for every editor: this one knows regular expressions, the // hashbang line and Node's globals, which an editor *for* JavaScript owes its // user and a Rust editor's README does not need. A shebang naming node is // claimed too, because a command-line tool written in JavaScript is a file // with no extension whose first line is #!/usr/bin/env node — and Node reads // that line as a comment. func Register() { syntax.Register(syntax.Definition{ Language: Language, // .mjs is a module whatever package.json says, .cjs is CommonJS // whatever it says, and .js is whichever the nearest package.json // makes it. All three are the same language to a scanner. Extensions: []string{".js", ".mjs", ".cjs"}, Shebangs: []string{"node"}, Highlight: Highlight, }) syntax.Register(syntax.Definition{ Language: LanguageJSON, // .jsonc is JSON with comments, which tsconfig.json and an editor's // own settings file are in all but name. The scanner tolerates // comments in both, so the two extensions share it. Extensions: []string{".json", ".jsonc"}, Highlight: HighlightJSON, }) } // DefaultInstallDirs are the two system prefixes `npm install -g` writes into // when Node was installed system-wide: /usr/local/bin for Node's own installer // and most Linux packages, /opt/homebrew/bin for Homebrew on Apple silicon. // Both are on PATH on nearly every machine, and searched all the same for the // one where they are not. var DefaultInstallDirs = []string{"/usr/local/bin", "/opt/homebrew/bin"} // ServerDirs returns the directories the language server is looked for in // after PATH, most specific first. // // "Completion silently does nothing" is what a user sees when the editor cannot // find a server they believe they installed, and Node has more places to put // a global binary than most: the version manager's current version, a // per-user npm prefix, pnpm's own home, Volta's, and the system prefix. // // Empty entries are skipped by the library, so a machine with no nvm and no // pnpm simply contributes nothing here. func ServerDirs() []string { dirs := []string{NvmBinDir(), NpmPrefixBinDir(), PnpmHome(), VoltaBinDir()} return append(dirs, DefaultInstallDirs...) } // NvmBinDir returns the bin directory of the Node version nvm has selected in // this shell, or "" when nvm is not in use. // // nvm exports NVM_BIN whenever a version is active, and it is the directory // `npm install -g` writes into under nvm — which makes it the most specific // answer available, and the one that changes when the user runs `nvm use`. // nvm's default alias is not resolved here: that means reading and following // files under NVM_DIR, and a shell where nvm is set up has NVM_BIN already. func NvmBinDir() string { return os.Getenv("NVM_BIN") } // NpmPrefixBinDir returns the bin directory of a per-user npm prefix: // NPM_CONFIG_PREFIX/bin when that variable is set, and ~/.npm-global/bin // otherwise. // // ~/.npm-global is the prefix npm's own documentation tells a user to // configure so that `npm install -g` needs no sudo, and a machine set up that // way has the server there and nowhere else. The prefix set with `npm config // set prefix` — which lands in ~/.npmrc — is not read; the environment // variable is the one place npm itself documents for overriding it. func NpmPrefixBinDir() string { if prefix := os.Getenv("NPM_CONFIG_PREFIX"); prefix != "" { return filepath.Join(prefix, "bin") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".npm-global", "bin") } // PnpmHome returns the directory pnpm puts globally installed binaries in, or // "" when pnpm is not set up. pnpm insists on PNPM_HOME being set and on PATH // before it will install anything globally, so the variable is the answer. func PnpmHome() string { return os.Getenv("PNPM_HOME") } // VoltaBinDir returns Volta's shim directory: VOLTA_HOME/bin when VOLTA_HOME // is set, and ~/.volta/bin otherwise. A tool installed with `volta install` // is a shim in that directory, and Volta's installer adds it to PATH — for // the shell it was run from. func VoltaBinDir() string { if home := os.Getenv("VOLTA_HOME"); home != "" { return filepath.Join(home, "bin") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".volta", "bin") }