Skip to content

spec: user-configurable language servers (#8803)#9848

Open
lonexreb wants to merge 3 commits intowarpdotdev:masterfrom
lonexreb:spec/8803-custom-lsp-config
Open

spec: user-configurable language servers (#8803)#9848
lonexreb wants to merge 3 commits intowarpdotdev:masterfrom
lonexreb:spec/8803-custom-lsp-config

Conversation

@lonexreb
Copy link
Copy Markdown
Contributor

@lonexreb lonexreb commented May 1, 2026

Description

Spec PR for #8803 ("Add support for adding custom language servers"), per @kevinyang372's direction on #9562 and #9568 — closing those PRs in favor of a user-configurable LSP path. This converts that product direction into testable behavior invariants plus a concrete implementation plan grounded in the existing crates/lsp/ infrastructure.

Linked Issue

What's in this PR

File Lines What
specs/GH8803/product.md ~140 15 numbered testable invariants. TOML config shape ([[lsp.servers]] array-of-tables with name / command / file_types / root_files / initialization_options / start_timeout_ms). Explicit non-goals (auto-install, version pinning, marketplace). User experience walkthrough (add → chip → enable → use → disable). Open questions (~ expansion, example-configs doc, network defaults).
specs/GH8803/tech.md ~270 File-by-file implementation plan. New UserConfiguredLanguageServer impl of LanguageServerCandidate. New LSPServerType::UserConfigured(...) variant. Per-workspace enabled_custom_servers / dismissed_custom_servers state. Settings-reload diff semantics. Per-invariant test plan. Risks called out.

Reuses lessons from the closed LSP PRs

This spec deliberately leans on patterns that survived bot review on #9562 and #9568, so the implementation phase doesn't relitigate them:

Scope discipline

  • Not implementing — this is spec-only, no Rust code yet. Implementation will land as a follow-up PR after spec approval.
  • Doesn't replace built-in servers — user-configured servers run alongside any built-in server matching the same file extension. The LSP client merges responses (existing behavior).
  • Doesn't auto-install — the user owns binary lifecycle for these (npm, brew, cargo, etc.). Warp's job is to discover, prompt, and connect.
  • Per-workspace enablement — defining a server is global; running it requires explicit opt-in per workspace via the editor footer chip.

Open questions for maintainers (also documented in product.md)

  1. ~ expansion in command[0] — Cmd-O / /open-file already use shellexpand::tilde; recommend yes for ~, defer $VAR to a follow-up. Confirm?
  2. Example configs doc — recommend shipping docs/custom-lsp-examples.md (intelephense, lua-language-server, zls, bash-language-server) alongside the implementation. Worth gating behind this spec or a separate docs PR?
  3. Schema-fetching restriction defaults — should we mandate / document that user-configured servers run with their network-fetching options disabled by default? Hard to enforce generically (each server has its own keys); spec recommends per-server initialization_options and an examples doc rather than a generic flag. Confirm?

Testing — for the spec itself

  • Both files render correctly on GitHub.
  • All 15 product invariants have a corresponding row in tech.md's testing matrix.
  • Cross-references to closed PR commits (31285c4, etc.) and existing files (crates/lsp/src/servers/intelephense.rs, app/src/settings/input.rs) verified at write time.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-NEW-FEATURE: Spec for user-configurable language servers (#8803) — defines the TOML config shape, per-workspace enablement model, and lifecycle for custom LSPs that aren't shipped built-in. Implementation PR to follow. Thanks @lonexreb!

Per @kevinyang372's direction on warpdotdev#9562 and warpdotdev#9568 (closing those PRs in
favor of a user-configurable LSP path), this spec turns warpdotdev#8803's product
direction into testable behavior invariants and a concrete
implementation plan grounded in the existing LSP infrastructure.

product.md
- 15 numbered, testable invariants covering parse/validate, chip
  behavior (per-workspace enablement, dismissal, persistence across
  restart), spawn outcomes (success / not-on-PATH / crash / timeout),
  settings reload diff semantics, coexistence with built-in servers,
  and graceful shutdown.
- TOML configuration shape with `name` / `command` / `file_types` /
  optional `root_files` / `initialization_options` / `start_timeout_ms`.
- Explicit non-goals: auto-install, version pinning, replacing built-in
  servers, marketplace.
- Open questions called out: `~` expansion in `command[0]`, example
  configs doc, schema-fetching restriction defaults.

tech.md
- File-by-file implementation plan against `crates/lsp/`,
  `app/src/settings/`, `app/src/workspace/state.rs`, `app/src/code/editor/`.
- Reuses patterns proven in the closed PRs:
  * `binary_in_path` PATH search with executable-bit check (warpdotdev#9562)
  * `warpui::r#async::FutureExt::with_timeout` for bounded init (warpdotdev#9562
    follow-up commit `31285c4`)
  * `std::env::join_paths` + `binary_filename` for cross-platform tests
    (warpdotdev#9562, warpdotdev#9568)
  * `initialization_options` plumbing for security-sensitive defaults
    (warpdotdev#9568, lessons from `handledSchemaProtocols`)
- Per-invariant test plan mapping each product.md item to a unit /
  integration test layer.
- ASCII flow diagram of settings-edit → chip-render → enable-click →
  spawn-with-timeout → route-traffic.
- Risks called out (crash loops, security of `initialization_options`,
  `Custom(String)` enum propagation, workspace-state migration).
@cla-bot cla-bot Bot added the cla-signed label May 1, 2026
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 1, 2026

@lonexreb

I'm starting a first review of this spec-only pull request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@github-actions github-actions Bot added the external-contributor Indicates that a PR has been opened by someone outside the Warp team. label May 1, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This spec PR defines the product behavior and implementation plan for user-configurable language servers.

Concerns

  • The tech spec contradicts the product requirements by moving the settings UI needed for disable/re-enable behavior into follow-ups.
  • The implementation plan has feasibility gaps around crate boundaries, process timeout cleanup, and deriving LSP language IDs from file extensions.
  • The config model does not resolve conflicts when multiple custom servers match the same file type.

Security

  • Existing enabled workspaces would automatically execute a changed command after settings reload; require a fresh opt-in or invalidate enablement for command/arg changes.

Verdict

Found: 1 critical, 5 important, 1 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH8803/tech.md Outdated
## Follow-ups (out of this spec)

- `nix flake check`-validated dev shell with all referenced LSP binaries pre-installed (would help testing).
- Settings UI: a "Custom language servers" page under Settings → Code intelligence that lists configured servers + workspace-enablement state with `Enable`/`Disable` buttons (currently described only in product.md user-experience section).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] Settings UI cannot be a follow-up because product.md requires disabling and re-enabling dismissed servers through settings (invariants 11 and 14); move this UI into Proposed changes/testing or drop those requirements from the product spec.

Comment thread specs/GH8803/tech.md

```rust
pub struct UserConfiguredLanguageServer {
config: UserLspServerConfig,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] UserLspServerConfig is defined in app/src/settings/lsp.rs, but this new crates/lsp module cannot depend on app code; specify a shared crate/type boundary or an app-to-lsp conversion layer.

Comment thread specs/GH8803/tech.md Outdated

**Option B — keep the enum closed and add a parallel `LspLanguageId` type that's either `Builtin(LanguageId)` or `Custom { extension: String, identifier: String }`.**

**Recommendation: Option A.** It's mechanical (one variant + two match arms) and avoids splitting the type system. The downside (more `Custom(...)` arms in match exhaustiveness) is acceptable.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Returning the file extension as languageId is not sufficient for many servers (shshellscript, phtmlphp, etc.); add a configurable language_id/per-extension mapping or define how aliases are handled.

Comment thread specs/GH8803/tech.md Outdated
1. Compute diff of `[lsp.servers]` between old and new config (by `name`).
2. For added entries: nothing immediate; chip will appear on next matching file open.
3. For removed entries: shut down any running instance for that name, clear from `enabled_custom_servers` in all workspaces.
4. For changed entries (config differs but `name` matches): if currently running, restart with new config.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] Auto-restarting an already-enabled server when command changes executes a new user-supplied binary without a fresh opt-in; require re-confirmation or invalidate enablement for command/arg changes.

Comment thread specs/GH8803/tech.md Outdated
- Wrap the `initialize` request in `warpui::r#async::FutureExt::with_timeout(Duration::from_millis(config.start_timeout_ms))`. Three outcomes (matches the pattern from `feat/9168-php-lsp-intelephense` commit `31285c4`):
- `Ok(Ok(_))` — server initialized; route LSP traffic.
- `Ok(Err(err))` — JSON-RPC error; surface notification with err message.
- `Err(timeout)` — kill child via `Drop`, surface timeout notification.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Dropping a child handle is not a portable kill guarantee for std::process::Child; specify explicit kill/kill_on_drop plus wait/reap semantics for timeout cleanup.

Comment thread specs/GH8803/product.md Outdated

- **Auto-installation of user-configured servers.** The user installs the binary themselves (npm, cargo, system package manager, brew, etc.). Warp does not run package managers on the user's behalf for these.
- **Version pinning / auto-update of user-configured servers.** Out of scope; the user owns the lifecycle.
- **Per-file dynamic switching.** A single file is associated with at most one user-configured server, plus any built-in servers that already match. No "try server A, fall back to server B" runtime logic.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] The spec says a file has at most one user-configured server, but validation only enforces unique names and the tech flow renders a chip per matching entry; define whether duplicate file_types are rejected or how conflicts are resolved.

Comment thread specs/GH8803/tech.md Outdated

**File:** new `app/src/settings/lsp.rs`. Pattern matches `app/src/settings/input.rs` (`define_settings_group!` macro).

The group holds a single setting, `custom_servers`, of type `Vec<UserLspServerConfig>`. The struct:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 [SUGGESTION] The user-facing TOML is [[lsp.servers]], but the Rust setting is named custom_servers; call out the serde rename/schema mapping so implementers don't accidentally expose [[lsp.custom_servers]].

Comment thread specs/GH8803/product.md Outdated
- **Auto-installation of user-configured servers.** The user installs the binary themselves (npm, cargo, system package manager, brew, etc.). Warp does not run package managers on the user's behalf for these.
- **Version pinning / auto-update of user-configured servers.** Out of scope; the user owns the lifecycle.
- **Per-file dynamic switching.** A single file is associated with at most one user-configured server, plus any built-in servers that already match. No "try server A, fall back to server B" runtime logic.
- **Replacing built-in servers.** A user-configured server with the same `file_types` as a built-in does **not** disable the built-in. Both run; the LSP client merges their results.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-configured server should overwrite built-in servers. It doesn't make sense to have both servers running at the same time

Comment thread specs/GH8803/product.md Outdated

### Disabling a server in a workspace

1. User opens settings → "Code intelligence" → "Custom language servers".
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Code intelligence" doesn't exist in our settings

To start, I also think it's probably fine to not have a dedicated custom language servers section in UI

Comment thread specs/GH8803/product.md Outdated

1. User opens settings → "Code intelligence" → "Custom language servers".
2. Each configured server lists which workspaces it is enabled for.
3. User clicks the workspace row's `Disable` button. Warp shuts down that server's process for that workspace and removes the per-workspace enablement record.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure we are following the existing settings > code > indexing and projects UI

Comment thread specs/GH8803/product.md Outdated

1. **Binary not on PATH:** When a user enables a server whose `command[0]` is not on PATH, Warp shows: *"Could not start `<name>`: binary `<cmd>` not found on PATH."* The chip's `Enable` button is replaced with `Open settings`.
2. **Binary on PATH but spawn fails:** Warp shows: *"`<name>` exited with status `<n>`. Last 200 bytes of stderr: `<...>`."* with `Open settings` and `Retry` buttons.
3. **Spawn hangs:** A configurable `start_timeout` (default 5s) bounds the LSP `initialize` request. On timeout, Warp shows: *"`<name>` did not respond to `initialize` within 5s."* The server's process is killed.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to do anything special for custom LSPs here

Comment thread specs/GH8803/product.md Outdated
command = ["intelephense", "--stdio"]
file_types = ["php", "phtml"]
root_files = ["composer.json", "composer.lock"] # optional; default: file's parent dir
initialization_options = { storagePath = "/tmp/intelephense" } # optional, opaque to Warp
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have support for these configs yet. Fine to skip for V0

Comment thread specs/GH8803/product.md Outdated
name = "intelephense"
command = ["intelephense", "--stdio"]
file_types = ["php", "phtml"]
root_files = ["composer.json", "composer.lock"] # optional; default: file's parent dir
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use our current root repo detection logic

Comment thread specs/GH8803/product.md Outdated
file_types = ["php", "phtml"]
root_files = ["composer.json", "composer.lock"] # optional; default: file's parent dir
initialization_options = { storagePath = "/tmp/intelephense" } # optional, opaque to Warp
start_timeout_ms = 5000 # optional, default 5000
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove this

@kevinyang372
Copy link
Copy Markdown
Member

Thanks for writing this up! Left some comments on the product spec

…e LSPs

Addresses 7 inline comments from @kevinyang372 (maintainer) and 7 findings
from oz-for-oss (1 critical, 5 important, 1 suggestion):

Maintainer-driven changes (@kevinyang372):
- User-configured servers now OVERWRITE built-ins for matched file types
  in workspaces where enabled (was: coexist alongside built-ins).
- Settings UI follows existing Settings > Code > Indexing/Projects pattern;
  no new "Code Intelligence" section. Moved from follow-up into V0 scope.
- Drop user-supplied root_files (use Warp's existing root-repo detection).
- Drop initialization_options for V0 (no settings shape for nested config yet).
- Drop user-configurable start_timeout_ms (fixed 5s default in code).

oz-for-oss-driven changes:
- CRITICAL: Settings UI moved into V0 (was follow-up); product invariants
  11 and 14 require it.
- Crate boundaries: UserLspServerConfig now defined in crates/lsp/src/user_config.rs
  and re-exported, since crates/lsp cannot depend on app/.
- New required language_id field per server (file extension is insufficient
  for LSP identifiers like sh -> shellscript, phtml -> php).
- SECURITY: command/args change on enabled server no longer auto-restarts
  the new binary. Stored command fingerprint detects the change; chip
  re-appears with re-enable affordance; user must explicitly re-confirm.
- Process cleanup specifies tokio::process::Command with kill_on_drop(true)
  plus explicit child.kill().await; child.wait().await; on timeout/error
  paths (std::process::Child::drop is not a portable kill).
- Cross-entry duplicate file_types now rejected at parse time (with
  overwrite semantics, two custom servers claiming the same extension is
  a configuration error).
- Documented [[lsp.servers]] <-> custom_servers serde rename.

Invariants restructured: 16 testable invariants (was 15), each mapped to
a concrete test layer in the testing matrix.
@lonexreb
Copy link
Copy Markdown
Contributor Author

lonexreb commented May 3, 2026

Thanks for the thorough reviews! Pushed 4f85a49 revising both specs to address every comment. Summary of how each was resolved:

@kevinyang372 — maintainer comments

Comment Resolution
User-configured servers should overwrite built-ins (product.md:36) Reframed as a Goal; new invariants 8 and 9 specify built-in suppression in the enabled workspace + restoration on disable. Tech spec adds handled_extensions() to the built-in trait and a suppression filter in all_candidates_for(workspace).
"Code intelligence" section doesn't exist (product.md:66) Removed. Disable flow now lives under Settings → Code → Indexing/Projects per your direction.
Follow existing Settings → Code → Indexing/Projects pattern (product.md:68) Tech spec §8 is now "Settings UI (in scope for V0)" and explicitly extends that surface — no new section introduced.
No special timeout handling needed for custom LSPs (product.md:74) start_timeout_ms is no longer a config field; a fixed 5s default lives in code.
Use existing root repo detection (product.md:85) root_files removed from V0 config. should_suggest_for_repo defers to Warp's existing root-repo detection.
Skip initialization_options for V0 (product.md:86) Field removed from config + invariants; tracked under Follow-ups.
Remove start_timeout_ms (product.md:87) Done.

oz-for-oss findings

Finding Resolution
🚨 Settings UI cannot be follow-up (tech.md:279) Moved into V0 as Tech §8, hooked into the existing Settings → Code surface per @kevinyang372. Invariants 11/12/14 are now reachable.
⚠️ Crate boundary violation for UserLspServerConfig (tech.md:87) Type now defined in crates/lsp/src/user_config.rs and re-exported; app/src/settings/lsp.rs imports it. New "Crate boundaries" section in tech.md states the rule explicitly.
⚠️ File extension insufficient as languageId (tech.md:164) New required language_id field on every [[lsp.servers]] entry. The closed LanguageId enum is no longer extended; the didOpen dispatch site branches on candidate type. New invariant 15 covers forwarding.
⚠️ [SECURITY] auto-restart on command change (tech.md:191) enabled_custom_servers is now a HashMap<name, command_fingerprint> keyed on the user's last confirmation. Live ≠ stored fingerprint demotes the server to "pending re-confirmation"; chip re-appears with a "command changed — re-enable?" affordance; the new binary is never spawned without explicit user click. New invariant 11 covers this.
⚠️ Drop of Child not a portable kill (tech.md:206) Switched to tokio::process::Command with kill_on_drop(true) plus explicit child.kill().await; child.wait().await; on timeout, JSON-RPC error, and shutdown paths.
⚠️ Duplicate file_types resolution (product.md:35) With overwrite semantics, overlapping file_types is now a parse-time error. Cross-entry validation rejects the entire [lsp] block; invariant 2 covers it.
💡 [[lsp.servers]]custom_servers serde rename (tech.md:37) Documented in tech §2 with the explicit #[serde(rename = "servers")] mapping.

Net spec shape

V0 config is now minimal:

[[lsp.servers]]
name = "intelephense"
command = ["intelephense", "--stdio"]
file_types = ["php", "phtml"]
language_id = "php"

Four required fields, no optional V0 fields. root_files, initialization_options, start_timeout_ms, and ~/$VAR expansion are all tracked under Follow-ups.

Re-requesting review when you have a chance — happy to iterate further.

Self-review of commit 4f85a49 found four spec-internal inconsistencies
that were unrelated to the original PR review feedback. Fixing them
proactively before the maintainer re-reviews.

1. Settings UI section (tech.md §8) only described `Enabled → Disable`
   rows. Invariants 11, 12, and 14 also require Settings UI affordances
   for `Dismissed → Re-enable` and `Pending re-confirmation → Re-enable`,
   without which a dismissal could never be cleared from the UI.
   Expanded §8 into a full state table covering all three row states
   with their predicates and click behaviors.

2. Disable button silently broke invariant 12 ("the chip reappears on
   next file open"): if the server was previously dismissed, the
   dismissal record would still suppress the chip after Disable.
   Spelled out that Disable clears BOTH `enabled_custom_servers[name]`
   and any `dismissed_custom_servers` entry for the same server.
   Updated product.md disable section to match.

3. Chip-state cascade in §7 and the end-to-end flow had inconsistent
   ordering. The flow placed `binary not on PATH → Configure` last,
   so a not-enabled-not-dismissed server with a missing binary would
   render as `Enable` (contradicting product misconfig 1's intent).
   Reordered the cascade in §7 and the flow diagram to match: command-
   changed (security override) → dismissed → binary missing → Enable
   default → already-running.

4. Made the security-override behavior explicit in product invariant 11:
   the command-changed chip surfaces even if the server was previously
   dismissed in that workspace, because a `command` change is a security
   signal and outweighs dismissal noise-suppression.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed external-contributor Indicates that a PR has been opened by someone outside the Warp team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants