Skip to content

surround_context: resolve trailing edge of a symbol (#1038 / #1027) - #340

Merged
lukaszsamson merged 5 commits into
masterfrom
fix-1038-end-of-symbol
Jul 4, 2026
Merged

surround_context: resolve trailing edge of a symbol (#1038 / #1027)#340
lukaszsamson merged 5 commits into
masterfrom
fix-1038-end-of-symbol

Conversation

@lukaszsamson

Copy link
Copy Markdown
Collaborator

What

ElixirSense.Core.SurroundContext.Toxic.surround_context/2 mirrored Code.Fragment.surround_context/2, which returns :none when the cursor is one column past the last character of a symbol, and returns the enclosing remote call when the cursor is at the end of an alias before a .. Both break navigation:

How

Because toxic2 gives every node an exact end-exclusive range, resolve/3 classifies at the cursor and one column to its left:

  • at the trailing edge of an alias/var/atom/attribute leaf, the leaf wins even over an enclosing dot (→ end of Foo in Foo.bar() is the module Foo) — #1027;
  • otherwise, when the cursor resolves to nothing, retry one column left, surfacing only navigation targets (a key:/operator/sigil trailing edge stays :none) — #1038.

Safety

This never reaches across whitespace/separators: when the cursor is :none, a node found one column left cannot extend past it (contiguous integer ranges), so it must end exactly there. Fuzzed across lib + fixtures at every column:

  • 0 results where the cursor falls outside the returned [begin, end];
  • by construction the result is :none only where the pre-existing classification was — nothing regresses.

Only the two differential tests whose asserted column is now a trailing edge changed (both to the more-correct #1027/#1038 result); a dedicated trailing edge describe block covers the new behavior plus false-positive guards.

🤖 Generated with Claude Code

`SurroundContext.Toxic` mirrored `Code.Fragment.surround_context/2`, which
returns `:none` when the cursor sits one column past the last character of a
symbol, and returns the enclosing remote call when it sits at the end of an
alias before a `.`. Both break navigation: goto-definition / references / hover
fail at the visual end of a name (elixir-lsp/elixir-ls#1038), and an alias at
the end resolves to the function instead of the module (#1027 - the upstream
lexical fix elixir-lang/elixir#13150 was reverted).

Because toxic2 gives every node an exact end-exclusive range, `resolve/3` fixes
this precisely by classifying at the cursor and one column to its left:

  * at the trailing edge of an alias / var / atom / attribute leaf, the leaf
    wins even over an enclosing dot -> end of `Foo` in `Foo.bar()` is the module
    `Foo`, not the remote call (#1027);
  * otherwise, if the cursor resolves to nothing, retry one column left, but only
    surface a genuine navigation target (a `key:` / operator / sigil trailing
    edge stays `:none`) -> end of a bare symbol or of a whole remote call
    resolves (#1038).

This never reaches across whitespace or a separator: when the cursor is `:none`,
a node found one column left cannot extend past it (contiguous ranges), so it
must end exactly there. Fuzzed across lib + fixtures at every column: 0 results
where the cursor falls outside the returned [begin, end], and by construction the
result is `:none` only where the pre-existing classification was, so nothing
regresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232
lukaszsamson and others added 3 commits July 4, 2026 14:43
… test

Code.Fragment.surround_context behavior at the arity slash varies across Elixir
1.16-1.20; assert only Toxic's (version-independent) trailing-edge result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232
The trailing-edge override that makes the end of an alias before `.` resolve to
the module (#1027) was firing for any name leaf, including bare-atom / var /
attribute LHS of a dot. That regressed erlang remote calls: hovering the `.` in
`:timer.sleep(...)` resolved the module `:timer` instead of the function.

#1027 / elixir-lang/elixir#13150 is specifically about `:alias` (uppercase
Elixir modules), so restrict the override to `{:alias, _}`. Bare atoms / vars /
attributes still resolve at their true trailing edges via the `:none` retry (when
they are not shadowing a remote call), so #1038 is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232
@lukaszsamson
lukaszsamson requested a review from Copilot July 4, 2026 13:03
@lukaszsamson

Copy link
Copy Markdown
Collaborator Author

@codex review

Copilot AI 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.

Pull request overview

This PR adjusts ElixirSense.Core.SurroundContext.Toxic.surround_context/2 to resolve symbols at the trailing edge (caret one column past the last character), fixing navigation failures at the visual end of names and ensuring an alias before . resolves to the module rather than the enclosing remote call.

Changes:

  • Introduces resolve/3 to classify at the cursor and (when applicable) one column to the left, with special handling for alias-vs-dot trailing-edge behavior.
  • Restricts the one-column-left retry to “navigable” context shapes to avoid surfacing non-navigation lexical contexts.
  • Updates and extends tests with dedicated trailing-edge coverage and adjusts two regression expectations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
test/elixir_sense/core/surround_context/toxic_test.exs Updates regression assertions and adds a new describe block covering trailing-edge behavior and false-positive guards.
lib/elixir_sense/core/surround_context/toxic.ex Replaces direct classification with resolve/3 to support trailing-edge symbol resolution while preserving fallbacks to Code.Fragment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +77 to +96
defp resolve(ast, source, {line, column} = position) do
r0 = classify_at(ast, source, position)

if column > 1 do
r1 = classify_at(ast, source, {line, column - 1})

cond do
# #1027: at the trailing edge of a name leaf, the leaf wins even over an enclosing dot
trailing_name_leaf?(r1, position) -> r1
# otherwise keep whatever the cursor itself resolved to
r0 != :none -> r0
# #1038: the cursor resolved to nothing - retry one column left, but only surface a genuine
# navigation target (a `key:` / operator / sigil trailing edge stays `:none`, as before)
navigable_result?(r1) -> r1
true -> :none
end
else
r0
end
end

@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: ee88a26f47

ℹ️ 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".


cond do
# #1027: at the trailing edge of a name leaf, the leaf wins even over an enclosing dot
trailing_name_leaf?(r1, position) -> r1

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 Preserve operator contexts after aliases

When an alias is immediately followed by an operator, e.g. Foo+1 or Foo==Bar, r0 is the operator context at that cursor column, but this first condition returns r1 just because the alias ended there. That regresses hover/definition on the operator itself (which SurroundContext.to_binding/2 supports via {:operator, _}) and makes the operator column resolve to the left-hand module instead; the override should be limited to the dot case that #1027 targets, or to positions where r0 is :none.

Useful? React with 👍 / 👎.

Comment on lines +105 to +107
defp trailing_name_leaf?(%{context: {:alias, _}, end: ending}, position)
when ending == position,
do: true

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 Include qualified aliases in trailing-edge override

For aliases such as __MODULE__.Foo.bar, the one-column-left fallback returns the alias shape {:alias, {:local_or_var, '__MODULE__'}, 'Foo'} ending at the dot, while r0 is the enclosing remote call; because this helper only matches two-element aliases, the new #1027 rule still resolves the function instead of the module at the end of __MODULE__.Foo. to_binding/2 already knows how to resolve this qualified alias form, so it should participate in the same trailing-edge override.

Useful? React with 👍 / 👎.

…lified aliases

Review of #340:
- Perf: classify one column left lazily. The fast path returns immediately when the
  cursor already sits inside a real, in-range, non-dot result, so the common
  navigation path does a single AST descent instead of two.
- Operators: the alias trailing-edge override no longer fires when the cursor is on
  an operator (`Foo+1` / `Foo==Bar`) - those keep the operator context. The override
  is limited to the remote-call dot case (and to fallbacks that don't cover the
  cursor), which is what #1027 targets.
- Qualified aliases: the override now also matches `{:alias, _, _}` (e.g.
  `__MODULE__.Foo` in `__MODULE__.Foo.bar`), which `to_binding/2` already resolves.

A `covers?/2` guard keeps a lexical `Code.Fragment` fallback (whose begin can point
past the cursor) from short-circuiting the fast path, so the alias override can still
win there. Fuzzed every column across lib: the out-of-range result set is now
byte-identical to the pre-change baseline (zero new false positives), while the
end-of-alias regressions the naive dot-only gate introduced are fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232
@lukaszsamson
lukaszsamson merged commit ab70b1b into master Jul 4, 2026
17 checks passed
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.

2 participants