| 📦 Turbo Python 6fc62ea k33g 8h ago | 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # Build turbo-python and install it where your shell can find it. |
| 4 | # |
| 5 | # scripts/install.sh # install into GOBIN, or GOPATH/bin |
| 6 | # scripts/install.sh --prefix ~/bin # install somewhere else |
| 7 | # scripts/install.sh --with-analyzer # install the language server too |
| 8 | # scripts/install.sh --uninstall # remove it again |
| 9 | # |
| 10 | # The build goes to a temporary file first, so a failed build never replaces a |
| 11 | # working installation, and the install itself is a rename rather than a write |
| 12 | # over the binary that is already there. The version is stamped in by the |
| 13 | # linker, so `turbo-python -version` names the commit it was built from. |
| 14 | |
| 15 | set -euo pipefail |
| 16 | |
| 17 | readonly BINARY=turbo-python |
| 18 | |
| 19 | # --- output ----------------------------------------------------------------- |
| 20 | |
| 21 | if [ -t 1 ]; then |
| 22 | readonly BOLD=$'\033[1m' DIM=$'\033[2m' RED=$'\033[31m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RESET=$'\033[0m' |
| 23 | else |
| 24 | readonly BOLD='' DIM='' RED='' GREEN='' YELLOW='' RESET='' |
| 25 | fi |
| 26 | |
| 27 | info() { printf '%s\n' "$*"; } |
| 28 | step() { printf '%s==>%s %s\n' "$BOLD" "$RESET" "$*"; } |
| 29 | ok() { printf '%s ✓%s %s\n' "$GREEN" "$RESET" "$*"; } |
| 30 | warn() { printf '%s !%s %s\n' "$YELLOW" "$RESET" "$*"; } |
| 31 | die() { |
| 32 | printf '%s ✗%s %s\n' "$RED" "$RESET" "$*" >&2 |
| 33 | exit 1 |
| 34 | } |
| 35 | |
| 36 | usage() { |
| 37 | cat <<EOF |
| 38 | ${BOLD}$BINARY installer${RESET} |
| 39 | |
| 40 | scripts/install.sh [options] |
| 41 | |
| 42 | Options: |
| 43 | -p, --prefix DIR install into DIR (default: GOBIN, or GOPATH/bin) |
| 44 | --with-server also install pylsp, which completion needs |
| 45 | --uninstall remove an installed $BINARY and stop |
| 46 | -h, --help show this and stop |
| 47 | EOF |
| 48 | } |
| 49 | |
| 50 | # --- arguments -------------------------------------------------------------- |
| 51 | |
| 52 | prefix="" |
| 53 | with_server=false |
| 54 | uninstall=false |
| 55 | |
| 56 | while [ $# -gt 0 ]; do |
| 57 | case "$1" in |
| 58 | -p | --prefix) |
| 59 | [ $# -ge 2 ] || die "--prefix needs a directory" |
| 60 | prefix="$2" |
| 61 | shift 2 |
| 62 | ;; |
| 63 | --with-server) |
| 64 | with_server=true |
| 65 | shift |
| 66 | ;; |
| 67 | --uninstall) |
| 68 | uninstall=true |
| 69 | shift |
| 70 | ;; |
| 71 | -h | --help) |
| 72 | usage |
| 73 | exit 0 |
| 74 | ;; |
| 75 | *) die "unknown option: $1 (try --help)" ;; |
| 76 | esac |
| 77 | done |
| 78 | |
| 79 | # --- where things are ------------------------------------------------------- |
| 80 | |
| 81 | cd "$(dirname "${BASH_SOURCE[0]}")/.." |
| 82 | readonly REPO="$PWD" |
| 83 | |
| 84 | command -v go >/dev/null 2>&1 || die "go is not installed: https://go.dev/dl/" |
| 85 | |
| 86 | # default_prefix returns where "go install" would put a binary: GOBIN when it |
| 87 | # is set, GOPATH/bin otherwise. That is the directory a Go developer is most |
| 88 | # likely to already have on PATH. |
| 89 | default_prefix() { |
| 90 | local gobin |
| 91 | gobin="$(go env GOBIN)" |
| 92 | if [ -n "$gobin" ]; then |
| 93 | printf '%s\n' "$gobin" |
| 94 | else |
| 95 | printf '%s/bin\n' "$(go env GOPATH)" |
| 96 | fi |
| 97 | } |
| 98 | |
| 99 | [ -n "$prefix" ] || prefix="$(default_prefix)" |
| 100 | [ -n "$prefix" ] || die "cannot work out where to install; use --prefix DIR" |
| 101 | readonly TARGET="$prefix/$BINARY" |
| 102 | |
| 103 | # --- uninstall -------------------------------------------------------------- |
| 104 | |
| 105 | if $uninstall; then |
| 106 | step "Removing $TARGET" |
| 107 | if [ -e "$TARGET" ]; then |
| 108 | rm -f "$TARGET" |
| 109 | ok "removed" |
| 110 | else |
| 111 | warn "nothing installed at $TARGET" |
| 112 | fi |
| 113 | exit 0 |
| 114 | fi |
| 115 | |
| 116 | # --- toolchain -------------------------------------------------------------- |
| 117 | |
| 118 | step "Checking the Go toolchain" |
| 119 | |
| 120 | # The requirement lives in go.mod, so this check cannot drift from the build. |
| 121 | required="$(awk '/^go /{print $2; exit}' "$REPO/go.mod")" |
| 122 | installed="$(go env GOVERSION)" |
| 123 | installed="${installed#go}" |
| 124 | |
| 125 | if [ "$(printf '%s\n%s\n' "$required" "$installed" | sort -V | head -1)" != "$required" ]; then |
| 126 | die "Go $required or later is needed, but $installed is installed" |
| 127 | fi |
| 128 | ok "go $installed (go.mod asks for $required or later)" |
| 129 | |
| 130 | # --- build ------------------------------------------------------------------ |
| 131 | |
| 132 | step "Building $BINARY" |
| 133 | |
| 134 | readonly STAGING="$(mktemp -d)" |
| 135 | trap 'rm -rf "$STAGING"' EXIT |
| 136 | |
| 137 | # The version the binary reports is stamped in by the linker, so that an |
| 138 | # installed editor names the commit it was actually built from rather than a |
| 139 | # constant somebody forgot to bump. Outside a git checkout — installed from a |
| 140 | # tarball, say — there is nothing to describe and the binary works the version |
| 141 | # out from its own build information instead. |
| 142 | version_pkg="rickub.com/turbo-editors/turbo-core/version" |
| 143 | if describe="$(git -C "$REPO" describe --tags --dirty 2>/dev/null)"; then |
| 144 | commit="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || true)" |
| 145 | built="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 146 | ldflags="-X '$version_pkg.stamp=$describe' -X '$version_pkg.commit=$commit' -X '$version_pkg.built=$built'" |
| 147 | else |
| 148 | ldflags="" |
| 149 | fi |
| 150 | |
| 151 | if ! go build -ldflags "$ldflags" -o "$STAGING/$BINARY" "$REPO" 2>"$STAGING/build.log"; then |
| 152 | cat "$STAGING/build.log" >&2 |
| 153 | info "" |
| 154 | warn "A failure in the repository root is often a stray .go file that has" |
| 155 | warn "landed in package main. 'go vet .' names it." |
| 156 | die "build failed; nothing was installed" |
| 157 | fi |
| 158 | |
| 159 | # Running the staged binary is the only proof that the flags above reached the |
| 160 | # linker rather than merely looking right. It happens before the install, so a |
| 161 | # binary that cannot name its own version never replaces a working one. |
| 162 | if ! stamped="$("$REPO/scripts/check-version.sh" "$STAGING/$BINARY" "${describe:-}" "${commit:-}" 2>&1)"; then |
| 163 | info "$stamped" |
| 164 | die "the build did not carry its version; nothing was installed" |
| 165 | fi |
| 166 | ok "built — $stamped" |
| 167 | |
| 168 | # --- install ---------------------------------------------------------------- |
| 169 | |
| 170 | step "Installing into $prefix" |
| 171 | |
| 172 | mkdir -p "$prefix" || die "cannot create $prefix" |
| 173 | |
| 174 | # Install by renaming a complete file over the target, never by writing into |
| 175 | # the one that is there. |
| 176 | # |
| 177 | # macOS caches a binary's code signature against its inode. cp writes new bytes |
| 178 | # into the existing inode, so the cached signature ends up describing something |
| 179 | # else and the kernel refuses to execute the result — a reinstall that builds, |
| 180 | # installs, and then will not run. A rename gives the name a fresh inode, so |
| 181 | # there is nothing stale to cache. It is atomic besides: no moment at which a |
| 182 | # half-written turbo-python is on the PATH. |
| 183 | # |
| 184 | # The temporary has to sit in $prefix, because a rename only works within one |
| 185 | # filesystem and $STAGING is somewhere else entirely. |
| 186 | readonly INCOMING="$prefix/.$BINARY.incoming.$$" |
| 187 | trap 'rm -rf "$STAGING"; rm -f "$INCOMING"' EXIT |
| 188 | |
| 189 | cp "$STAGING/$BINARY" "$INCOMING" || die "cannot write into $prefix" |
| 190 | chmod 0755 "$INCOMING" |
| 191 | mv -f "$INCOMING" "$TARGET" || die "cannot replace $TARGET" |
| 192 | |
| 193 | # Whatever the system said is the useful part: "does not run" on its own tells |
| 194 | # nobody anything they can act on. |
| 195 | if ! verify="$("$TARGET" -version 2>&1)"; then |
| 196 | info "$verify" |
| 197 | die "the installed binary does not run" |
| 198 | fi |
| 199 | version="$verify" |
| 200 | ok "$version → $TARGET" |
| 201 | |
| 202 | # --- PATH ------------------------------------------------------------------- |
| 203 | |
| 204 | # on_path reports whether a directory is one the shell searches. |
| 205 | on_path() { |
| 206 | case ":${PATH:-}:" in |
| 207 | *":$1:"*) return 0 ;; |
| 208 | *) return 1 ;; |
| 209 | esac |
| 210 | } |
| 211 | |
| 212 | # shell_profile guesses the file that sets PATH for the user's shell. |
| 213 | shell_profile() { |
| 214 | case "${SHELL:-}" in |
| 215 | */zsh) printf '~/.zshrc\n' ;; |
| 216 | */fish) printf '~/.config/fish/config.fish\n' ;; |
| 217 | *) printf '~/.bashrc\n' ;; |
| 218 | esac |
| 219 | } |
| 220 | |
| 221 | step "Checking your PATH" |
| 222 | if on_path "$prefix"; then |
| 223 | ok "$prefix is on your PATH" |
| 224 | else |
| 225 | warn "$prefix is not on your PATH. Add it:" |
| 226 | info "" |
| 227 | info " echo 'export PATH=\"\$PATH:$prefix\"' >> $(shell_profile)" |
| 228 | info " exec \$SHELL" |
| 229 | fi |
| 230 | |
| 231 | # --- the language server ---------------------------------------------------- |
| 232 | |
| 233 | # find_server looks where the editor itself looks: PATH, then the active |
| 234 | # virtual environment, the user's tool directory, pyenv's shims, and — on macOS |
| 235 | # — the per-version script directory that is on nobody's PATH. |
| 236 | # |
| 237 | # Finding it is not the same as its working: a shim left behind by a tool |
| 238 | # manager whose environment has since been removed sits on PATH and fails only |
| 239 | # when started. So this asks it for its version rather than trusting the file's |
| 240 | # existence, which is the difference between "you have completion" and "you |
| 241 | # will find out you have not when you press Ctrl-Space". |
| 242 | find_server() { |
| 243 | local candidate |
| 244 | for candidate in \ |
| 245 | "$(command -v pylsp 2>/dev/null || true)" \ |
| 246 | "${VIRTUAL_ENV:-/nonexistent}/bin/pylsp" \ |
| 247 | "$HOME/.local/bin/pylsp" \ |
| 248 | "${PYENV_ROOT:-$HOME/.pyenv}/shims/pylsp" \ |
| 249 | "$HOME"/Library/Python/*/bin/pylsp; do |
| 250 | [ -n "$candidate" ] && [ -x "$candidate" ] || continue |
| 251 | "$candidate" --version >/dev/null 2>&1 || continue |
| 252 | printf '%s\n' "$candidate" |
| 253 | return 0 |
| 254 | done |
| 255 | return 1 |
| 256 | } |
| 257 | |
| 258 | # server_has_linters reports whether the server can produce diagnostics at all. |
| 259 | # |
| 260 | # This is the trap that costs an afternoon. Installed without its extras, pylsp |
| 261 | # starts, completes and jumps to definitions — and publishes an *empty* list of |
| 262 | # problems for a file that does not even parse, because the linters that find |
| 263 | # them are optional dependencies. A blank gutter because the server has no |
| 264 | # linter and a blank gutter because the code is fine look identical. |
| 265 | # |
| 266 | # pylsp is a script whose first line names the interpreter it runs under, and |
| 267 | # the linters live in that interpreter's environment. A server this cannot read |
| 268 | # a shebang from returns 2, and nothing is claimed either way. |
| 269 | server_has_linters() { |
| 270 | local interpreter |
| 271 | interpreter="$(sed -n '1s/^#!\([^ ]*\).*/\1/p' "$1" 2>/dev/null || true)" |
| 272 | [ -n "$interpreter" ] && [ -x "$interpreter" ] || return 2 |
| 273 | "$interpreter" -c 'import pyflakes' >/dev/null 2>&1 |
| 274 | } |
| 275 | |
| 276 | # install_server runs the one command the editor's install hint names. |
| 277 | install_server() { |
| 278 | if command -v pipx >/dev/null 2>&1; then |
| 279 | pipx install "python-lsp-server[all]" || die "pipx could not install python-lsp-server" |
| 280 | elif command -v uv >/dev/null 2>&1; then |
| 281 | uv tool install "python-lsp-server[all]" || die "uv could not install python-lsp-server" |
| 282 | else |
| 283 | die "neither pipx nor uv is installed; see https://pipx.pypa.io" |
| 284 | fi |
| 285 | } |
| 286 | |
| 287 | step "Checking the language server" |
| 288 | |
| 289 | if $with_server && ! find_server >/dev/null; then |
| 290 | info " installing python-lsp-server…" |
| 291 | install_server |
| 292 | fi |
| 293 | |
| 294 | if server_path="$(find_server)"; then |
| 295 | ok "pylsp at $server_path" |
| 296 | |
| 297 | server_has_linters "$server_path" |
| 298 | case $? in |
| 299 | 0) ;; |
| 300 | 1) |
| 301 | warn "…but it has no linters, so there will be no error marks in the gutter." |
| 302 | warn "Completion and Go to definition work; problems will never appear." |
| 303 | info "" |
| 304 | info " pipx install --force \"python-lsp-server[all]\"" |
| 305 | ;; |
| 306 | esac |
| 307 | else |
| 308 | warn "pylsp is not installed, so there will be no completion." |
| 309 | warn "Editing, colouring and themes all work without it." |
| 310 | info "" |
| 311 | info " pipx install \"python-lsp-server[all]\"" |
| 312 | info " ${DIM}or re-run this script with --with-server${RESET}" |
| 313 | fi |
| 314 | |
| 315 | # --- what to do next -------------------------------------------------------- |
| 316 | |
| 317 | info "" |
| 318 | step "Ready" |
| 319 | info "" |
| 320 | info " Open a file ${BOLD}inside a Go module${RESET} — completion needs one:" |
| 321 | info "" |
| 322 | info " cd /path/to/your/project" |
| 323 | info " $BINARY main.go" |
| 324 | info "" |
| 325 | info " ${DIM}F10 menu · F2 save · Alt-X exit · type a '.' for completion${RESET}" |
| 326 | info " ${DIM}themes: $BINARY -list-themes${RESET}" |
| 327 | info "" |