diff --git a/README.md b/README.md index 348482a..0d25fed 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ underneath for automation. ./dots restore # restore a snapshot (creates a safety snapshot first) ./dots profile # create / preview / switch profiles ./dots models # local model plan & pull +./dots lsp # language-server install / status / checks ./dots check # health verification ./dots update # git pull + optional reapply ./dots --version @@ -166,6 +167,7 @@ the profile / `--with` for that run. ./setup.sh --with skills,ai-skills ./setup.sh --with ai # platform-aware AI applications supergroup ./setup.sh --with mactools # Darwin workstation layer +./setup.sh --with lsp # local language-server toolchain ./setup.sh --dry-run --with cursor ``` @@ -180,6 +182,27 @@ the profile / `--with` for that run. | `fluidvoice` | Voice dictation (macOS 15+) | | `images` / `tex` | Media / TeX toolkits | | `mactools` | macOS workstation casks | +| `lsp` | PATH-visible language servers for Neovim and other clients | + +## Language servers + +```bash +./dots lsp +./dots lsp status +./dots lsp install --yes +./dots lsp check +./dots setup --with lsp +./setup.sh --with lsp +./setup.sh --with ai +``` + +The `lsp` component covers Rust, Python, Go, Bash, Markdown, TOML, +Terraform/HCL, Dockerfile, Docker Compose, R, YAML, JSON, and Lua. The `ai` +supergroup includes this local code-intelligence layer. Selecting `lsp` alone +does **not** authorize or configure Hermes, Codex, Cursor, OpenCode, Ollama, or +any cloud provider. DOTS owns PATH-visible binaries; Neovim configures clients +without downloading duplicate Mason copies. See the +[language-server guide](docs/using/language-servers.md). Full subsystem detail lives under `docs/` — not in this README. diff --git a/brew/Brewfile.lsp b/brew/Brewfile.lsp new file mode 100644 index 0000000..92eee61 --- /dev/null +++ b/brew/Brewfile.lsp @@ -0,0 +1,16 @@ +# DOTS first-class language-server toolchain. +# Binary/provider details: configs/lsp/servers.toml +brew "rust-analyzer" +brew "basedpyright" +brew "gopls" +brew "bash-language-server" +brew "yaml-language-server" +brew "vscode-langservers-extracted" +brew "lua-language-server" +brew "markdown-oxide" +brew "taplo" +brew "terraform-ls" +brew "dockerfile-language-server" + +# R's language server is a CRAN package; helpers/lsp.sh installs it idempotently. +brew "r" diff --git a/configs/components.toml b/configs/components.toml index 8853d0a..42e7ae2 100644 --- a/configs/components.toml +++ b/configs/components.toml @@ -161,12 +161,22 @@ platforms = ["darwin"] brewfile = "brew/Brewfile.mactools" description = "Optional macOS workstation layer (Vorssaint + automation/audio/CLI; Brewfile.mactools)" +[[components]] +id = "lsp" +label = "Language servers" +category = "developer_tooling" +cloud = false +local_ai = false +platforms = ["darwin", "linux"] +brewfile = "brew/Brewfile.lsp" +description = "Language servers for DOTS development/editor workflows" + # Declarative selector aggregates. Expand to member component ids before install. # Nested groups are not supported in v1.0.1. [[supergroups]] id = "ai" label = "AI applications" -description = "All supported AI applications for this platform" +description = "Local runtimes, coding agents, and the LSP toolchain for this platform" members = [ "hermes", "ollama", @@ -176,4 +186,5 @@ members = [ "codex", "cursor", "fluidvoice", + "lsp", ] diff --git a/configs/lsp/servers.toml b/configs/lsp/servers.toml new file mode 100644 index 0000000..6099284 --- /dev/null +++ b/configs/lsp/servers.toml @@ -0,0 +1,273 @@ +# configs/lsp/servers.toml — authoritative DOTS language-server registry +# +# Binary ownership lives here. Homebrew realizes `brew` entries, while +# helpers/lsp.sh selects apt or the declared trusted fallback on Linux. +# Neovim client configuration is generated from `nvim` + `nvim_config`. + +version = 1 + +[[servers]] +id = "rust-analyzer" +label = "Rust" +domains = ["Rust"] +filetypes = ["rust"] +extensions = [".rs"] +binary = "rust-analyzer" +platforms = ["darwin", "linux"] +brew = "rust-analyzer" +apt = "rust-analyzer" +fallback = "github_gzip" +fallback_repo = "rust-lang/rust-analyzer" +fallback_asset_amd64 = "rust-analyzer-x86_64-unknown-linux-gnu.gz" +fallback_asset_arm64 = "rust-analyzer-aarch64-unknown-linux-gnu.gz" +required = true +nvim = "rust_analyzer" +health = "executable" +version_args = ["--version"] + +[servers.nvim_config] +filetypes = ["rust"] + +[[servers]] +id = "basedpyright" +label = "Python" +domains = ["Python"] +filetypes = ["python"] +extensions = [".py"] +binary = "basedpyright-langserver" +platforms = ["darwin", "linux"] +brew = "basedpyright" +apt = "" +fallback = "python_venv" +fallback_package = "basedpyright" +required = true +nvim = "basedpyright" +health = "executable" +version_args = [] + +[servers.nvim_config] +filetypes = ["python"] + +[[servers]] +id = "gopls" +label = "Go" +domains = ["Go"] +filetypes = ["go", "gomod", "gowork", "gotmpl"] +extensions = [".go", "go.mod", "go.work"] +binary = "gopls" +platforms = ["darwin", "linux"] +brew = "gopls" +apt = "gopls" +fallback = "go" +fallback_package = "golang.org/x/tools/gopls@latest" +required = true +nvim = "gopls" +health = "executable" +version_args = ["version"] + +[servers.nvim_config] +filetypes = ["go", "gomod", "gowork", "gotmpl"] + +[[servers]] +id = "bash-language-server" +label = "Bash" +domains = ["Bash", "shell"] +filetypes = ["sh", "bash", "zsh"] +extensions = [".sh", ".bash", ".zsh"] +binary = "bash-language-server" +platforms = ["darwin", "linux"] +brew = "bash-language-server" +apt = "" +fallback = "npm" +fallback_package = "bash-language-server" +required = true +nvim = "bashls" +health = "executable" +version_args = ["--version"] + +[servers.nvim_config] +cmd = ["bash-language-server", "start"] +filetypes = ["sh", "bash", "zsh"] + +[[servers]] +id = "yaml-language-server" +label = "YAML / Compose" +domains = ["YAML", "Docker Compose", "GitHub Actions", "Kubernetes"] +filetypes = ["yaml", "yaml.docker-compose", "yaml.github-actions"] +extensions = [".yml", ".yaml", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"] +binary = "yaml-language-server" +platforms = ["darwin", "linux"] +brew = "yaml-language-server" +apt = "" +fallback = "npm" +fallback_package = "yaml-language-server" +required = true +nvim = "yamlls" +health = "executable" +version_args = [] + +[servers.nvim_config] +cmd = ["yaml-language-server", "--stdio"] +filetypes = ["yaml", "yaml.docker-compose", "yaml.github-actions"] + +[servers.nvim_config.settings.yaml] +validate = true +completion = true +hover = true +schemaStore = { enable = true } +schemas = { "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json" = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"], "https://json.schemastore.org/github-workflow.json" = [".github/workflows/*.yml", ".github/workflows/*.yaml"], kubernetes = ["k8s/*.yml", "k8s/*.yaml", "kubernetes/*.yml", "kubernetes/*.yaml"] } + +[[servers]] +id = "vscode-json-language-server" +label = "JSON" +domains = ["JSON", "JSONC"] +filetypes = ["json", "jsonc"] +extensions = [".json", ".jsonc"] +binary = "vscode-json-language-server" +platforms = ["darwin", "linux"] +brew = "vscode-langservers-extracted" +apt = "" +fallback = "npm" +fallback_package = "vscode-langservers-extracted" +required = true +nvim = "jsonls" +health = "executable" +version_args = [] + +[servers.nvim_config] +cmd = ["vscode-json-language-server", "--stdio"] +filetypes = ["json", "jsonc"] + +[[servers]] +id = "lua-language-server" +label = "Lua" +domains = ["Lua", "Neovim Lua"] +filetypes = ["lua"] +extensions = [".lua"] +binary = "lua-language-server" +platforms = ["darwin", "linux"] +brew = "lua-language-server" +apt = "" +fallback = "github_tar" +fallback_repo = "LuaLS/lua-language-server" +fallback_asset_amd64 = "lua-language-server-{version}-linux-x64.tar.gz" +fallback_asset_arm64 = "lua-language-server-{version}-linux-arm64.tar.gz" +fallback_binary_path = "bin/lua-language-server" +required = true +nvim = "lua_ls" +health = "executable" +version_args = ["--version"] + +[servers.nvim_config] +filetypes = ["lua"] + +[servers.nvim_config.settings.Lua.diagnostics] +globals = ["vim"] + +[servers.nvim_config.settings.Lua.workspace] +checkThirdParty = false +library = ["__DOTS_VIMRUNTIME__"] + +[[servers]] +id = "markdown-oxide" +label = "Markdown" +domains = ["Markdown", "MDX"] +filetypes = ["markdown", "markdown.mdx"] +extensions = [".md", ".mdx"] +binary = "markdown-oxide" +platforms = ["darwin", "linux"] +brew = "markdown-oxide" +apt = "" +fallback = "github_binary" +fallback_repo = "Feel-ix-343/markdown-oxide" +fallback_asset_amd64 = "markdown-oxide-v{version}-x86_64-unknown-linux-gnu" +fallback_asset_arm64 = "markdown-oxide-v{version}-aarch64-unknown-linux-gnu" +required = true +nvim = "markdown_oxide" +health = "executable" +version_args = ["--version"] + +[servers.nvim_config] +filetypes = ["markdown", "markdown.mdx"] + +[[servers]] +id = "taplo" +label = "TOML" +domains = ["TOML"] +filetypes = ["toml"] +extensions = [".toml", "Cargo.toml", "pyproject.toml"] +binary = "taplo" +platforms = ["darwin", "linux"] +brew = "taplo" +apt = "" +fallback = "npm" +fallback_package = "@taplo/cli" +required = true +nvim = "taplo" +health = "executable" +version_args = ["--version"] + +[servers.nvim_config] +filetypes = ["toml"] + +[[servers]] +id = "terraform-ls" +label = "Terraform / HCL" +domains = ["Terraform", "HCL"] +filetypes = ["terraform", "terraform-vars", "hcl"] +extensions = [".tf", ".tfvars", ".hcl"] +binary = "terraform-ls" +platforms = ["darwin", "linux"] +brew = "terraform-ls" +apt = "" +fallback = "go" +fallback_package = "github.com/hashicorp/terraform-ls@latest" +required = true +nvim = "terraformls" +health = "executable" +version_args = ["version"] + +[servers.nvim_config] +filetypes = ["terraform", "terraform-vars", "hcl"] + +[[servers]] +id = "dockerfile-language-server" +label = "Dockerfile" +domains = ["Dockerfile"] +filetypes = ["dockerfile"] +extensions = ["Dockerfile", "Dockerfile.*"] +binary = "docker-langserver" +platforms = ["darwin", "linux"] +brew = "dockerfile-language-server" +apt = "" +fallback = "npm" +fallback_package = "dockerfile-language-server-nodejs" +required = true +nvim = "dockerls" +health = "executable" +version_args = [] + +[servers.nvim_config] +cmd = ["docker-langserver", "--stdio"] +filetypes = ["dockerfile"] + +[[servers]] +id = "r-languageserver" +label = "R" +domains = ["R", "R Markdown"] +filetypes = ["r", "rmd"] +extensions = [".R", ".Rmd"] +binary = "Rscript" +platforms = ["darwin", "linux"] +brew = "r" +apt = "" +fallback = "cran" +fallback_package = "languageserver" +required = true +nvim = "r_language_server" +health = "r_package" +version_args = [] + +[servers.nvim_config] +cmd = ["R", "--no-echo", "-e", "languageserver::run()"] +filetypes = ["r", "rmd"] diff --git a/configs/nvim/lua/config/lsp_servers.lua b/configs/nvim/lua/config/lsp_servers.lua new file mode 100644 index 0000000..9e5ef24 --- /dev/null +++ b/configs/nvim/lua/config/lsp_servers.lua @@ -0,0 +1,71 @@ +-- Generated by scripts/generate_lsp_nvim.py; do not edit. +-- Authority: configs/lsp/servers.toml +return { + rust_analyzer = { + filetypes = { "rust" }, + }, + basedpyright = { + filetypes = { "python" }, + }, + gopls = { + filetypes = { "go", "gomod", "gowork", "gotmpl" }, + }, + bashls = { + cmd = { "bash-language-server", "start" }, + filetypes = { "sh", "bash", "zsh" }, + }, + yamlls = { + cmd = { "yaml-language-server", "--stdio" }, + filetypes = { "yaml", "yaml.docker-compose", "yaml.github-actions" }, + settings = { + yaml = { + validate = true, + completion = true, + hover = true, + schemaStore = { + enable = true, + }, + schemas = { + ["https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json"] = { "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml" }, + ["https://json.schemastore.org/github-workflow.json"] = { ".github/workflows/*.yml", ".github/workflows/*.yaml" }, + kubernetes = { "k8s/*.yml", "k8s/*.yaml", "kubernetes/*.yml", "kubernetes/*.yaml" }, + }, + }, + }, + }, + jsonls = { + cmd = { "vscode-json-language-server", "--stdio" }, + filetypes = { "json", "jsonc" }, + }, + lua_ls = { + filetypes = { "lua" }, + settings = { + Lua = { + diagnostics = { + globals = { "vim" }, + }, + workspace = { + checkThirdParty = false, + library = { vim.env.VIMRUNTIME }, + }, + }, + }, + }, + markdown_oxide = { + filetypes = { "markdown", "markdown.mdx" }, + }, + taplo = { + filetypes = { "toml" }, + }, + terraformls = { + filetypes = { "terraform", "terraform-vars", "hcl" }, + }, + dockerls = { + cmd = { "docker-langserver", "--stdio" }, + filetypes = { "dockerfile" }, + }, + r_language_server = { + cmd = { "R", "--no-echo", "-e", "languageserver::run()" }, + filetypes = { "r", "rmd" }, + }, +} diff --git a/configs/nvim/lua/plugins/lsp.lua b/configs/nvim/lua/plugins/lsp.lua index 52acf33..3597914 100644 --- a/configs/nvim/lua/plugins/lsp.lua +++ b/configs/nvim/lua/plugins/lsp.lua @@ -4,31 +4,10 @@ return { { "neovim/nvim-lspconfig", - dependencies = { - { "williamboman/mason.nvim", opts = {} }, - { "williamboman/mason-lspconfig.nvim" }, - }, config = function() - local ok_mason, mason_lsp = pcall(require, "mason-lspconfig") - if ok_mason then - mason_lsp.setup({ - ensure_installed = { "lua_ls", "bashls", "pyright" }, - automatic_installation = false, - }) - end - - local servers = { - lua_ls = { - settings = { - Lua = { - diagnostics = { globals = { "vim" } }, - workspace = { checkThirdParty = false }, - }, - }, - }, - bashls = {}, - pyright = {}, - } + -- Generated from configs/lsp/servers.toml. DOTS owns the PATH-visible + -- binaries; Neovim only configures and attaches clients. + local servers = require("config.lsp_servers") for name, opts in pairs(servers) do vim.lsp.config(name, opts) diff --git a/docs/Brewfile.lsp b/docs/Brewfile.lsp new file mode 100644 index 0000000..091f259 --- /dev/null +++ b/docs/Brewfile.lsp @@ -0,0 +1,15 @@ +# LSP (Language Server Protocol) tools for IDE support +# Use ./setup.sh --with lsp +# +# Provides language servers for common filetypes: +# bash-language-server → .sh, docker-compose.yml +# yaml-language-server → .yml, .yaml, .toml (for some LSP clients) +# json-language-server → .json +# lua-language-server → .lua +# markdown-mathjax-lsp → .md with MathJax support + +brew "bash-language-server" +brew "rusty.vim" # rust-analyzer + language server +brew "yamllint" # YAML schema support via yamlschema +brew "jsonlint" # JSON validation (schema support) +brew "deno" # Lua/JavaScript LSP via deno diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 37a961e..58aff34 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -47,6 +47,7 @@ supergroups expand to component ids (e.g. ai) managed links repo files → home paths skills packs / standalone via skills CLI models local model registry + pull policy +language servers local code-intelligence binaries + editor clients runtime.env generated non-secret runtime local.sh host overrides (never overwritten) ``` @@ -66,6 +67,7 @@ local.sh host overrides (never overwritten) | managed file links | `configs/links.toml` | | skill packs | `configs/skills/manifest.toml` | | local model policy | `configs/models.toml` | +| language-server inventory / providers / filetypes | `configs/lsp/servers.toml` | | built-in profiles | `configs/bootstrap/profiles/*.toml` | | agent routing defaults | `configs/agents/router.toml` (+ `skills/agent-router/SKILL.md`) | | supported `--with` ids | loaded from `configs/components.toml` (not edited in `setup.sh`) | @@ -73,6 +75,21 @@ local.sh host overrides (never overwritten) Do **not** invent a second registry. Prefer extending these files over new hard-coded `case` maps. +## Language-server ownership + +```text +configs/lsp/servers.toml + → authoritative server, filetype, provider, health, and Neovim-id registry + → brew/Brewfile.lsp realizes Homebrew formulae + → helpers/lsp.sh selects native Linux packages or trusted fallbacks + → configs/nvim/lua/config/lsp_servers.lua is generated client configuration + → ./dots lsp is the human-facing orchestration and status surface +``` + +DOTS owns PATH-visible server binaries. Neovim consumes those binaries and does +not use Mason to install private duplicate copies. Other LSP clients can use the +same executables. + ## `brew/Brewfile` role `brew/Brewfile` is a **convenience / backward-compatible aggregate** of workstation diff --git a/docs/reference/generated/cli-help.md b/docs/reference/generated/cli-help.md index 4051a5c..b3e0f96 100644 --- a/docs/reference/generated/cli-help.md +++ b/docs/reference/generated/cli-help.md @@ -20,6 +20,7 @@ Usage: ./dots components optional components / --with selectors ./dots components list registry + host compatibility ./dots components active last-selected components (informational) + ./dots lsp language-server install / status / checks ./dots packages package ownership / drift ./dots packages groups capability groups (read-only) ./dots packages group ID inspect one package group @@ -87,8 +88,9 @@ Selectors (--with / --without): images Deterministic image toolkit (Magick, etc.) tex Homebrew TeX Live (CLI) mactools Optional macOS workstation layer (Vorssaint + automation/audio/CLI; Brewfile.mactools) + lsp Language servers for DOTS development/editor workflows Supergroups: - ai AI applications — All supported AI applications for this platform + ai AI applications — Local runtimes, coding agents, and the LSP toolchain for this platform List: ./dots components list Happy paths: @@ -139,8 +141,9 @@ Options: images Deterministic image toolkit (Magick, etc.) tex Homebrew TeX Live (CLI) mactools Optional macOS workstation layer (Vorssaint + automation/audio/CLI; Brewfile.mactools) + lsp Language servers for DOTS development/editor workflows Supergroups: - ai AI applications — All supported AI applications for this platform + ai AI applications — Local runtimes, coding agents, and the LSP toolchain for this platform List: ./dots components list --with= Same as --with --without Exclude selectors after expansion (profile without / CLI) @@ -177,6 +180,7 @@ Examples: ./setup.sh --with cursor,drawthings ./setup.sh --with skills ./setup.sh --with images,tex + ./setup.sh --with lsp ./setup.sh --dry-run --with ai ./bootstrap.sh --profile home ./bootstrap.sh --profile work --with ai diff --git a/docs/reference/generated/components.md b/docs/reference/generated/components.md index 9da53b1..69776aa 100644 --- a/docs/reference/generated/components.md +++ b/docs/reference/generated/components.md @@ -25,3 +25,4 @@ Authoritative registry: `configs/components.toml`. | images | Images toolkit | media | darwin, linux | brew/Brewfile.images | no | Deterministic image toolkit (Magick, etc.) | | tex | TeX | docs | darwin, linux | brew/Brewfile.tex | no | Homebrew TeX Live (CLI) | | mactools | macOS tools | workstation | darwin | brew/Brewfile.mactools | yes | Optional macOS workstation layer (Vorssaint + automation/audio/CLI; Brewfile.mactools) | +| lsp | Language servers | developer_tooling | darwin, linux | brew/Brewfile.lsp | no | Language servers for DOTS development/editor workflows | diff --git a/docs/reference/generated/supergroups.md b/docs/reference/generated/supergroups.md index 5a2d9d3..c21efab 100644 --- a/docs/reference/generated/supergroups.md +++ b/docs/reference/generated/supergroups.md @@ -10,4 +10,4 @@ Supergroups expand to member component ids before install. | id | label | members | description | | --- | --- | --- | --- | -| ai | AI applications | hermes, ollama, llamacpp, drawthings, opencode, codex, cursor, fluidvoice | All supported AI applications for this platform | +| ai | AI applications | hermes, ollama, llamacpp, drawthings, opencode, codex, cursor, fluidvoice, lsp | Local runtimes, coding agents, and the LSP toolchain for this platform | diff --git a/docs/reference/generated/with-options.md b/docs/reference/generated/with-options.md index 6dc3bb6..ee811b0 100644 --- a/docs/reference/generated/with-options.md +++ b/docs/reference/generated/with-options.md @@ -12,7 +12,7 @@ Registry-driven reference for optional components and supergroups. Do not invent | id | label | members | description | | --- | --- | --- | --- | -| `ai` | AI applications | `hermes`, `ollama`, `llamacpp`, `drawthings`, `opencode`, `codex`, `cursor`, `fluidvoice` | All supported AI applications for this platform | +| `ai` | AI applications | `hermes`, `ollama`, `llamacpp`, `drawthings`, `opencode`, `codex`, `cursor`, `fluidvoice`, `lsp` | Local runtimes, coding agents, and the LSP toolchain for this platform | ## Components @@ -186,3 +186,14 @@ Optional macOS workstation layer (Vorssaint + automation/audio/CLI; Brewfile.mac - **casks:** `vorssaint`, `raycast`, `keyboard-maestro`, `hazel`, `little-snitch`, `orbstack`, `hookmark`, `devonthink`, `ghostty`, `zed`, `loopback`, `audio-hijack`, `blackhole-2ch`, `vcv-rack`, `obs`, `touchdesigner`, `processing`, `sonic-pi`, `supercollider`, `qgis`, `grandperspective`, `midi-monitor` - **activate:** `./dots setup --with mactools` +### `lsp` + +Language servers for DOTS development/editor workflows + +- **label:** Language servers +- **category:** developer_tooling +- **platforms:** darwin, linux +- **brewfile:** `brew/Brewfile.lsp` +- **formulae:** `rust-analyzer`, `basedpyright`, `gopls`, `bash-language-server`, `yaml-language-server`, `vscode-langservers-extracted`, `lua-language-server`, `markdown-oxide`, `taplo`, `terraform-ls`, `dockerfile-language-server`, `r` +- **activate:** `./dots setup --with lsp` + diff --git a/docs/using/language-servers.md b/docs/using/language-servers.md new file mode 100644 index 0000000..9191987 --- /dev/null +++ b/docs/using/language-servers.md @@ -0,0 +1,97 @@ +# Language servers + +DOTS provides a first-class local `lsp` component for code intelligence. It is +managed through the primary interface: + +```bash +./dots lsp +./dots lsp status +./dots lsp install --yes +./dots lsp check +``` + +The direct automation interfaces are equivalent: + +```bash +./dots setup --with lsp +./setup.sh --with lsp +./bootstrap.sh --profile base --with lsp +./setup.sh --with ai +``` + +`ai` includes the LSP toolchain so it produces a complete AI-assisted local +development environment. Selecting `lsp` alone installs and configures no AI or +cloud provider. Supergroup membership is not provider consent. + +## Coverage + +| Language / filetype | Server | Neovim id | +|---|---|---| +| Rust | rust-analyzer | `rust_analyzer` | +| Python | basedpyright | `basedpyright` | +| Go / modules / workspaces | gopls | `gopls` | +| Bash-compatible shell | bash-language-server | `bashls` | +| Markdown / MDX | markdown-oxide | `markdown_oxide` | +| TOML | Taplo | `taplo` | +| Terraform / HCL | terraform-ls | `terraformls` | +| Dockerfile | dockerfile-language-server | `dockerls` | +| Docker Compose | yaml-language-server + Compose schema | `yamlls` | +| R / R Markdown | CRAN languageserver | `r_language_server` | +| YAML | yaml-language-server | `yamlls` | +| JSON / JSONC | vscode-json-language-server | `jsonls` | +| Lua / Neovim Lua | lua-language-server | `lua_ls` | + +`markdown-mathjax-lsp` was investigated for this release but no current, +maintained installable package/project under that name could be verified. DOTS +therefore does not invent an installer for it. `markdown-oxide` supplies general +links, references, headings, and workspace navigation; MathJax-specific LSP +support remains an explicit provider gap. + +## Provider matrix + +DOTS first accepts an already-working server binary, then uses Homebrew on +Homebrew systems or apt where the default Ubuntu/Debian repository actually has +the server. Only then does it use the declared official ecosystem/release +fallback. + +| Server | Homebrew | Ubuntu 24.04 apt | Debian 13 apt | Trusted fallback | +|---|---|---|---|---| +| rust-analyzer | `rust-analyzer` | unavailable | `rust-analyzer` | official rust-analyzer GitHub binary | +| basedpyright | `basedpyright` | unavailable | unavailable | official PyPI package in an isolated venv | +| gopls | `gopls` | `gopls` | `gopls` | official `go install` module | +| bash-language-server | `bash-language-server` | unavailable | unavailable | official npm package | +| yaml-language-server | `yaml-language-server` | unavailable | unavailable | official npm package | +| vscode-json-language-server | `vscode-langservers-extracted` | unavailable | unavailable | upstream npm package | +| lua-language-server | `lua-language-server` | unavailable | unavailable | official LuaLS GitHub release | +| markdown-oxide | `markdown-oxide` | unavailable | unavailable | official GitHub release | +| Taplo | `taplo` | unavailable | unavailable | official `@taplo/cli` npm package | +| terraform-ls | `terraform-ls` | unavailable in default repos | unavailable in default repos | official Go module | +| Dockerfile language server | `dockerfile-language-server` | unavailable | unavailable | upstream `dockerfile-language-server-nodejs` npm package | +| R languageserver | `r` runtime | unavailable | unavailable | official CRAN `languageserver` package | + +The npm fallback bootstraps a current Node runtime with the official `fnm` +release when the host Node is absent or older than Node 20. No `curl | sh`, PPA, +random mirror, or editor-private package directory is used. + +## File ownership + +Correct ownership is deliberate: + +- Bash buffers use bash-language-server; Docker Compose does not. +- YAML and Compose YAML use yaml-language-server; TOML does not. +- Taplo owns TOML. +- terraform-ls owns `.tf`, `.tfvars`, and HCL buffers. +- dockerfile-language-server owns Dockerfile syntax. +- vscode-json-language-server owns JSON and JSONC. + +Schema associations for Compose, GitHub Actions, and Kubernetes enhance YAML +when online. The YAML server still provides syntax intelligence offline; remote +schema downloads are not an installation dependency. + +## Desired state + +Every provider starts with its health contract. A valid executable already on +`PATH` (or an already-installed R `languageserver` namespace) is logged as +`already installed` and skipped, regardless of which package manager originally +provided it. Re-running `./dots lsp install` therefore converges without an +unnecessary reinstall loop. diff --git a/dots b/dots index 5d7b7c5..8c7689a 100755 --- a/dots +++ b/dots @@ -33,6 +33,8 @@ source "${DIR}/helpers/wizard.sh" source "${DIR}/helpers/packages.sh" # shellcheck source=helpers/components.sh source "${DIR}/helpers/components.sh" +# shellcheck source=helpers/lsp.sh +source "${DIR}/helpers/lsp.sh" # shellcheck source=helpers/package_state.sh source "${DIR}/helpers/package_state.sh" # shellcheck source=helpers/cask_apps.sh @@ -53,6 +55,7 @@ Usage: ./dots components optional components / --with selectors ./dots components list registry + host compatibility ./dots components active last-selected components (informational) + ./dots lsp language-server install / status / checks ./dots packages package ownership / drift ./dots packages groups capability groups (read-only) ./dots packages group ID inspect one package group @@ -153,6 +156,12 @@ dots_cmd_status() { fi echo " detail: ./dots components active" echo " (informational — mutating setup needs profile/--with)" + case " ${DOTS_ACTIVE_COMPONENTS:-} " in + *" lsp "*) + echo "" + dots_lsp_summary + ;; + esac echo "" echo "Runtime:" echo " multiplexer: ${mux}" @@ -176,7 +185,8 @@ dots_cmd_status() { # Best-effort inventory without requiring Stage 0 if command -v ollama >/dev/null 2>&1; then local n - n="$(ollama ls 2>/dev/null | awk 'NR>1 && NF{c++} END{print c+0}')" + n="$(ollama ls 2>/dev/null | awk 'NR>1 && NF{c++} END{print c+0}' || true)" + n="${n:-0}" echo " Ollama: ${n} model(s)" else echo " Ollama: not installed" @@ -441,6 +451,70 @@ dots_cmd_check() { exec "${DIR}/scripts/check.sh" --profile "${profile}" } +dots_cmd_lsp() { + local sub="${1:-}" + shift || true + if [[ -z ${sub} ]] && [[ -t 0 ]] && [[ -t 1 ]]; then + dots_ui_header "DOTS Language Servers" + dots_lsp_status 1 + echo "" + echo " 1) Install missing" + echo " 2) Re-check" + echo " 3) Show filetype map" + echo " 4) Back" + local choice + choice="$(dots_prompt_text "Choice" "2")" + case "${choice}" in + 1) sub=install ;; + 2) sub=check ;; + 3) sub=list ;; + *) return 0 ;; + esac + elif [[ -z ${sub} ]]; then + sub=status + fi + case "${sub}" in + status) dots_lsp_status 1 ;; + check) dots_lsp_check ;; + install) + # --yes is accepted for scripting; setup.sh is already noninteractive. + local arg + for arg in "$@"; do + case "${arg}" in + --yes | -y) ;; + --dry-run) exec "${DIR}/setup.sh" --with lsp --dry-run ;; + *) + echo "Unknown lsp install option: ${arg}" >&2 + return 1 + ;; + esac + done + exec "${DIR}/setup.sh" --with lsp + ;; + list) + printf '%-22s %-30s %s\n' "Server" "Neovim" "Filetypes" + local id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions + while IFS='|' read -r id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions; do + printf '%-22s %-30s %s\n' "${id}" "${nvim}" "${filetypes}" + done < <(dots_lsp_rows) + ;; + -h | --help | help) + cat <<'EOF' +Usage: + ./dots lsp interactive menu (TTY) or status + ./dots lsp status full non-mutating inventory + ./dots lsp install --yes delegate to ./setup.sh --with lsp + ./dots lsp check fail when required servers are missing + ./dots lsp list show Neovim/filetype mapping +EOF + ;; + *) + echo "Unknown lsp subcommand: ${sub}" >&2 + return 1 + ;; + esac +} + dots_cmd_update() { dots_ui_load_answers dots_ui_header "Update DOTS" @@ -776,6 +850,10 @@ components | component) shift || true dots_cmd_components "$@" ;; +lsp) + shift || true + dots_cmd_lsp "$@" + ;; packages | package) shift || true dots_cmd_packages "$@" diff --git a/helpers/lsp.sh b/helpers/lsp.sh new file mode 100644 index 0000000..4958551 --- /dev/null +++ b/helpers/lsp.sh @@ -0,0 +1,401 @@ +# shellcheck shell=bash +# helpers/lsp.sh — registry-driven language-server install/status/check +# +# Authority: configs/lsp/servers.toml. DOTS owns PATH-visible binaries; +# editors only configure and attach clients. + +dots_lsp_registry_path() { + printf '%s\n' "${DOTS_LSP_REGISTRY:-${DIR}/configs/lsp/servers.toml}" +} + +# Pipe-delimited rows. Registry values may not contain literal `|`. +dots_lsp_rows() { + dots_toml_query "$(dots_lsp_registry_path)" <<'PY' +def csv(value): + return ",".join(str(x) for x in (value or [])) + +for s in data.get("servers") or []: + values = [ + s.get("id", ""), s.get("label", ""), csv(s.get("domains")), + s.get("binary", ""), s.get("brew", ""), s.get("apt", ""), + s.get("fallback", ""), s.get("fallback_package", ""), + s.get("fallback_repo", ""), s.get("fallback_asset_amd64", ""), + s.get("fallback_asset_arm64", ""), s.get("fallback_binary_path", ""), + "true" if s.get("required") else "false", s.get("nvim", ""), + s.get("health", ""), csv(s.get("version_args")), + csv(s.get("filetypes")), csv(s.get("extensions")), + ] + print("|".join(str(v).replace("|", "") for v in values)) +PY +} + +dots_lsp_validate_registry() { + dots_toml_query "$(dots_lsp_registry_path)" <<'PY' +servers = data.get("servers") or [] +if data.get("version") != 1: + raise SystemExit("Error: unsupported LSP registry version") +if not servers: + raise SystemExit("Error: LSP registry has no servers") +seen = set() +roles = {} +allowed_health = {"executable", "r_package"} +allowed_fallback = {"npm", "python_venv", "go", "github_binary", "github_gzip", "github_tar", "cran"} +for s in servers: + sid = str(s.get("id") or "").strip() + if not sid or sid in seen: + raise SystemExit("Error: missing/duplicate LSP id '%s'" % sid) + seen.add(sid) + for key in ("label", "binary", "nvim", "health"): + if not str(s.get(key) or "").strip(): + raise SystemExit("Error: LSP '%s' missing %s" % (sid, key)) + for key in ("domains", "filetypes", "extensions", "platforms"): + if not isinstance(s.get(key), list) or not s.get(key): + raise SystemExit("Error: LSP '%s' missing %s mapping" % (sid, key)) + if s.get("health") not in allowed_health: + raise SystemExit("Error: LSP '%s' has invalid health policy" % sid) + fallback = str(s.get("fallback") or "").strip() + if fallback not in allowed_fallback: + raise SystemExit("Error: LSP '%s' has invalid fallback '%s'" % (sid, fallback)) + if not str(s.get("brew") or "").strip() and not str(s.get("apt") or "").strip() and not fallback: + raise SystemExit("Error: LSP '%s' has no install provider" % sid) + if fallback.startswith("github_"): + for key in ("fallback_repo", "fallback_asset_amd64", "fallback_asset_arm64"): + if not str(s.get(key) or "").strip(): + raise SystemExit("Error: LSP '%s' missing %s" % (sid, key)) + for role in s.get("extensions") or []: + role = str(role) + owner = roles.get(role) + if owner and not s.get("allow_role_overlap"): + raise SystemExit("Error: LSP role '%s' claimed by %s and %s" % (role, owner, sid)) + roles[role] = sid +print("OK") +PY +} + +_dots_lsp_r_package_ok() { + command -v Rscript >/dev/null 2>&1 || return 1 + Rscript -e 'quit(status=if(requireNamespace("languageserver", quietly=TRUE)) 0 else 1)' >/dev/null 2>&1 +} + +dots_lsp_server_satisfied() { + local binary="$1" health="$2" + case "${health}" in + r_package) _dots_lsp_r_package_ok ;; + *) command -v "${binary}" >/dev/null 2>&1 ;; + esac +} + +dots_lsp_probe_version() { + local binary="$1" args_csv="$2" output + local -a args=() + [[ -n ${args_csv} ]] || return 0 + IFS=',' read -r -a args <<<"${args_csv}" + output="$("${binary}" "${args[@]}" 2>&1 | head -1 || true)" + [[ -n ${output} ]] && printf '%s\n' "${output}" +} + +dots_lsp_status() { + local detailed="${1:-1}" id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions + local available=0 required_missing=0 optional_missing=0 total=0 state + if [[ ${detailed} -eq 1 ]]; then + printf '%-20s %-34s %s\n' "Domain" "Server / binary" "State" + printf '%-20s %-34s %s\n' "--------------------" "----------------------------------" "-------" + fi + while IFS='|' read -r id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions; do + [[ -n ${id} ]] || continue + total=$((total + 1)) + if dots_lsp_server_satisfied "${binary}" "${health}"; then + available=$((available + 1)) + state="OK" + if [[ ${detailed} -eq 1 && -n ${version_args} ]]; then + local version_line + version_line="$(dots_lsp_probe_version "${binary}" "${version_args}")" + [[ -n ${version_line} ]] && state="OK (${version_line})" + fi + else + state="MISSING" + if [[ ${required} == true ]]; then + required_missing=$((required_missing + 1)) + else + optional_missing=$((optional_missing + 1)) + fi + fi + if [[ ${detailed} -eq 1 ]]; then + printf '%-20s %-34s %s\n' "${label}" "${binary}" "${state}" + fi + done < <(dots_lsp_rows) + DOTS_LSP_TOTAL=${total} + DOTS_LSP_AVAILABLE=${available} + DOTS_LSP_REQUIRED_MISSING=${required_missing} + DOTS_LSP_OPTIONAL_MISSING=${optional_missing} + if [[ ${detailed} -eq 1 ]]; then + echo "" + echo "Available: ${available}/${total}; required missing: ${required_missing}; optional missing: ${optional_missing}" + fi + return 0 +} + +dots_lsp_summary() { + dots_lsp_status 0 + echo "LSP:" + echo " ${DOTS_LSP_AVAILABLE}/${DOTS_LSP_TOTAL} available" + echo " ${DOTS_LSP_REQUIRED_MISSING} required missing, ${DOTS_LSP_OPTIONAL_MISSING} optional missing" +} + +dots_lsp_check() { + dots_lsp_status 1 + [[ ${DOTS_LSP_REQUIRED_MISSING} -eq 0 ]] +} + +_dots_lsp_install_dir() { + mkdir -p "${HOME}/.local/bin" "${HOME}/.local/share/dots/lsp" + export PATH="${HOME}/.local/bin:${PATH}" +} + +_dots_lsp_download() { + local url="$1" dest="$2" + if declare -F dots_download_official >/dev/null 2>&1; then + dots_download_official "${url}" "${dest}" + else + curl -fsSL --proto '=https' --tlsv1.2 -o "${dest}" "${url}" + fi +} + +_dots_lsp_native_install() { + local package="$1" mgr + [[ -n ${package} ]] || return 1 + mgr="$(dots_detect_linux_pkg_mgr 2>/dev/null || echo unknown)" + case "${mgr}" in + apt) + dots_run_elevated apt-get update -qq + dots_run_elevated env DEBIAN_FRONTEND=noninteractive apt-get install -y "${package}" + ;; + pacman) dots_run_elevated pacman -Sy --noconfirm --needed "${package}" ;; + dnf) dots_run_elevated dnf install -y "${package}" ;; + xbps) dots_run_elevated xbps-install -Sy "${package}" ;; + *) return 1 ;; + esac +} + +_dots_lsp_try_apt() { + local package="$1" + [[ -n ${package} ]] || return 1 + [[ "$(dots_detect_linux_pkg_mgr 2>/dev/null || true)" == apt ]] || return 1 + if ! apt-cache show "${package}" >/dev/null 2>&1; then + if [[ ${DOTS_LSP_APT_REFRESHED:-0} -eq 0 ]]; then + dots_run_elevated apt-get update -qq || return 1 + DOTS_LSP_APT_REFRESHED=1 + fi + fi + apt-cache show "${package}" >/dev/null 2>&1 || return 1 + dots_run_elevated env DEBIAN_FRONTEND=noninteractive apt-get install -y "${package}" +} + +_dots_lsp_ensure_node() { + local major="" asset tmp fnm_bin ver + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + major="$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)" + if [[ ${major:-0} -ge 20 ]]; then + return 0 + fi + fi + if command -v fnm >/dev/null 2>&1; then + fnm_bin="$(command -v fnm)" + else + case "$(uname -m)" in + aarch64 | arm64) asset="fnm-arm64.zip" ;; + x86_64 | amd64) asset="fnm-linux.zip" ;; + *) + echo "Error: unsupported architecture for fnm: $(uname -m)" >&2 + return 1 + ;; + esac + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dots-fnm.XXXXXX")" + _dots_lsp_download "https://github.com/Schniz/fnm/releases/latest/download/${asset}" "${tmp}/fnm.zip" || { + rm -rf "${tmp}" + return 1 + } + python3 -m zipfile -e "${tmp}/fnm.zip" "${tmp}/out" || { + rm -rf "${tmp}" + return 1 + } + install -m 755 "${tmp}/out/fnm" "${HOME}/.local/bin/fnm" + rm -rf "${tmp}" + fnm_bin="${HOME}/.local/bin/fnm" + fi + eval "$("${fnm_bin}" env --shell bash)" + ver="24" + if declare -F dots_node_default_version >/dev/null 2>&1; then + ver="$(dots_node_default_version)" + fi + "${fnm_bin}" install "${ver}" || return 1 + "${fnm_bin}" default "${ver}" >/dev/null 2>&1 || true + eval "$("${fnm_bin}" env --shell bash)" + command -v npm >/dev/null 2>&1 +} + +_dots_lsp_install_python_venv() { + local package="$1" binary="$2" py dest + py="$(dots_find_python311 2>/dev/null || command -v python3)" + [[ -n ${py} ]] || return 1 + dest="${HOME}/.local/share/dots/lsp/${package}" + if ! "${py}" -m venv "${dest}" >/dev/null 2>&1; then + if [[ "$(dots_detect_linux_pkg_mgr 2>/dev/null || true)" == apt ]]; then + _dots_lsp_native_install python3-venv || return 1 + fi + "${py}" -m venv "${dest}" || return 1 + fi + "${dest}/bin/python" -m pip install --upgrade "${package}" || return 1 + ln -sfn "${dest}/bin/${binary}" "${HOME}/.local/bin/${binary}" +} + +_dots_lsp_github_release() { + local kind="$1" id="$2" binary="$3" repo="$4" asset_template="$5" archive_path="$6" + local tmp tag version asset url install_root + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dots-lsp-release.XXXXXX")" + _dots_lsp_download "https://api.github.com/repos/${repo}/releases/latest" "${tmp}/release.json" || { + rm -rf "${tmp}" + return 1 + } + tag="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"])' "${tmp}/release.json")" + version="${tag#v}" + asset="${asset_template//\{version\}/${version}}" + url="https://github.com/${repo}/releases/download/${tag}/${asset}" + case "${kind}" in + github_binary) + _dots_lsp_download "${url}" "${tmp}/${binary}" || { + rm -rf "${tmp}" + return 1 + } + install -m 755 "${tmp}/${binary}" "${HOME}/.local/bin/${binary}" + ;; + github_gzip) + _dots_lsp_download "${url}" "${tmp}/${binary}.gz" || { + rm -rf "${tmp}" + return 1 + } + gzip -dc "${tmp}/${binary}.gz" >"${tmp}/${binary}" || { + rm -rf "${tmp}" + return 1 + } + install -m 755 "${tmp}/${binary}" "${HOME}/.local/bin/${binary}" + ;; + github_tar) + _dots_lsp_download "${url}" "${tmp}/archive.tar.gz" || { + rm -rf "${tmp}" + return 1 + } + install_root="${HOME}/.local/share/dots/lsp/${id}/${version}" + mkdir -p "${install_root}" + tar -xzf "${tmp}/archive.tar.gz" -C "${install_root}" || { + rm -rf "${tmp}" + return 1 + } + [[ -x ${install_root}/${archive_path} ]] || { + echo "Error: ${archive_path} absent from ${asset}" >&2 + rm -rf "${tmp}" + return 1 + } + ln -sfn "${install_root}/${archive_path}" "${HOME}/.local/bin/${binary}" + ;; + *) + rm -rf "${tmp}" + return 1 + ;; + esac + rm -rf "${tmp}" +} + +_dots_lsp_install_cran() { + local package="$1" + if ! command -v Rscript >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then + brew install r || return 1 + else + case "$(dots_detect_linux_pkg_mgr 2>/dev/null || true)" in + apt) _dots_lsp_native_install r-base || return 1 ;; + pacman) _dots_lsp_native_install r || return 1 ;; + dnf) _dots_lsp_native_install R || return 1 ;; + xbps) _dots_lsp_native_install R || return 1 ;; + *) return 1 ;; + esac + fi + fi + Rscript -e "p <- path.expand(Sys.getenv('R_LIBS_USER')); dir.create(p, recursive=TRUE, showWarnings=FALSE); .libPaths(c(p, .libPaths())); if (!requireNamespace('${package}', quietly=TRUE)) install.packages('${package}', repos='https://cloud.r-project.org', lib=p)" +} + +_dots_lsp_install_fallback() { + local fallback="$1" id="$2" binary="$3" package="$4" repo="$5" asset="$6" archive_path="$7" + case "${fallback}" in + npm) + _dots_lsp_ensure_node || return 1 + npm install -g "${package}" + ;; + python_venv) _dots_lsp_install_python_venv "${package}" "${binary}" ;; + go) + if ! command -v go >/dev/null 2>&1; then + case "$(dots_detect_linux_pkg_mgr 2>/dev/null || true)" in + apt) _dots_lsp_native_install golang-go || return 1 ;; + pacman) _dots_lsp_native_install go || return 1 ;; + dnf) _dots_lsp_native_install golang || return 1 ;; + xbps) _dots_lsp_native_install go || return 1 ;; + *) return 1 ;; + esac + fi + env GOBIN="${HOME}/.local/bin" go install "${package}" + ;; + github_binary | github_gzip | github_tar) _dots_lsp_github_release "${fallback}" "${id}" "${binary}" "${repo}" "${asset}" "${archive_path}" ;; + cran) _dots_lsp_install_cran "${package}" ;; + *) + echo "Error: unsupported LSP fallback '${fallback}' for ${id}" >&2 + return 1 + ;; + esac +} + +dots_lsp_install() { + local id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions asset + local failures=0 + if [[ ${DRY_RUN:-0} -eq 1 ]]; then + echo "[dry-run] reconcile language servers from $(dots_lsp_registry_path)" + dots_lsp_rows | while IFS='|' read -r id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions; do + echo "[dry-run] ${id}: existing binary → skip; else native package → ${fallback} fallback" + done + return 0 + fi + _dots_lsp_install_dir + DOTS_LSP_APT_REFRESHED=0 + while IFS='|' read -r id label domains binary brew apt fallback package repo asset_x64 asset_arm archive_path required nvim health version_args filetypes extensions; do + [[ -n ${id} ]] || continue + if dots_lsp_server_satisfied "${binary}" "${health}"; then + echo "OK: ${id} already installed" + continue + fi + echo "Installing ${id}..." + if command -v brew >/dev/null 2>&1 && [[ -n ${brew} ]]; then + brew install "${brew}" || true + elif [[ -n ${apt} ]]; then + _dots_lsp_try_apt "${apt}" || true + fi + if dots_lsp_server_satisfied "${binary}" "${health}"; then + echo "OK: ${id} installed via native package manager" + continue + fi + case "$(uname -m)" in + aarch64 | arm64) asset="${asset_arm}" ;; + *) asset="${asset_x64}" ;; + esac + if ! _dots_lsp_install_fallback "${fallback}" "${id}" "${binary}" "${package}" "${repo}" "${asset}" "${archive_path}"; then + echo "Error: trusted fallback '${fallback}' failed for ${id}" >&2 + failures=$((failures + 1)) + continue + fi + if dots_lsp_server_satisfied "${binary}" "${health}"; then + echo "OK: ${id} installed via ${fallback}" + else + echo "Error: ${id} provider completed but health check still fails" >&2 + failures=$((failures + 1)) + fi + done < <(dots_lsp_rows) + [[ ${failures} -eq 0 ]] +} diff --git a/helpers/optional_components.sh b/helpers/optional_components.sh old mode 100644 new mode 100755 index f3355ec..0368130 --- a/helpers/optional_components.sh +++ b/helpers/optional_components.sh @@ -200,6 +200,9 @@ apply_optional_brewfiles() { # Darwin-only (registry platforms); explicit --with on Linux errors before here. _dots_optional_apply_registry_brewfile "mactools" || true fi + if has_component lsp; then + _dots_optional_apply_registry_brewfile "lsp" || true + fi } # Configuration / post-package side effects that must run AFTER backup + links. diff --git a/helpers/wizard.sh b/helpers/wizard.sh index 4130c66..d7e468c 100644 --- a/helpers/wizard.sh +++ b/helpers/wizard.sh @@ -151,7 +151,7 @@ dots_wizard_pick_components() { echo " Select additions (blank = none). Descriptions from configs/components.toml." echo "" - local want_ai=0 want_herdr=0 want_skills=0 want_aiskills=0 + local want_ai=0 want_lsp=0 want_herdr=0 want_skills=0 want_aiskills=0 local want_images=0 want_tex=0 local excl="" local _desc @@ -172,7 +172,7 @@ PY if [[ ${want_ai} -eq 1 ]]; then echo " AI applications selected. Exclude any?" - for id in cursor codex fluidvoice drawthings; do + for id in cursor codex fluidvoice drawthings lsp; do if ! dots_wizard_component_ok_here "${id}"; then echo " - ${id}: skipped (platform)" continue @@ -183,6 +183,16 @@ PY done fi + dots_ui_section "Developer intelligence" + if [[ ${want_ai} -eq 1 ]]; then + echo " AI applications includes the local LSP toolchain." + echo " LSPs are editor tooling and do not send code to AI providers." + else + _desc="$(dots_component_description lsp 2>/dev/null || echo "Language servers")" + ans="$(dots_prompt_yesno "Add Language servers (lsp) — Rust, Python, Go, Bash, Markdown, TOML, Terraform/HCL, Docker, R, YAML, JSON, Lua" n)" + [[ ${ans} == y ]] && want_lsp=1 + fi + if [[ ${builtin} != server ]]; then _desc="$(dots_component_description herdr 2>/dev/null || echo "Herdr")" ans="$(dots_prompt_yesno "Add Herdr — ${_desc}" n)" @@ -213,6 +223,7 @@ PY local parts=() [[ ${want_ai} -eq 1 ]] && parts+=("ai") + [[ ${want_lsp} -eq 1 ]] && parts+=("lsp") [[ ${want_herdr} -eq 1 ]] && parts+=("herdr") [[ ${want_skills} -eq 1 ]] && parts+=("skills") [[ ${want_aiskills} -eq 1 ]] && parts+=("ai-skills") @@ -508,7 +519,7 @@ dots_wizard_apply() { DOTS_APPLY_STARTED=1 # Dry-run: never write under HOME (logs go to TMPDIR) if [[ ${WIZ_DRY_RUN} -eq 1 ]]; then - log="$(mktemp "${TMPDIR:-/tmp}/dots-setup.XXXXXX.log")" + log="$(mktemp "${TMPDIR:-/tmp}/dots-setup.XXXXXX")" else log="$(dots_state_new_log setup)" fi diff --git a/mkdocs.yml b/mkdocs.yml index 359f97b..2275fab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,7 @@ nav: - Recipes: using/recipes.md - Package groups: using/package-groups.md - Components and --with: using/components.md + - Language servers: using/language-servers.md - Skills: using/skills.md - Tools catalog: using/tools.md - Extending: using/extending.md diff --git a/scripts/check.sh b/scripts/check.sh index 3339159..adae191 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -31,6 +31,8 @@ source "${DOTS_DIR}/helpers/components.sh" source "${DOTS_DIR}/helpers/profiles.sh" # shellcheck source=../helpers/packages.sh source "${DOTS_DIR}/helpers/packages.sh" +# shellcheck source=../helpers/lsp.sh +source "${DOTS_DIR}/helpers/lsp.sh" ok() { echo -e "${GREEN}✓${NC} $1"; PASSED=$((PASSED + 1)); } warn() { echo -e "${YELLOW}⚠${NC} $1"; WARNINGS=$((WARNINGS + 1)); } @@ -198,6 +200,25 @@ while IFS= read -r _tool; do warn "optional tool missing: ${_tool}" fi done < <(dots_tools_for_groups optional 2>/dev/null || true) + +_lsp_selected=0 +for _c in "${EFFECTIVE_WITH[@]+"${EFFECTIVE_WITH[@]}"}"; do + [[ "${_c}" == "lsp" ]] && _lsp_selected=1 && break +done +if [[ "${_lsp_selected}" -eq 1 ]]; then + echo -e "\n${BLUE}Language servers${NC}" + while IFS='|' read -r _id _label _domains _binary _brew _apt _fallback _package _repo _asset_x64 _asset_arm _archive_path _required _nvim _health _version_args _filetypes _extensions; do + [[ -n "${_id}" ]] || continue + if dots_lsp_server_satisfied "${_binary}" "${_health}"; then + ok "${_binary}" + elif [[ "${_required}" == "true" ]]; then + fail "${_binary} missing (required by lsp component)" + else + warn "${_binary} missing (optional LSP)" + fi + done < <(dots_lsp_rows) +fi + echo -e "\n${BLUE}Node / fnm${NC}" if command -v fnm >/dev/null 2>&1; then ok "fnm $(fnm --version 2>/dev/null | head -1)" diff --git a/scripts/ci/distro_smoke_inner.sh b/scripts/ci/distro_smoke_inner.sh index bdf59c3..515c5c3 100755 --- a/scripts/ci/distro_smoke_inner.sh +++ b/scripts/ci/distro_smoke_inner.sh @@ -15,6 +15,8 @@ source /dots/helpers/packages.sh source /dots/helpers/hardware.sh # shellcheck disable=SC1091 source /dots/helpers/linux_distro.sh +# shellcheck disable=SC1091 +source /dots/helpers/lsp.sh echo "" dots_linux_platform_report @@ -140,6 +142,17 @@ if [[ ${req_fail} -ne 0 ]]; then fi echo "Optional advisories: ${opt_warn}" +echo "" +echo "=== LSP provider registry (no installs) ===" +dots_lsp_validate_registry +while IFS='|' read -r lsp_id _label _domains _binary _brew lsp_apt lsp_fallback _rest; do + if [[ ${mgr} == apt && -n ${lsp_apt} ]] && pkg_exists "${lsp_apt}"; then + echo "APT: ${lsp_id} → ${lsp_apt}" + else + echo "ECOSYSTEM FALLBACK: ${lsp_id} → ${lsp_fallback}" + fi +done < <(dots_lsp_rows) + echo "" echo "=== bootstrap --profile server --show ===" bash /dots/bootstrap.sh --profile server --show diff --git a/scripts/ci/lint.sh b/scripts/ci/lint.sh index a46bfe8..0737d01 100755 --- a/scripts/ci/lint.sh +++ b/scripts/ci/lint.sh @@ -33,6 +33,7 @@ SCRIPTS=( helpers/ui.sh helpers/state.sh helpers/wizard.sh + helpers/lsp.sh helpers/cask_apps.sh helpers/backup.sh helpers/mactools.sh @@ -59,6 +60,7 @@ SCRIPTS=( scripts/tests/dots_cli_test.sh scripts/tests/dots_wizard_test.sh scripts/tests/dots_wizard_failure_test.sh + scripts/tests/lsp_toolchain_test.sh scripts/tests/wizard_models_progress_test.sh scripts/tests/cask_app_test.sh scripts/tests/optional_failure_summary_test.sh @@ -98,6 +100,7 @@ shfmt -d -s \ helpers/toml.sh helpers/bootstrap_prereqs.sh \ helpers/hardware.sh helpers/models.sh helpers/model_providers.sh \ helpers/ui.sh helpers/state.sh helpers/wizard.sh helpers/cask_apps.sh \ + helpers/lsp.sh \ helpers/backup.sh helpers/package_state.sh helpers/linux_distro.sh \ shell/runtime.sh shell/fnm.sh \ scripts/pull_models.sh \ diff --git a/scripts/ci/macos_smoke.sh b/scripts/ci/macos_smoke.sh index 95b2349..f76983a 100755 --- a/scripts/ci/macos_smoke.sh +++ b/scripts/ci/macos_smoke.sh @@ -5,6 +5,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "${ROOT}" +if ! command -v rg >/dev/null 2>&1; then + brew install ripgrep +fi +command -v rg >/dev/null 2>&1 || { + echo "Error: rg (ripgrep) required for macOS smoke" >&2 + exit 1 +} + /bin/bash ./bootstrap.sh --profile work --show /bin/bash ./bootstrap.sh --profile home --show /bin/bash ./bootstrap.sh --profile server --show @@ -12,8 +20,10 @@ cd "${ROOT}" /bin/bash ./setup.sh --dry-run --with ai /bin/bash ./setup.sh --dry-run --with fluidvoice /bin/bash ./setup.sh --dry-run --with llamacpp +/bin/bash ./setup.sh --dry-run --with lsp brew info --cask fluidvoice || true brew info llama.cpp || true +brew bundle list --file=brew/Brewfile.lsp --brews >/dev/null # Model planner (mocked; no network downloads) DOTS_FORCE_OS=darwin DOTS_FORCE_ARCH=arm64 DOTS_FORCE_RAM_GB=24 \ @@ -39,6 +49,7 @@ bash scripts/tests/skills_location_test.sh bash scripts/tests/skills_desired_state_test.sh bash scripts/tests/mactools_test.sh bash scripts/tests/backup_gate_test.sh +bash scripts/tests/lsp_toolchain_test.sh /bin/bash ./dots --version /bin/bash ./dots help >/dev/null diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh index 4479e55..d57acc1 100755 --- a/scripts/ci/test.sh +++ b/scripts/ci/test.sh @@ -27,6 +27,7 @@ run scripts/tests/repository_contract_test.sh run scripts/tests/multiplexer_nesting_test.sh run scripts/tests/stage0_prereqs_test.sh run scripts/tests/provider_mapping_test.sh +run scripts/tests/lsp_toolchain_test.sh echo "=== Bootstrap + setup dry-run ===" ./bootstrap.sh --profile server --dry-run @@ -34,6 +35,7 @@ echo "=== Bootstrap + setup dry-run ===" ./setup.sh --dry-run --with herdr ./setup.sh --dry-run --with hermes,ollama ./setup.sh --dry-run --with ai +./setup.sh --dry-run --with lsp ./setup.sh --dry-run --profile base --packages core,modern if [[ "$(uname -s)" != "Darwin" ]]; then if ./setup.sh --dry-run --with fluidvoice; then diff --git a/scripts/generate_lsp_nvim.py b/scripts/generate_lsp_nvim.py new file mode 100755 index 0000000..656d6ae --- /dev/null +++ b/scripts/generate_lsp_nvim.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Generate Neovim's server table from configs/lsp/servers.toml.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import tomllib + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "configs/lsp/servers.toml" +OUTPUT = ROOT / "configs/nvim/lua/config/lsp_servers.lua" + + +def lua(value: object, indent: int = 0) -> str: + if value is None: + return "nil" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + if value == "__DOTS_VIMRUNTIME__": + return "vim.env.VIMRUNTIME" + return json.dumps(value, ensure_ascii=False) + if isinstance(value, list): + return "{ " + ", ".join(lua(item, indent + 2) for item in value) + " }" + if isinstance(value, dict): + if not value: + return "{}" + pad = " " * indent + child = " " * (indent + 2) + rows = [] + for key, item in value.items(): + if key.replace("_", "a").isalnum() and not key[0].isdigit(): + rendered_key = key + else: + rendered_key = f"[{lua(key)}]" + rows.append(f"{child}{rendered_key} = {lua(item, indent + 2)},") + return "{\n" + "\n".join(rows) + f"\n{pad}}}" + raise TypeError(f"unsupported Lua value: {type(value)!r}") + + +def render() -> str: + data = tomllib.loads(REGISTRY.read_text(encoding="utf-8")) + rows = [ + "-- Generated by scripts/generate_lsp_nvim.py; do not edit.", + "-- Authority: configs/lsp/servers.toml", + "return {", + ] + for server in data.get("servers", []): + name = server["nvim"] + options = server.get("nvim_config", {}) + rows.append(f" {name} = {lua(options, 2)},") + rows.append("}") + return "\n".join(rows) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + expected = render() + if args.check: + if not OUTPUT.is_file() or OUTPUT.read_text(encoding="utf-8") != expected: + raise SystemExit(f"stale generated file: {OUTPUT.relative_to(ROOT)}") + return 0 + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(expected, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/profile_resolution_test.sh b/scripts/profile_resolution_test.sh index 6160616..2168e3f 100755 --- a/scripts/profile_resolution_test.sh +++ b/scripts/profile_resolution_test.sh @@ -53,7 +53,7 @@ if [[ ${#EFFECTIVE_WITH[@]} -eq 0 ]]; then ok "work → []"; else bad "work expe export DOTS_FORCE_OS=darwin DOTS_FORCE_DARWIN_MAJOR=15 resolve_named home home_got="$(printf '%s\n' "${EFFECTIVE_WITH[@]}" | sort | tr '\n' ' ')" -home_want="$(printf '%s\n' hermes herdr ollama llamacpp skills ai-skills drawthings opencode codex cursor images tex | sort | tr '\n' ' ')" +home_want="$(printf '%s\n' hermes herdr ollama llamacpp skills ai-skills drawthings opencode codex cursor images tex lsp | sort | tr '\n' ' ')" if [[ "${home_got}" == "${home_want}" ]]; then ok "home Darwin set"; else bad "home want='${home_want}' got='${home_got}'"; fi unset DOTS_FORCE_OS DOTS_FORCE_DARWIN_MAJOR || true diff --git a/scripts/tests/component_supergroups_test.sh b/scripts/tests/component_supergroups_test.sh index 30c06cd..5ef8017 100755 --- a/scripts/tests/component_supergroups_test.sh +++ b/scripts/tests/component_supergroups_test.sh @@ -46,7 +46,7 @@ fi echo "=== ai supergroup members ===" members="$(dots_supergroup_members ai | tr '\n' ' ')" members="${members% }" -want="hermes ollama llamacpp drawthings opencode codex cursor fluidvoice" +want="hermes ollama llamacpp drawthings opencode codex cursor fluidvoice lsp" if [[ ${members} == "${want}" ]]; then ok "ai members declared" else @@ -58,7 +58,7 @@ export DOTS_FORCE_OS=darwin DOTS_FORCE_DARWIN_MAJOR=15 got=() read_expand got ai if contains fluidvoice "${got[@]+"${got[@]}"}"; then ok "darwin15 ai includes fluidvoice"; else bad "darwin15 missing fluidvoice"; fi -for m in hermes ollama llamacpp drawthings opencode codex cursor; do +for m in hermes ollama llamacpp drawthings opencode codex cursor lsp; do if contains "${m}" "${got[@]+"${got[@]}"}"; then ok "darwin15 ai has ${m}"; else bad "darwin15 missing ${m}"; fi done @@ -70,8 +70,8 @@ read_expand got ai if contains fluidvoice "${got[@]+"${got[@]}"}"; then bad "linux ai should omit fluidvoice"; else ok "linux ai omits fluidvoice"; fi if contains cursor "${got[@]+"${got[@]}"}"; then bad "linux ai should omit cursor"; else ok "linux ai omits cursor"; fi if contains codex "${got[@]+"${got[@]}"}"; then bad "linux ai should omit codex"; else ok "linux ai omits codex"; fi -if contains hermes "${got[@]+"${got[@]}"}" && contains ollama "${got[@]+"${got[@]}"}" && contains opencode "${got[@]+"${got[@]}"}" && contains llamacpp "${got[@]+"${got[@]}"}"; then - ok "linux ai keeps hermes/ollama/opencode/llamacpp" +if contains hermes "${got[@]+"${got[@]}"}" && contains ollama "${got[@]+"${got[@]}"}" && contains opencode "${got[@]+"${got[@]}"}" && contains llamacpp "${got[@]+"${got[@]}"}" && contains lsp "${got[@]+"${got[@]}"}"; then + ok "linux ai keeps hermes/ollama/opencode/llamacpp/lsp" else bad "linux ai missing portable members: ${got[*]-}" fi @@ -142,7 +142,7 @@ PROFILE_WITH=() PROFILE_WITHOUT=() dots_load_profile_file "$(dots_resolve_profile_path home)" >/dev/null dots_compute_effective_with _ai_leak=0 -for m in hermes ollama llamacpp drawthings opencode codex cursor fluidvoice; do +for m in hermes ollama llamacpp drawthings opencode codex cursor fluidvoice lsp; do if contains "${m}" "${EFFECTIVE_WITH[@]+"${EFFECTIVE_WITH[@]}"}"; then bad "home --without ai still has ${m}" _ai_leak=1 @@ -159,7 +159,7 @@ PROFILE_WITH=() PROFILE_WITHOUT=() dots_load_profile_file "$(dots_resolve_profile_path home)" >/dev/null dots_compute_effective_with home_got="$(printf '%s\n' "${EFFECTIVE_WITH[@]}" | sort | tr '\n' ' ')" -home_want="$(printf '%s\n' hermes herdr ollama llamacpp skills ai-skills drawthings opencode codex cursor images tex | sort | tr '\n' ' ')" +home_want="$(printf '%s\n' hermes herdr ollama llamacpp skills ai-skills drawthings opencode codex cursor images tex lsp | sort | tr '\n' ' ')" if [[ ${home_got} == "${home_want}" ]]; then ok "home Darwin set matches prior leaf intent" else diff --git a/scripts/tests/dots_wizard_test.sh b/scripts/tests/dots_wizard_test.sh index 7d0ab49..e21718e 100755 --- a/scripts/tests/dots_wizard_test.sh +++ b/scripts/tests/dots_wizard_test.sh @@ -67,7 +67,7 @@ echo "=== wizard: server customize bertha + ollama/llamacpp + balanced ===" # mux 1 (tmux), infra y, models 2 (choose), tier 3 (balanced), cleanup n IFS='|' read -r shome sout src smut < <(_wiz_parse server \ 3 n n bertha \ - n n n n n \ + n n n n n n \ y y \ 1 \ y \ @@ -93,7 +93,7 @@ if [[ ${os} == Darwin ]]; then IFS='|' read -r chome cout crc cmut < <(_wiz_parse aicustom \ 1 n n home-custom \ n \ - y y n n n \ + y y n n n n \ n n n n n \ 2 \ 3) @@ -102,7 +102,7 @@ else IFS='|' read -r chome cout crc cmut < <(_wiz_parse aicustom \ 1 n n home-custom \ n \ - y n \ + y n n \ n n n n n \ 1 \ 3) diff --git a/scripts/tests/lsp_toolchain_test.sh b/scripts/tests/lsp_toolchain_test.sh new file mode 100755 index 0000000..672cd22 --- /dev/null +++ b/scripts/tests/lsp_toolchain_test.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# scripts/tests/lsp_toolchain_test.sh — registry, providers, CLI, consent, Neovim +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +DIR="${ROOT}" +pass=0 +fail=0 +ok() { + echo "OK: $1" + pass=$((pass + 1)) +} +bad() { + echo "FAIL: $1" >&2 + fail=$((fail + 1)) +} +contains() { + local needle="$1" + shift + local item + for item in "$@"; do [[ ${item} == "${needle}" ]] && return 0; done + return 1 +} + +# shellcheck source=../../helpers/toml.sh +source "${ROOT}/helpers/toml.sh" +# shellcheck source=../../helpers/components.sh +source "${ROOT}/helpers/components.sh" +# shellcheck source=../../helpers/packages.sh +source "${ROOT}/helpers/packages.sh" +# shellcheck source=../../helpers/bootstrap_prereqs.sh +source "${ROOT}/helpers/bootstrap_prereqs.sh" +# shellcheck source=../../helpers/python_runtime.sh +source "${ROOT}/helpers/python_runtime.sh" +# shellcheck source=../../helpers/lsp.sh +source "${ROOT}/helpers/lsp.sh" + +echo "=== registry contract ===" +if [[ $(dots_lsp_validate_registry 2>&1) == OK ]]; then ok "registry validates"; else bad "registry validation"; fi +count="$(dots_lsp_rows | wc -l | tr -d ' ')" +[[ ${count} -eq 12 ]] && ok "12 server entries" || bad "expected 12 servers, got ${count}" + +if python3 - "${ROOT}/configs/lsp/servers.toml" <<'PY'; then ok "apt/fallback matrix is deliberate"; else bad "provider matrix"; fi +import sys, tomllib +with open(sys.argv[1], "rb") as fh: + data = tomllib.load(fh) +servers = {s["id"]: s for s in data["servers"]} +expected = { + "rust-analyzer", "basedpyright", "gopls", "bash-language-server", + "yaml-language-server", "vscode-json-language-server", "lua-language-server", + "markdown-oxide", "taplo", "terraform-ls", + "dockerfile-language-server", "r-languageserver", +} +assert set(servers) == expected +assert servers["rust-analyzer"]["apt"] == "rust-analyzer" +assert servers["gopls"]["apt"] == "gopls" +assert all(s["apt"] == "" for k, s in servers.items() if k not in {"rust-analyzer", "gopls"}) +assert servers["basedpyright"]["fallback"] == "python_venv" +assert servers["r-languageserver"]["fallback"] == "cran" +assert servers["yaml-language-server"]["binary"] == "yaml-language-server" +assert servers["taplo"]["binary"] == "taplo" +assert servers["dockerfile-language-server"]["binary"] == "docker-langserver" +assert servers["vscode-json-language-server"]["binary"] == "vscode-json-language-server" +PY +brew_count="$(rg -c '^brew ' "${ROOT}/brew/Brewfile.lsp")" +[[ ${brew_count} -eq 12 ]] && ok "Brewfile has 12 formulae" || bad "Brewfile formula count=${brew_count}" +if python3 - "${ROOT}/configs/lsp/servers.toml" "${ROOT}/brew/Brewfile.lsp" <<'PY'; then ok "Brewfile matches registry"; else bad "Brewfile drift"; fi +import re, sys, tomllib +with open(sys.argv[1], "rb") as fh: + data = tomllib.load(fh) +want = {s["brew"] for s in data["servers"] if s.get("brew")} +got = set(re.findall(r'^brew "([^"]+)"', open(sys.argv[2], encoding="utf-8").read(), re.M)) +assert want == got, (want - got, got - want) +PY +echo "=== component and consent semantics ===" +members="$(dots_supergroup_members ai | tr '\n' ' ')" +case " ${members} " in *" lsp "*) ok "lsp is in ai" ;; *) bad "lsp missing from ai" ;; esac +all_ids="$(dots_component_ids_for_all | tr '\n' ' ')" +case " ${all_ids} " in *" lsp "*) ok "lsp is in all" ;; *) bad "lsp missing from all" ;; esac + +show="$("${ROOT}/bootstrap.sh" --profile work --with lsp --show 2>&1)" +echo "${show}" | rg -q '^ lsp$' && ok "work + lsp selects leaf" || bad "work + lsp" +echo "${show}" | rg -q 'local AI: \(none\)' && echo "${show}" | rg -q 'cloud AI: \(none\)' && ok "lsp grants no AI provider consent" || bad "lsp provider isolation" +ai_show="$("${ROOT}/bootstrap.sh" --profile base --with ai --show 2>&1)" +echo "${ai_show}" | rg -q '^ lsp$' && ok "with ai includes lsp" || bad "ai missing lsp" +without_show="$("${ROOT}/bootstrap.sh" --profile base --with ai --without lsp --show 2>&1)" +if echo "${without_show}" | sed -n '/^components:/,/^requested:/p' | rg -q '^ lsp$'; then bad "without lsp retained lsp"; else ok "with ai --without lsp removes lsp"; fi +base_show="$("${ROOT}/bootstrap.sh" --profile base --show 2>&1)" +if echo "${base_show}" | sed -n '/^components:/,/^explicit provider/p' | rg -q '^ lsp$'; then bad "plain base includes lsp"; else ok "plain base unchanged"; fi + +echo "=== desired-state and CLI ===" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dots-lsp-test.XXXXXX")" +trap 'rm -rf "${TMP}"' EXIT +mkdir -p "${TMP}/bin" "${TMP}/home" +while IFS='|' read -r _id _label _domains binary _rest; do + cat >"${TMP}/bin/${binary}" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "${TMP}/bin/${binary}" +done < <(dots_lsp_rows) +out="$(HOME="${TMP}/home" PATH="${TMP}/bin:${PATH}" dots_lsp_install 2>&1)" || bad "desired-state install failed" +already="$(echo "${out}" | rg -c 'already installed' || true)" +[[ ${already} -eq 12 ]] && ok "all existing servers are skipped" || bad "existing skip count=${already}" + +cli="$("${ROOT}/dots" lsp list 2>&1)" || cli="" +echo "${cli}" | rg -q 'rust_analyzer' && echo "${cli}" | rg -q 'r_language_server' && ok "dots lsp list" || bad "dots lsp list" +help="$("${ROOT}/dots" lsp help 2>&1)" || help="" +echo "${help}" | rg -q 'lsp install --yes' && ok "dots lsp command surface" || bad "dots lsp help" +dry="$("${ROOT}/dots" lsp install --dry-run 2>&1)" || dry="" +echo "${dry}" | rg -q 'reconcile language servers' && ok "lsp install delegates to component dry-run" || bad "lsp install delegation" + +echo "=== wizard and Neovim ===" +# shellcheck source=../../helpers/ui.sh +source "${ROOT}/helpers/ui.sh" +# shellcheck source=../../helpers/state.sh +source "${ROOT}/helpers/state.sh" +# shellcheck source=../../helpers/wizard.sh +source "${ROOT}/helpers/wizard.sh" +dots_prompt_yesno() { + case "$1" in + *"Language servers"*) echo y ;; + *) echo n ;; + esac +} +WIZ_WITH="" WIZ_WITHOUT="" +dots_wizard_pick_components base >/dev/null +[[ ${WIZ_WITH} == lsp ]] && ok "wizard selects lsp directly" || bad "wizard direct lsp got '${WIZ_WITH}'" +dots_prompt_yesno() { + case "$1" in + "Add AI applications"*) echo y ;; + *"Exclude lsp"*) echo y ;; + *) echo n ;; + esac +} +WIZ_WITH="" WIZ_WITHOUT="" +dots_wizard_pick_components base >/dev/null +[[ ${WIZ_WITH} == ai && ${WIZ_WITHOUT} == lsp ]] && ok "wizard selects ai then excludes lsp" || bad "wizard ai exclusion with='${WIZ_WITH}' without='${WIZ_WITHOUT}'" + +if python3 "${ROOT}/scripts/generate_lsp_nvim.py" --check; then ok "generated Neovim table is current"; else bad "Neovim table drift"; fi +if rg -q 'mason|ensure_installed' "${ROOT}/configs/nvim/lua/plugins/lsp.lua"; then bad "Neovim still manages LSP binaries"; else ok "Neovim consumes system binaries"; fi +for server in rust_analyzer basedpyright gopls bashls yamlls jsonls lua_ls markdown_oxide taplo terraformls dockerls r_language_server; do + rg -q "^ ${server} =" "${ROOT}/configs/nvim/lua/config/lsp_servers.lua" && ok "Neovim config: ${server}" || bad "Neovim missing ${server}" +done + +echo "Passed: ${pass} Failed: ${fail}" +[[ ${fail} -eq 0 ]] diff --git a/setup.sh b/setup.sh index e000952..1d2eb56 100755 --- a/setup.sh +++ b/setup.sh @@ -56,6 +56,8 @@ source "${DIR}/helpers/cursor.sh" source "${DIR}/helpers/agent_router.sh" # shellcheck source=helpers/fnm.sh source "${DIR}/helpers/fnm.sh" +# shellcheck source=helpers/lsp.sh +source "${DIR}/helpers/lsp.sh" # shellcheck source=helpers/nvim.sh source "${DIR}/helpers/nvim.sh" # shellcheck source=helpers/notify.sh @@ -137,6 +139,7 @@ Examples: ./setup.sh --with cursor,drawthings ./setup.sh --with skills ./setup.sh --with images,tex + ./setup.sh --with lsp ./setup.sh --dry-run --with ai ./bootstrap.sh --profile home ./bootstrap.sh --profile work --with ai @@ -441,18 +444,30 @@ elif command -v brew >/dev/null 2>&1 || [[ -n ${DOTS_BREW_BIN:-} ]]; then echo "Error: one or more optional components failed (see list above)" >&2 exit 1 fi -elif has_component herdr; then +elif has_component herdr || has_component lsp; then # Linux native path: official Herdr installer (no Homebrew required) - if [[ ${NO_INSTALL:-0} -eq 1 ]]; then + if has_component herdr && [[ ${NO_INSTALL:-0} -eq 1 ]]; then if ! command -v herdr >/dev/null 2>&1; then echo "Error: herdr missing (--no-install)" >&2 exit 1 fi - else + elif has_component herdr; then dots_ensure_herdr || exit 1 fi fi +if has_component lsp; then + echo "=== Language servers ===" + if [[ ${NO_INSTALL:-0} -eq 1 ]]; then + dots_lsp_check || { + echo "Error: required language servers missing (--no-install)" >&2 + exit 1 + } + else + dots_lsp_install || exit 1 + fi +fi + echo "=== Symlinks / configuration ===" # Declarative authority: configs/links.toml (targets already snapshotted above) dots_deploy_links