// Package pythonlang is everything about Turbo Python that is about *Python*: // how the editor names itself, which language server it talks to, what a // project's starter files say, and how Python source is coloured. // // Everything else the editor does lives in turbo-core, which knows nothing // about Python. This package is the whole of the difference between Turbo // Python and Turbo Rust, which is what makes a fourth editor a matter of // writing one of these rather than forking anything. // // pythonlang.Register() // teach the library to colour Python // editor := app.New(screen, name, pythonlang.Profile()) package pythonlang 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-python) and the stem of its environment // variables (as TURBO_PYTHON_…), so it is not free to change. const ( Name = "Turbo Python" Slug = "turbo-python" ) // Language is the name Python is known by: the value LanguageOf returns for a // .py file, and what a snippets file writes in its languages key. const Language syntax.Language = "python" // ServerCommand is the language server Turbo Python talks to, and InstallHint // the single command that installs it. // // python-lsp-server answers seven of the nine questions turbo-core asks — // completion, hover, definition, type definition, references and the file's // symbols — and publishes diagnostics unasked. It advertises neither // implementations nor a project-wide symbol search, so those two items report // nothing found; that is documented rather than worked around. // // **The [all] is not optional.** Installed bare, pylsp starts, completes and // jumps, and publishes an *empty* list of diagnostics for a file that does not // parse — because the linters that produce them are extras, and without them // the server has nothing to say. An editor whose gutter stays blank because // the server has no linter looks exactly like one whose gutter is blank // because the code is fine, which is why the hint installs them. // // pipx is named rather than pip because the server is a tool rather than a // dependency of the project being edited, and installing it into that // project's environment is how it ends up missing from the next one. const ( ServerCommand = "pylsp" InstallHint = `pipx install "python-lsp-server[all]"` ) // Profile returns the editor Turbo Python is. // // It is a function rather than a variable because Server.Dirs is worked out // from the environment, and a variable would freeze whatever VIRTUAL_ENV said // when the package was linked — which for a Python tool is the one value most // likely to change between two runs in the same shell. func Profile() profile.Profile { return profile.Profile{ Name: Name, Slug: Slug, Language: "Python", // P is free: the fixed menus take F, E, S, R, C, O, W, N and H, so the // hot key lands on the first letter of the word, which is the reading // that costs nobody a second glance. The menu is named after the // language and not after uv, 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: "~P~ython", // pyproject.toml first because it is where a modern project declares // itself, then the two forms a setuptools project used before it // existed. The nearest one going up is the directory the server is // started in. RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg"}, Server: profile.Server{ Command: ServerCommand, // pylsp takes no subcommand, unlike gopls. Args: nil, InstallHint: InstallHint, Dirs: ServerDirs(), }, Templates: profile.Templates{ Settings: settingsTemplate, Snippets: snippetsTemplate, Tools: toolsTemplate, Agents: agentsTemplate, }, } } // Register teaches turbo-core to colour Python. // // 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. func Register() { syntax.Register(syntax.Definition{ Language: Language, // .pyw is Windows' "run me without a console window"; .pyi is a stub // file, which is Python and nothing else. Extensions: []string{".py", ".pyi", ".pyw"}, // A Python script with no extension at all is an ordinary thing to // find in a bin directory, and its first line says what it is. Shebangs: []string{"python", "python3"}, Highlight: Highlight, }) } // ServerDirs returns the directories pylsp 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 Python has more places to // install one than most languages: an environment belonging to this project, a // tool directory belonging to this user, a pyenv shim, and — on macOS — a // per-version directory under the user's Library that is on nobody's PATH by // default. // // Empty entries are skipped by the library, so a machine with no pyenv and no // active environment simply contributes nothing here. func ServerDirs() []string { dirs := []string{VirtualEnvBinDir(), UserBinDir(), PyenvShimDir()} return append(dirs, FrameworkScriptDirs()...) } // VirtualEnvBinDir returns the bin directory of the virtual environment that is // active right now, or "" when none is. // // It comes first because a server installed into the project's own environment // is the most specific answer available, and because it is the one that stops // being true when the user deactivates. func VirtualEnvBinDir() string { env := os.Getenv("VIRTUAL_ENV") if env == "" { return "" } return filepath.Join(env, "bin") } // UserBinDir returns ~/.local/bin, where pipx, `uv tool install` and `pip // install --user` on Linux all put an executable. // // It is the directory the install hint's command writes into, so it is the one // that matters most to somebody who followed the hint and found nothing. func UserBinDir() string { home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".local", "bin") } // PyenvShimDir returns pyenv's shim directory: PYENV_ROOT/shims when PYENV_ROOT // is set, and ~/.pyenv/shims otherwise. // // pyenv works by putting shims on PATH, so this only matters on a machine where // its shell hook was never installed — which is exactly the machine where the // user cannot work out why nothing is found. func PyenvShimDir() string { if root := os.Getenv("PYENV_ROOT"); root != "" { return filepath.Join(root, "shims") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".pyenv", "shims") } // FrameworkScriptDirs returns every ~/Library/Python//bin that exists, // in the order the directory lists them — by name, which is not version order. // // That is where `pip install --user` puts an executable on macOS, and it is on // nobody's PATH by default — so a Mac user who installed the server the obvious // way has it in a directory the shell has never heard of. The version is part // of the path and cannot be predicted, so the directory is read rather than // guessed; on a system with no such directory this returns nothing, which is // what happens on Linux. func FrameworkScriptDirs() []string { home, err := os.UserHomeDir() if err != nil { return nil } versions, err := os.ReadDir(filepath.Join(home, "Library", "Python")) if err != nil { return nil } var dirs []string for _, version := range versions { if !version.IsDir() { continue } dirs = append(dirs, filepath.Join(home, "Library", "Python", version.Name(), "bin")) } return dirs }