Skip to content

Commit 291d197

Browse files
lukaszsamsonclaude
andcommitted
Address PR review: reuse parsed AST, hoist neutralize, filter fix
- document_symbols: reuse the AST `Parser.parse_immediate/2` already produced on `Parser.Context.ast` instead of reparsing the raw source with toxic2. That reparse was an extra parse for .ex/.exs and nonsensical for .eex/.html-eex (which are EEx-derived Code ASTs, not toxic2 trees). - selection_ranges: neutralize the toxic2 error nodes once in `selection_ranges/3` and pass the neutralized AST to `delimiter_pair_ranges/4` and `ast_node_ranges/4`, instead of re-walking/neutralizing the whole tree once per cursor position. - completion_engine: compute the loaded/application module-name list once per struct-module filter pass rather than per candidate (`has_struct_submodule?` was O(N^2)). Mirrors the elixir_sense copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232
1 parent f8454b3 commit 291d197

3 files changed

Lines changed: 44 additions & 43 deletions

File tree

apps/elixir_ls_utils/lib/completion_engine.ex

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -722,18 +722,37 @@ defmodule ElixirLS.Utils.CompletionEngine do
722722
end
723723

724724
defp struct_module_filter(true, %State.Env{} = _env, %Metadata{} = metadata) do
725+
# Build the loaded/application module-name list ONCE for the whole filter pass
726+
# rather than recomputing `:code.all_loaded()` (and the application module scan)
727+
# for every candidate module - that turned the filter into O(N^2) work.
728+
all_module_names = all_module_names()
729+
725730
fn module ->
726731
Struct.is_struct(module, metadata.structs) or
727-
has_struct_submodule?(module, metadata.structs)
732+
has_struct_submodule?(module, metadata.structs, all_module_names)
728733
end
729734
end
730735

731736
defp struct_module_filter(false, %State.Env{} = _env, %Metadata{} = _metadata) do
732737
fn _ -> true end
733738
end
734739

740+
# The loaded (and, in interactive mode, application) module names as strings.
741+
# Computed once per struct-module filter pass, not per candidate module.
742+
defp all_module_names do
743+
modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0)))
744+
745+
case :code.get_mode() do
746+
:interactive ->
747+
modules ++ Enum.map(Applications.get_modules_from_applications(), &Atom.to_string/1)
748+
749+
_ ->
750+
modules
751+
end
752+
end
753+
735754
# Check if a module has any direct submodules that are structs
736-
defp has_struct_submodule?(module, structs) do
755+
defp has_struct_submodule?(module, structs, all_module_names) do
737756
module_str = Atom.to_string(module)
738757

739758
# Check metadata structs (from current buffer)
@@ -747,30 +766,14 @@ defmodule ElixirLS.Utils.CompletionEngine do
747766
if metadata_result do
748767
true
749768
else
750-
# Get all modules and check if any direct submodule is a struct
769+
# Check if any direct submodule (from the precomputed list) is a struct
751770
module_str_with_dot = module_str <> "."
752771

753-
# Get all loaded modules
754-
modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0)))
755-
756-
# Add modules from applications if in interactive mode
757-
modules =
758-
case :code.get_mode() do
759-
:interactive ->
760-
modules ++
761-
Enum.map(Applications.get_modules_from_applications(), &Atom.to_string/1)
762-
763-
_ ->
764-
modules
765-
end
766-
767-
# Find submodules
768772
submodules =
769-
for mod <- modules,
773+
for mod <- all_module_names,
770774
String.starts_with?(mod, module_str_with_dot),
771775
do: String.to_atom(mod)
772776

773-
# Check if any submodule is a struct
774777
Enum.any?(submodules, fn mod ->
775778
Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1)
776779
end)

apps/language_server/lib/language_server/providers/document_symbols.ex

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,17 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do
2424
:deprecated
2525
]
2626

27-
def symbols(uri, %Parser.Context{source_file: source_file}, hierarchical) do
28-
# Parse with the error-tolerant toxic2 parser so every node carries a `range:` (end-exclusive,
29-
# 1-based) - that is the source of every symbol's range and selection range, replacing the old
30-
# token-metadata end-position heuristics. No `literal_encoder` here: wrapping literals would
31-
# change the atom/keyword shapes that the `extract_*` clauses pattern-match on (defstruct fields
32-
# etc.). Structural nodes (def/defmodule/@/...) carry ranges regardless.
33-
{ast, diagnostics} = Toxic2.parse_to_ast(source_file.text, token_metadata: true, range: true)
34-
35-
# `{:__error__, ...}` placeholder nodes (best-effort recovery on invalid code) carry map args
36-
# that crash `Macro` traversal; neutralize them via the shared helper (keep_range: true so the
37-
# `range:` meta survives - it is the source of every symbol's range).
27+
def symbols(uri, %Parser.Context{source_file: source_file, ast: ast}, hierarchical) do
28+
# Reuse the AST `Parser.parse_immediate/2` already produced on the context rather than
29+
# reparsing the raw source here. For `.ex/.exs` that is the error-tolerant, already-neutralized
30+
# toxic2 tree carrying `range:` meta (end-exclusive, 1-based) - the source of every symbol's
31+
# range and selection range. For `.eex/.html-eex` it is the EEx-derived Code AST (reparsing the
32+
# raw template text with toxic2 would be nonsensical); such nodes lack `range:` meta, so
33+
# `location_to_range/2` degrades to a zero-width range at the node's `line`/`column` anchor.
34+
# No `literal_encoder` is applied in either path, so the atom/keyword shapes the `extract_*`
35+
# clauses pattern-match on (defstruct fields etc.) are preserved.
3836
symbols =
3937
ast
40-
|> ElixirSense.Core.Parser.neutralize_errors(diagnostics, true)
4138
|> extract_modules()
4239
|> Enum.reject(&is_nil/1)
4340

apps/language_server/lib/language_server/providers/selection_ranges.ex

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do
4444
end
4545
)
4646

47-
parse_result = {:ok, ast}
47+
# Neutralize `{:__error__, ...}` placeholder nodes once here rather than once per cursor
48+
# position - `delimiter_pair_ranges/4` and `ast_node_ranges/4` both walk this tree for every
49+
# requested position, so neutralizing inside them repeated an O(AST) traversal per cursor.
50+
# `surround_context_ranges/4` keeps the raw `ast` (it does its own handling).
51+
neutralized_parse_result =
52+
{:ok, ElixirSense.Core.Parser.neutralize_errors(ast, [], true)}
4853

4954
comment_groups = group_comments(comments)
5055

@@ -61,12 +66,12 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do
6166
cell_pair_ranges = cell_pair_ranges(lines, cell_pairs, line, character)
6267

6368
delimiter_pair_ranges =
64-
delimiter_pair_ranges(parse_result, lines, line, character)
69+
delimiter_pair_ranges(neutralized_parse_result, lines, line, character)
6570
|> deduplicate
6671

6772
comment_block_ranges = comment_block_ranges(lines, comment_groups, line, character)
6873

69-
ast_node_ranges = ast_node_ranges(parse_result, line, character, options)
74+
ast_node_ranges = ast_node_ranges(neutralized_parse_result, line, character, options)
7075

7176
surround_context_ranges = surround_context_ranges(ast, text, line, character)
7277

@@ -150,9 +155,8 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do
150155
# tokenizer-driven token-pair pass (FoldingRange.Token/TokenPair). String/heredoc/sigil ranges,
151156
# which the old special-token pass produced, already come from `ast_node_ranges` (the toxic AST
152157
# nodes carry `range:`), so they are not reproduced here.
158+
# `ast` is already neutralized by the caller (`selection_ranges/3`).
153159
def delimiter_pair_ranges({:ok, ast}, lines, line, character) do
154-
ast = ElixirSense.Core.Parser.neutralize_errors(ast, [], true)
155-
156160
{_ast, {acc, _stack}} =
157161
Macro.traverse(
158162
ast,
@@ -414,13 +418,10 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do
414418

415419
@empty_node {:__block__, [], []}
416420

421+
# `ast` is already neutralized by the caller (`selection_ranges/3`) - toxic2's best-effort
422+
# `{:__error__, meta, %{...}}` nodes (whose map args would crash `Macro.traverse`) have been
423+
# replaced there, once per parse rather than once per cursor position.
417424
def ast_node_ranges({:ok, ast}, line, character, _options) do
418-
# toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose
419-
# map args would crash `Macro.traverse`; neutralize them here so this function is safe for any
420-
# toxic2 AST regardless of caller. No diagnostics in scope (this is range-only and `__error__`
421-
# nodes carry no `range:`, so the diagnostics-driven call-arg cleaning is a no-op for ranges).
422-
ast = ElixirSense.Core.Parser.neutralize_errors(ast, [], true)
423-
424425
{_new_ast, {acc, [@empty_node]}} =
425426
Macro.traverse(
426427
ast,

0 commit comments

Comments
 (0)