Skip to content

feat(lsp): hover, document symbols, go-to-definition and smarter completion 🔍 - #164

Merged
timfennis merged 9 commits into
masterfrom
feature/lsp-improvements
May 30, 2026
Merged

feat(lsp): hover, document symbols, go-to-definition and smarter completion 🔍#164
timfennis merged 9 commits into
masterfrom
feature/lsp-improvements

Conversation

@timfennis

Copy link
Copy Markdown
Owner

Context

The ndc_lsp server offered only inlay hints, dot-completion, and diagnostics. This adds the commonly-expected LSP features and, along the way, cleans up the document model and removes a per-request hot path. Prompted by a request to improve the LSP across features, architecture, and performance.

Changes

Architecture

  • DocumentState now stores the analysed AST + AnalysisResult side tables (verified Send + Sync) as the source of truth for position-based features, instead of only pre-flattened offset-keyed maps. The flat variable_types/expression_types maps are kept as a documented resilience cache so dot-completion keeps working while the buffer is mid-edit and doesn't parse.
  • Added a node_at_offset resolver to the AST visitor.

Performance

  • Native-function metadata is snapshotted once at startup (FunctionInfo); completion and hover no longer rebuild an interpreter per request.
  • New LineIndex gives O(log n) offset↔position conversion instead of rescanning from byte 0; completion holds the read lock instead of cloning document state.

Features

  • Hover — inferred type at the cursor; signature + docs for built-in functions.
  • Document symbols — outline of top-level/nested functions and variables.
  • Go-to-definition — jump from a usage to its declaration.
  • Completion — in-scope locals and language keywords in general completion.

Docs

  • New "Editor support" manual page; updated the VS Code extension README capabilities.

Note for reviewers

The original plan was to add a HashMap<ResolvedVar, Span> to the analyser for go-to-definition. While implementing I found that ResolvedVar::Local slots are stack-relative and reset to 0 per function (new_function_scope sets base_offset: 0), so such a map collides across functions and resolves to the wrong declaration. Go-to-definition is therefore implemented via lexical scope resolution over the AST — correct, and contained entirely within ndc_lsp with no analyser change.

🤖

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45b0434c06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/features/definition.rs Outdated
Comment thread ndc_lsp/src/features/hover.rs Outdated
@timfennis timfennis changed the title 🔍 feat(lsp): hover, document symbols, go-to-definition and smarter completion feat(lsp): hover, document symbols, go-to-definition and smarter completion 🔍 May 30, 2026
Comment thread ndc_lsp/src/features/completion.rs Outdated
Comment thread ndc_lsp/src/features/completion.rs
Comment thread ndc_lsp/src/features/definition.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06022f09af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/util/position.rs Outdated
timfennis and others added 4 commits May 30, 2026 08:39
…completion 🔍

Rework the document model to store the analysed AST and its AnalysisResult
side tables (both Send + Sync) as the source of truth, replacing the
pre-flattened offset-keyed type maps. This powers the new position-based
features and keeps the flat completion-support maps only as a documented
resilience cache for dot-completion mid-edit.

Features:
- Hover: inferred type at the cursor; signature + docs for built-in functions.
- Document symbols: outline of top-level/nested functions and variables.
- Go-to-definition: lexical scope resolution over the AST (local slot numbers
  are stack-relative and reset per function, so they can't key a global map).
- Completion: in-scope locals and language keywords in general completion.

Performance:
- Snapshot native-function metadata once at startup; completion and hover no
  longer rebuild an interpreter per request.
- LineIndex gives O(log n) offset<->position instead of rescanning from 0;
  completion holds the read lock instead of cloning document state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses PR review (Codex + Tim). All issues shared one cause: the LSP
re-derived name resolution by name only and ignored scope/binding info.

- New scope_resolve.rs: one scope-aware declaration walk with correct visibility
  (enclosing scope + declared-before-use; functions hoisted). Shared by
  go-to-definition and completion.
- go-to-def: a use before a later inner shadow now resolves to the outer binding
  (was: inner scope won by size before the visibility check).
- completion: in-scope locals only — a local from one function is no longer
  suggested inside another.
- hover: only show a built-in's signature when the identifier resolved to a
  global; a local shadowing a built-in (e.g. `let len = 1; len`) shows its type.
- Use AHashMap throughout ndc_lsp, per project convention.

Known gap: go-to-def is still name-only and does not disambiguate function
overloads. The follow-up that exposes the analyser's resolution closes this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Position.character is UTF-16 by default and the server advertises no other
encoding, but LineIndex counted Unicode scalar values — so positions after an
astral character (e.g. an emoji) mapped to the wrong byte offset for hover,
go-to-definition, and completion, and ranges had the symmetric error. Count
UTF-16 units in both directions; a column splitting a surrogate pair clamps to
the character's start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis
timfennis force-pushed the feature/lsp-improvements branch from 06022f0 to e5a491a Compare May 30, 2026 06:41
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Rebased onto master and addressed the UTF-16 column feedback (e5a491a). Ready for another pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5a491ad66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/backend.rs Outdated
When an edit leaves the buffer unparsable, update_source advanced source and
line_index but the cached AST/analysis stayed from the last good parse, so
hover, go-to-definition, document symbols, and inlay hints mapped old spans
onto the edited text (wrong ranges / symbols from deleted code). Track whether
the AST matches the current source (analysis_matches_source); these features
now no-op until the buffer parses again. Completion still works mid-edit via
the name/offset caches, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Addressed the stale-AST-vs-edited-source issue (e4bccb6). AST-backed features now no-op until the buffer parses again.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4bccb69cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/features/completion.rs Outdated
local_completions walks the cached AST's declaration spans against the current
source, so mid-edit (last parse failed) it could offer locals from stale scopes.
Skip the AST walk when analysis_matches_source is false; keywords and the
resilient dot-completion caches are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Gated local completion on fresh analysis too (34d9317).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34d9317005

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/backend.rs Outdated
Codex flagged a race where a slow `validate` could overwrite newer source.
Rather than patch that one spot, harden the whole staleness/concurrency class:

- Tag each document with the client's monotonic `version`. `validate` runs
  analysis off the lock and commits/publishes only if the stored version still
  matches — a stale run no longer rolls the buffer back.
- Commit analysis in place (`set_analysis`); never rewrite source/line_index
  that validate didn't produce.
- Publish diagnostics with the document version so clients discard superseded
  ones.
- `did_change`: with full-document sync, process only the final change.
- Add `did_close`: drop cached state (no unbounded growth) and clear diagnostics.
- Document the staleness model: AST-backed features gate on
  `analysis_matches_source`; the completion caches are intentionally resilient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Hardened the whole document-staleness/concurrency class (afbcdc5): version-guarded validate commit + versioned diagnostics, in-place analysis commit, single full-sync change per did_change, and a did_close handler. Model documented in backend.rs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afbcdc5789

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/scope_resolve.rs Outdated
Comment thread ndc_lsp/src/backend.rs
…lish 🔒

Two review findings:

- Lexical visibility: a `let`/for-loop binding was visible inside its own
  initializer, so `let x = x` (and `for x in x`) could resolve the RHS/sequence
  to the binding being declared. Bindings now become visible from the END of
  their initializer (RHS / sequence), matching the analyser, which resolves the
  initializer before creating the binding. Later iterations, guards, and the
  body still see the binding.
- Diagnostics race: a `validate` publish could slip past a concurrent
  `did_close` and leave stale diagnostics on a closed file. `validate` now holds
  the document lock across both the commit and the publish, and `did_close` does
  the same, so the two fully serialize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Fixed both (89cf627): bindings are now scoped from the end of their initializer (no self-reference in let x = x / for x in x), and validate/did_close hold the document lock across their diagnostics publishes so they can't race.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 89cf627c4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_lsp/src/visitor.rs
A reassignment target (`x` in `x = 2`) is an Lvalue, not an expression node, so
node_at_offset returned the enclosing assignment and go-to-def bailed. Resolve
the identifier under the cursor whether it's an expression use or an lvalue
identifier; reassignment targets now jump to the declaration. Declaration,
parameter, and loop-variable lvalues are matched too but resolve to nothing,
since they aren't visible to themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

Fixed (9720244): go-to-definition now works from assignment targets (x = 2 jumps to let x).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

s.expression_types.get(&dot_offset).cloned().or_else(|| {
let ident = identifier_before_dot(&s.source, offset)?;
s.variable_types.get(ident).cloned()

P2 Badge Scope the fallback used for dot completion

When the user types a new ident. at a position that was not present in the last successful AST, expression_types has no entry and this falls back to variable_types by name only. In a file with a top-level x: String and a later inner x: Int, typing a new top-level x. after that inner declaration can use the inner/out-of-scope type because the flat map was overwritten, so completion suggests methods for the wrong receiver. The fallback should resolve the visible declaration at the cursor instead of using an unscoped name lookup.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


let best = decls
.iter()
.filter(|d| d.name == name && is_visible(d, offset))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve definitions using the analysed overload

When multiple visible functions share a name but have different arities, this name-only filter can return the wrong declaration: for example, with fn foo(a) { ... } followed by fn foo(a, b) { ... }, go-to-definition from foo(1) will reduce to the later two-argument declaration even though the analyser resolves the one-argument overload. This makes the new definition feature inaccurate for valid overloaded functions; it needs to use the resolved binding/call arity rather than only d.name == name.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, and this is the known limitation the function doc already calls out: the interim resolver is name-only and can't disambiguate overloads. It's not fixable here — the analyser selects the right overload but records it as a stack slot, which isn't a stable identity and doesn't map back to a declaration span. That's exactly what the approved follow-up (parser name_span + analyser DefId side-table exposing resolved_uses) fixes, making go-to-def and hover overload- and shadowing-correct by construction. Deferring this to that PR rather than re-implementing overload resolution in code the follow-up will delete. All the soundness findings from this review round are fixed; merging on that basis.

@timfennis
timfennis merged commit 98be07d into master May 30, 2026
1 check passed
@timfennis
timfennis deleted the feature/lsp-improvements branch May 30, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant