Skip to content

fix(lsp): robustness of command arguments, URIs and positions - #928

Merged
psteinroe merged 2 commits into
mainfrom
fix/lsp-arguments-uris-positions
Oct 26, 2026
Merged

psteinroe merged 2 commits into
mainfrom
fix/lsp-arguments-uris-positions

Conversation

@psteinroe

Copy link
Copy Markdown
Collaborator

Three ways a client request could break the server or the document state.

pgls.executeStatement without arguments panicked

The handler read params.arguments[0] and params.arguments[1] directly, so workspace/executeCommand with no arguments panicked in the handler. Malformed arguments came back as an internal error. Both are now InvalidParams errors with a message that says which argument is wrong.

Documents of other schemes shared the workspace document of a file

Session::file_path mapped every URI to its path, whatever the scheme, and the workspace keys documents by path. So:

  • VS Code's git:/workspace/a.sql?{"ref":"HEAD"} (the left side of a diff) mapped to /workspace/a.sql. didOpen on it kept the existing document, and didClose removed the document of the real file. The next edit of the file failed with "not found", and its diagnostics were never published again.
  • Every cell of a notebook (vscode-notebook-cell:/nb.ipynb#W0..., #W1...) mapped to the same path.
  • untitled:Untitled-1 became the relative path Untitled-1, and ntitled-1 on Windows, where to_file_path strips the first character.

Only file: URIs map to file paths now. Other schemes are keyed by their whole URI, e.g. untitled:Untitled-1, which never equals a file's path and differs between notebook cells.

I gave them paths of their own rather than skipping them because untitled buffers and notebook cells are real editing surfaces, and people do write SQL in them. With their own key they keep diagnostics, completions and hover without touching any file's document. The trade-off is that path-based settings (files.include/ignore, the migrations directory) don't match them, which is right since they are not files. A git diff's old side is linted like any other buffer, which matches what VS Code does for other languages. The WASM worker's own documentSelector ({ language: 'sql' }) is unchanged.

Out-of-range positions failed the whole request

LineIndex::offset returned None for a line past the end, and a column past the end of a line ran into the next line or past the end of the text. Some clients send the line after the last one as the end of a range:

textDocument/inlayHint, range (0,0)-(2,0) on a two-line document without a final newline
-> Internal error: position Position { line: 2, character: 0 } is out of range

Positions from the client are now clamped (LineIndex::offset_clamped): a column past the end of its line means the end of the line, as the LSP specifies, and a line past the end means the end of the text. This applies to every handler that converts client positions (from_lsp::offset). The internal LineIndex::offset used for diffs stays exact. A huge UTF-16 column no longer overflows in wide_to_utf8_col. Inlay hints that can't be converted are left out with a warning instead of failing the response.

Tests

  • execute_statement_without_arguments_is_invalid: no arguments, and arguments of the wrong type, both get InvalidParams.
  • closing_a_document_of_another_scheme_keeps_the_file: open the file, open and close its git: version, edit the file. Diagnostics for the edit are published.
  • test_inlay_hints_utf16_and_out_of_range: hints after an emoji land on the right UTF-16 columns, and a range that ends at the end of the line, past it, or on the line after the last one gets the same hints.
  • Unit tests: document_path for file: (percent-decoding, drive colon), git:, notebook cells and untitled:, and clamping in UTF-16 including u32::MAX.

Not covered

fluent_uri (since #877) rejects URIs with unencoded spaces, so such a request fails to deserialize before it reaches the server. VS Code always encodes them, and fixing it would need a change in ls-types.

- pgls.executeStatement indexed its arguments without checking them, so
  a call without arguments panicked. Missing or malformed arguments are
  now an InvalidParams error.
- Documents of other schemes than file: shared the workspace document of
  the file with the same path: the git: side of a diff, every cell of a
  notebook. Closing the diff closed the file's document. They are now
  keyed by their whole URI.
- Positions past the end of a line or of the document failed the whole
  request. They are clamped, as the LSP specifies, and an inlay hint that
  can't be placed is left out instead of failing the response.
# Conflicts:
#	crates/pgls_lsp/tests/server.rs
@psteinroe
psteinroe merged commit 9435988 into main Oct 26, 2026
11 checks passed
@psteinroe
psteinroe deleted the fix/lsp-arguments-uris-positions branch October 26, 2026 09:12
psteinroe added a commit that referenced this pull request Oct 26, 2026
#943)

Stacked on #928.

Adds a `[workspace.lints]` table and opts every member crate into it (`[lints] workspace = true`, also in the `just new-crate` template), and turns on the panic lints for `pgls_lsp`, the crate that handles client input first.

**Workspace lints**

`clippy::todo`, `clippy::unimplemented` and `clippy::dbg_macro` are now denied everywhere (`dbg!` stays allowed in tests, as before). The existing occurrences are fixed, not allowed:

- `MemoryFileSystem::resolve_configuration` was a `todo!()`, so a configuration that `extends` a package name (not a path) panicked on the memory file system, which the WASM build uses. It returns `ResolveError::NotFound` now, which is reported as "can't resolve" like an unresolvable package on disk.
- The TypeScript bindings generator had `unimplemented!()` for array and object literals and for boolean schemas. It now prints `[a, b]`, `{ "k": v }`, `unknown` and `never`. The generated bindings don't change.
- `generate_tool_configuration` returns an error for `assists` and the transformation category instead of panicking. `unreachable!()` got a message.

**pgls_lsp: no `unwrap`, `expect` or indexing**

`pgls_lsp/src/lib.rs` warns on `clippy::unwrap_used`, `clippy::expect_used` and `clippy::indexing_slicing`, and CI's `-D warnings` turns them into errors. Tests are exempt (`allow-*-in-tests` in `clippy.toml`). On top of #928, which already removed the `params.arguments[0]` indexing, there were 21 findings (19 `unwrap`, 2 `expect`):

- 12 were `std::sync` lock unwraps. `pgls_lsp` uses `parking_lot` locks now, which can't be poisoned. Before, a request handler that panicked while holding the documents lock (`panic_to_lsp_error` catches the panic) poisoned the lock, and every later request on that session panicked too.
- `get_cursor_position` and completions `expect`ed/`unwrap`ped the client capabilities. They use `Session::position_encoding()`, which falls back to UTF-16 like the other handlers.
- A completion item whose range can't be converted is left out with a warning, instead of panicking the whole completion request.
- `diagnostic.advices(..)` errors are returned through `?`. Serialising the `executeStatement` arguments drops the action on failure. The unused `print_markup` (two unwraps) is removed.
- Two can't fail and are `#[expect(..., reason = "...")]`: the `u32` offset in `LineIndex::new` (documents over 4 GiB aren't supported anywhere) and the `ConfigurationStatus` atomic, which only ever stores a `ConfigurationStatus as u8`.

**Counts for the next crate**

`pgls_workspace` (non-test code) has 76 findings: 48 `unwrap`, 5 `expect`, 16 indexing and 7 slicing. About 34 of the unwraps are lock unwraps, which `parking_lot` would remove the same way. `matcher/pattern.rs` (20, the glob matcher's index arithmetic) and `server.rs` (16) have most of the rest. For reference, the same measurement over the other crates found 178 in `pgls_configuration` (mostly generated code), 89 in `pgls_pretty_print`, 70 in `pgls_catalog` and 39 in `pgls_treesitter`.

**Risks**

Open PRs that add `std::sync` locks with `.unwrap()` to `pgls_lsp` (#895, #908) will fail clippy once this is merged. The fix is to use `parking_lot::{Mutex, RwLock}` and drop the `.unwrap()`. `client_capabilities()` stays because #908, #911 and #919 use it.

Tests: `cargo clippy --workspace --all-targets --all-features` is clean, `pgls_lsp` and `pgls_fs` tests pass, and `just gen-lint` leaves the generated files unchanged.

**Follow-ups:** #895, #908 and #936 now use `parking_lot` locks. Only #881's own `projects` and `workspace_folders` locks remain to be switched when the two meet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant