| 📦 Turbo Python 6fc62ea k33g 8h ago | 1 | // Package pythonlang is everything about Turbo Python that is about *Python*: |
| 2 | // how the editor names itself, which language server it talks to, what a |
| 3 | // project's starter files say, and how Python source is coloured. |
| 4 | // |
| 5 | // Everything else the editor does lives in turbo-core, which knows nothing |
| 6 | // about Python. This package is the whole of the difference between Turbo |
| 7 | // Python and Turbo Rust, which is what makes a fourth editor a matter of |
| 8 | // writing one of these rather than forking anything. |
| 9 | // |
| 10 | // pythonlang.Register() // teach the library to colour Python |
| 11 | // editor := app.New(screen, name, pythonlang.Profile()) |
| 12 | package pythonlang |
| 13 | |
| 14 | import ( |
| 15 | "os" |
| 16 | "path/filepath" |
| 17 | |
| 18 | "rickub.com/turbo-editors/turbo-core/profile" |
| 19 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 20 | ) |
| 21 | |
| 22 | // Name and Slug are what the editor calls itself. The slug is also its binary, |
| 23 | // its project directory (as .turbo-python) and the stem of its environment |
| 24 | // variables (as TURBO_PYTHON_…), so it is not free to change. |
| 25 | const ( |
| 26 | Name = "Turbo Python" |
| 27 | Slug = "turbo-python" |
| 28 | ) |
| 29 | |
| 30 | // Language is the name Python is known by: the value LanguageOf returns for a |
| 31 | // .py file, and what a snippets file writes in its languages key. |
| 32 | const Language syntax.Language = "python" |
| 33 | |
| 34 | // ServerCommand is the language server Turbo Python talks to, and InstallHint |
| 35 | // the single command that installs it. |
| 36 | // |
| 37 | // python-lsp-server answers seven of the nine questions turbo-core asks — |
| 38 | // completion, hover, definition, type definition, references and the file's |
| 39 | // symbols — and publishes diagnostics unasked. It advertises neither |
| 40 | // implementations nor a project-wide symbol search, so those two items report |
| 41 | // nothing found; that is documented rather than worked around. |
| 42 | // |
| 43 | // **The [all] is not optional.** Installed bare, pylsp starts, completes and |
| 44 | // jumps, and publishes an *empty* list of diagnostics for a file that does not |
| 45 | // parse — because the linters that produce them are extras, and without them |
| 46 | // the server has nothing to say. An editor whose gutter stays blank because |
| 47 | // the server has no linter looks exactly like one whose gutter is blank |
| 48 | // because the code is fine, which is why the hint installs them. |
| 49 | // |
| 50 | // pipx is named rather than pip because the server is a tool rather than a |
| 51 | // dependency of the project being edited, and installing it into that |
| 52 | // project's environment is how it ends up missing from the next one. |
| 53 | const ( |
| 54 | ServerCommand = "pylsp" |
| 55 | InstallHint = `pipx install "python-lsp-server[all]"` |
| 56 | ) |
| 57 | |
| 58 | // Profile returns the editor Turbo Python is. |
| 59 | // |
| 60 | // It is a function rather than a variable because Server.Dirs is worked out |
| 61 | // from the environment, and a variable would freeze whatever VIRTUAL_ENV said |
| 62 | // when the package was linked — which for a Python tool is the one value most |
| 63 | // likely to change between two runs in the same shell. |
| 64 | func Profile() profile.Profile { |
| 65 | return profile.Profile{ |
| 66 | Name: Name, |
| 67 | Slug: Slug, |
| 68 | Language: "Python", |
| 69 | // P is free: the fixed menus take F, E, S, R, C, O, W, N and H, so the |
| 70 | // hot key lands on the first letter of the word, which is the reading |
| 71 | // that costs nobody a second glance. The menu is named after the |
| 72 | // language and not after uv, because it holds whatever the project put |
| 73 | // in its tools file — and the first tools file anybody writes outgrows |
| 74 | // the language's own toolchain. |
| 75 | ToolsMenu: "~P~ython", |
| 76 | // pyproject.toml first because it is where a modern project declares |
| 77 | // itself, then the two forms a setuptools project used before it |
| 78 | // existed. The nearest one going up is the directory the server is |
| 79 | // started in. |
| 80 | RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg"}, |
| 81 | Server: profile.Server{ |
| 82 | Command: ServerCommand, |
| 83 | // pylsp takes no subcommand, unlike gopls. |
| 84 | Args: nil, |
| 85 | InstallHint: InstallHint, |
| 86 | Dirs: ServerDirs(), |
| 87 | }, |
| 88 | Templates: profile.Templates{ |
| 89 | Settings: settingsTemplate, |
| 90 | Snippets: snippetsTemplate, |
| 91 | Tools: toolsTemplate, |
| 92 | Agents: agentsTemplate, |
| 93 | }, |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // Register teaches turbo-core to colour Python. |
| 98 | // |
| 99 | // It is called explicitly at start-up rather than from an init function so that |
| 100 | // "which languages does this editor know?" is answered by reading main, not by |
| 101 | // working out which packages were imported. |
| 102 | func Register() { |
| 103 | syntax.Register(syntax.Definition{ |
| 104 | Language: Language, |
| 105 | // .pyw is Windows' "run me without a console window"; .pyi is a stub |
| 106 | // file, which is Python and nothing else. |
| 107 | Extensions: []string{".py", ".pyi", ".pyw"}, |
| 108 | // A Python script with no extension at all is an ordinary thing to |
| 109 | // find in a bin directory, and its first line says what it is. |
| 110 | Shebangs: []string{"python", "python3"}, |
| 111 | Highlight: Highlight, |
| 112 | }) |
| 113 | } |
| 114 | |
| 115 | // ServerDirs returns the directories pylsp is looked for in after PATH, most |
| 116 | // specific first. |
| 117 | // |
| 118 | // "Completion silently does nothing" is what a user sees when the editor cannot |
| 119 | // find a server they believe they installed, and Python has more places to |
| 120 | // install one than most languages: an environment belonging to this project, a |
| 121 | // tool directory belonging to this user, a pyenv shim, and — on macOS — a |
| 122 | // per-version directory under the user's Library that is on nobody's PATH by |
| 123 | // default. |
| 124 | // |
| 125 | // Empty entries are skipped by the library, so a machine with no pyenv and no |
| 126 | // active environment simply contributes nothing here. |
| 127 | func ServerDirs() []string { |
| 128 | dirs := []string{VirtualEnvBinDir(), UserBinDir(), PyenvShimDir()} |
| 129 | return append(dirs, FrameworkScriptDirs()...) |
| 130 | } |
| 131 | |
| 132 | // VirtualEnvBinDir returns the bin directory of the virtual environment that is |
| 133 | // active right now, or "" when none is. |
| 134 | // |
| 135 | // It comes first because a server installed into the project's own environment |
| 136 | // is the most specific answer available, and because it is the one that stops |
| 137 | // being true when the user deactivates. |
| 138 | func VirtualEnvBinDir() string { |
| 139 | env := os.Getenv("VIRTUAL_ENV") |
| 140 | if env == "" { |
| 141 | return "" |
| 142 | } |
| 143 | return filepath.Join(env, "bin") |
| 144 | } |
| 145 | |
| 146 | // UserBinDir returns ~/.local/bin, where pipx, `uv tool install` and `pip |
| 147 | // install --user` on Linux all put an executable. |
| 148 | // |
| 149 | // It is the directory the install hint's command writes into, so it is the one |
| 150 | // that matters most to somebody who followed the hint and found nothing. |
| 151 | func UserBinDir() string { |
| 152 | home, err := os.UserHomeDir() |
| 153 | if err != nil { |
| 154 | return "" |
| 155 | } |
| 156 | return filepath.Join(home, ".local", "bin") |
| 157 | } |
| 158 | |
| 159 | // PyenvShimDir returns pyenv's shim directory: PYENV_ROOT/shims when PYENV_ROOT |
| 160 | // is set, and ~/.pyenv/shims otherwise. |
| 161 | // |
| 162 | // pyenv works by putting shims on PATH, so this only matters on a machine where |
| 163 | // its shell hook was never installed — which is exactly the machine where the |
| 164 | // user cannot work out why nothing is found. |
| 165 | func PyenvShimDir() string { |
| 166 | if root := os.Getenv("PYENV_ROOT"); root != "" { |
| 167 | return filepath.Join(root, "shims") |
| 168 | } |
| 169 | home, err := os.UserHomeDir() |
| 170 | if err != nil { |
| 171 | return "" |
| 172 | } |
| 173 | return filepath.Join(home, ".pyenv", "shims") |
| 174 | } |
| 175 | |
| 176 | // FrameworkScriptDirs returns every ~/Library/Python/<version>/bin that exists, |
| 177 | // in the order the directory lists them — by name, which is not version order. |
| 178 | // |
| 179 | // That is where `pip install --user` puts an executable on macOS, and it is on |
| 180 | // nobody's PATH by default — so a Mac user who installed the server the obvious |
| 181 | // way has it in a directory the shell has never heard of. The version is part |
| 182 | // of the path and cannot be predicted, so the directory is read rather than |
| 183 | // guessed; on a system with no such directory this returns nothing, which is |
| 184 | // what happens on Linux. |
| 185 | func FrameworkScriptDirs() []string { |
| 186 | home, err := os.UserHomeDir() |
| 187 | if err != nil { |
| 188 | return nil |
| 189 | } |
| 190 | |
| 191 | versions, err := os.ReadDir(filepath.Join(home, "Library", "Python")) |
| 192 | if err != nil { |
| 193 | return nil |
| 194 | } |
| 195 | |
| 196 | var dirs []string |
| 197 | for _, version := range versions { |
| 198 | if !version.IsDir() { |
| 199 | continue |
| 200 | } |
| 201 | dirs = append(dirs, filepath.Join(home, "Library", "Python", version.Name(), "bin")) |
| 202 | } |
| 203 | return dirs |
| 204 | } |