From 1fb5636448d4ed83c75e472b591d8f4d44c7a92a Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 15:59:25 +0200 Subject: [PATCH 01/15] Use toxic2 via elixir_sense path dep; reimplement selection ranges on toxic2 ranges - Point elixir_sense to a local path dep (carries the toxic2-backed parser) - Reimplement SelectionRanges AST node ranges using toxic2's range: metadata and comments from Toxic2.string_to_quoted_with_comments, dropping the bespoke AstUtils.node_range computation - Delete now-unused AstUtils module and its tests - Adjust document_symbols test for toxic2-recovered AST Co-Authored-By: Claude Fable 5 --- apps/debug_adapter/mix.exs | 2 +- apps/elixir_ls_utils/mix.exs | 2 +- .../lib/language_server/ast_utils.ex | 440 ---------------- .../providers/selection_ranges.ex | 165 ++++-- apps/language_server/mix.exs | 2 +- apps/language_server/test/ast_utils_test.exs | 483 ------------------ .../test/providers/document_symbols_test.exs | 18 +- 7 files changed, 149 insertions(+), 963 deletions(-) delete mode 100644 apps/language_server/lib/language_server/ast_utils.ex delete mode 100644 apps/language_server/test/ast_utils_test.exs diff --git a/apps/debug_adapter/mix.exs b/apps/debug_adapter/mix.exs index 67ccf7a61..c2aedc608 100644 --- a/apps/debug_adapter/mix.exs +++ b/apps/debug_adapter/mix.exs @@ -38,7 +38,7 @@ defmodule ElixirLS.DebugAdapter.MixProject do defp deps do [ - {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, + {:elixir_sense, path: "../../../../elixir_sense"}, {:schematic_v, github: "elixir-lsp/schematic_vendored", ref: @dep_versions[:schematic_vendored]}, {:typed_struct, "~> 0.3"}, diff --git a/apps/elixir_ls_utils/mix.exs b/apps/elixir_ls_utils/mix.exs index 28866fc93..d1fb545a8 100644 --- a/apps/elixir_ls_utils/mix.exs +++ b/apps/elixir_ls_utils/mix.exs @@ -37,7 +37,7 @@ defmodule ElixirLS.Utils.MixProject do defp deps do [ - {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, + {:elixir_sense, path: "../../../../elixir_sense"}, {:jason_v, github: "elixir-lsp/jason", ref: @dep_versions[:jason_v]}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", ref: @dep_versions[:dialyxir_vendored], runtime: false} diff --git a/apps/language_server/lib/language_server/ast_utils.ex b/apps/language_server/lib/language_server/ast_utils.ex deleted file mode 100644 index 04c8623ad..000000000 --- a/apps/language_server/lib/language_server/ast_utils.ex +++ /dev/null @@ -1,440 +0,0 @@ -# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense - -defmodule ElixirLS.LanguageServer.AstUtils do - import ElixirLS.LanguageServer.RangeUtils - alias ElixirLS.LanguageServer.SourceFile - - @binary_operators ~w[| . ** * / + - ++ -- +++ --- .. <> in |> <<< >>> <<~ ~>> <~ ~> <~> < > <= >= == != === !== =~ && &&& and || ||| or = => :: when <- -> \\]a - @unary_operators ~w[@ + - ! ^ not &]a - - def node_range(node, options \\ []) - def node_range(atom, _options) when is_atom(atom), do: nil - - def node_range([{{:__block__, meta, [_]} = first, _} | _] = list, _options) do - if Keyword.get(meta, :format) == :keyword or Keyword.has_key?(meta, :assoc) or - Version.match?(System.version(), "< 1.18.0-dev") do - case List.last(list) do - {_, last} -> - case {node_range(first), node_range(last)} do - {range(start_line, start_character, _, _), range(_, _, end_line, end_character)} -> - range(start_line, start_character, end_line, end_character) - - _ -> - nil - end - - _ -> - nil - end - end - end - - def node_range(list, _options) when is_list(list), do: nil - - def node_range({{:__block__, meta, [_]} = first, last}, _options) do - if Keyword.get(meta, :format) == :keyword or Keyword.has_key?(meta, :assoc) or - Version.match?(System.version(), "< 1.18.0-dev") do - case {node_range(first), node_range(last)} do - {range(start_line, start_character, _, _), range(_, _, end_line, end_character)} -> - range(start_line, start_character, end_line, end_character) - - _ -> - nil - end - end - end - - def node_range({:__block__, meta, args} = _ast, _options) do - line = Keyword.get(meta, :line) - column = Keyword.get(meta, :column) - - if line == nil or column == nil do - if match?([_ | _], args) do - first = List.first(args) - last = List.last(args) - - case {node_range(first), node_range(last)} do - {range(start_line, start_character, _, _), range(_, _, end_line, end_character)} -> - range(start_line, start_character, end_line, end_character) - - _ -> - nil - end - end - else - line = line - 1 - column = column - 1 - - {end_line, end_column} = - cond do - token = meta[:token] -> - {line, column + String.length(token)} - - end_location = meta[:closing] -> - # 2 element tuple - {end_location[:line] - 1, end_location[:column] - 1 + 1} - - match?(kind when kind in [:atom, :keyword], Keyword.get(meta, :format)) -> - [literal] = args - - modifier = - if literal in [true, false, nil] do - 1 - else - 0 - end - - get_literal_end(literal, {line, column + modifier}, nil) - - match?([_], args) -> - [literal] = args - delimiter = meta[:delimiter] - - if delimiter in ["\"\"\"", "'''"] do - literal = - if is_list(literal) do - to_string(literal) - else - literal - end - - lines = SourceFile.lines(literal) - # TODO meta[:indentation] is nil on 1.12, not sure this is needed in 1.13+ - indentation = Keyword.get(meta, :indentation, 0) - - {line + length(lines), indentation + get_delimiter_length(delimiter)} - else - get_literal_end(literal, {line, column}, delimiter) - end - - true -> - {line, column} - end - - range(line, column, end_line, end_column) - end - end - - # interpolated charlist AST is too complicated to handle via the generic algorithm - def node_range({{:., _, [List, :to_charlist]}, meta, _args} = ast, options) do - line = Keyword.get(meta, :line) - 1 - column = Keyword.get(meta, :column) - 1 - {end_line, end_column} = get_eoe_by_formatting(ast, {line, column}, options) - - # on elixir 1.15-1.17 formatter changes charlist '' to ~c"" sigil so we need to correct columns - # if charlist is single line - correction = - if end_line == line and Version.match?(System.version(), "< 1.18.0-dev") do - 2 - else - 0 - end - - range(line, column, end_line, end_column - correction) - end - - # interpolated atom AST is too complicated to handle via the generic algorithm - def node_range({{:., _, [:erlang, :binary_to_atom]}, meta, _args} = ast, options) do - line = Keyword.get(meta, :line) - 1 - column = Keyword.get(meta, :column) - 1 - {end_line, end_column} = get_eoe_by_formatting(ast, {line, column}, options) - range(line, column, end_line, end_column) - end - - def node_range({form, meta, args} = ast, options) do - line = Keyword.get(meta, :line) - column = Keyword.get(meta, :column) - - if line == nil or column == nil do - nil - else - line = line - 1 - column = column - 1 - - start_position = - cond do - form == :%{} -> - column = - if Version.match?(System.version(), "< 1.17.0-dev") do - # workaround elixir bug - # https://github.com/elixir-lang/elixir/commit/fd4e6b530c0e010712b06909c89820b08e49c238 - column - 1 - else - column - end - - {line, column} - - form == :-> and match?([[_ | _], _], args) -> - [[left | _], _right] = args - - case node_range(left) do - range(line, column, _, _) -> - {line, column} - - nil -> - nil - end - - form == :& and match?([int] when is_integer(int), args) -> - {line, column} - - form in @binary_operators and match?([_, _], args) -> - [left, _right] = args - - case node_range(left) do - range(line, column, _, _) -> - {line, column} - - nil -> - nil - end - - match?({:., _meta, [Kernel, :to_string]}, form) -> - if Keyword.get(meta, :from_interpolation) do - {line, column} - end - - match?({:., _meta, [Access, :get]}, form) and match?([_ | _], args) -> - if Keyword.get(meta, :from_brackets) do - [arg | _] = args - - case node_range(arg) do - range(line, column, _, _) -> - {line, column} - - nil -> - nil - end - end - - match?({:., _, [_ | _]}, form) -> - {:., _, [module_or_var | _]} = form - - case node_range(module_or_var) do - range(line, column, _, _) -> - {line, column} - - nil -> - nil - end - - true -> - {line, column} - end - - end_position = - cond do - end_location = meta[:end] -> - {end_location[:line] - 1, end_location[:column] - 1 + 3} - - end_location = meta[:end_of_expression] -> - {end_location[:line] - 1, end_location[:column] - 1} - - end_location = meta[:closing] -> - closing_length = - case form do - :<<>> -> 2 - :fn -> 3 - _ -> 1 - end - - {end_location[:line] - 1, end_location[:column] - 1 + closing_length} - - form == :__aliases__ -> - last = meta[:last] - - if last do - last_length = - case List.last(args) do - atom when is_atom(atom) -> atom |> to_string() |> String.length() - _ -> 0 - end - - {last[:line] - 1, last[:column] - 1 + last_length} - else - # TODO last is nil on 1.12, not sure this is needed in 1.13+ - get_eoe_by_formatting(ast, {line, column}, options) - end - - form == :% and match?([_, _], args) -> - [_alias, map] = args - - case node_range(map) do - range(_, _, end_line, end_column) -> - {end_line, end_column} - - nil -> - nil - end - - form == :<<>> or (is_atom(form) and String.starts_with?(to_string(form), "sigil_")) -> - # interpolated string AST is too complicated - # try to format it instead - get_eoe_by_formatting(ast, {line, column}, options) - - form == :& and match?([int] when is_integer(int), args) -> - [int] = args - {line, column + String.length(to_string(int))} - - form in @binary_operators and match?([_, _], args) -> - [_left, right] = args - - case node_range(right) do - range(_, _, end_line, end_column) -> - {end_line, end_column} - - nil -> - # e.g. inside form of a call - not enough meta {:., _, [alias, atom]} - nil - end - - form in @unary_operators and match?([_], args) -> - [right] = args - - case node_range(right) do - range(_, _, end_line, end_column) -> - {end_line, end_column} - - nil -> - nil - end - - match?({:., _, [_, _]}, form) -> - case args do - [] -> - {:., _, [_, fun]} = form - {line, column + String.length(to_string(fun))} - - _ -> - case node_range(List.last(args)) do - range(_, _, end_line, end_column) -> - {end_line, end_column} - - nil -> - nil - end - end - - is_atom(form) -> - variable_length = form |> to_string() |> String.length() - - case args do - nil -> - {line, column + variable_length} - - [] -> - {line, column + variable_length} - - _ -> - # local call no parens - last_arg = List.last(args) - - case node_range(last_arg) do - range(_, _, end_line, end_column) -> - {end_line, end_column} - - nil -> - nil - end - end - - true -> - raise "unhandled block" - end - - case {start_position, end_position} do - {{start_line, start_column}, {end_line, end_column}} -> - range(start_line, start_column, end_line, end_column) - - _ -> - nil - end - end - end - - def node_range(_, _options), do: nil - - def get_literal_end(true, {line, column}, _), do: {line, column + 4} - def get_literal_end(false, {line, column}, _), do: {line, column + 5} - def get_literal_end(nil, {line, column}, _), do: {line, column + 3} - - def get_literal_end(atom, {line, column}, delimiter) when is_atom(atom) do - delimiter_length = get_delimiter_length(delimiter) - lines = atom |> to_string() |> SourceFile.lines() - - case lines do - [only_line] -> - # add : - {line, column + String.length(only_line) + 1 + 2 * delimiter_length} - - _ -> - last_line_length = lines |> List.last() |> String.length() - {line + length(lines) - 1, last_line_length + 1 * delimiter_length} - end - end - - def get_literal_end(list, {line, column}, delimiter) when is_list(list) do - delimiter_length = get_delimiter_length(delimiter) - lines = list |> to_string() |> SourceFile.lines() - - case lines do - [only_line] -> - # add 2 x ' - {line, column + String.length(only_line) + 2 * delimiter_length} - - _ -> - # add 1 x ' - last_line_length = lines |> List.last() |> String.length() - {line + length(lines) - 1, last_line_length + 1 * delimiter_length} - end - end - - def get_literal_end(binary, {line, column}, delimiter) when is_binary(binary) do - delimiter_length = get_delimiter_length(delimiter) - lines = binary |> SourceFile.lines() - - case lines do - [only_line] -> - # add 2 x " - {line, column + String.length(only_line) + 2 * delimiter_length} - - _ -> - # add 1 x " - last_line_length = lines |> List.last() |> String.length() - {line + length(lines) - 1, last_line_length + 1 * delimiter_length} - end - end - - def get_delimiter_length(nil), do: 0 - def get_delimiter_length("\""), do: 1 - def get_delimiter_length("'"), do: 1 - def get_delimiter_length("\"\"\""), do: 3 - def get_delimiter_length("'''"), do: 3 - - defp get_eoe_by_formatting(ast, {line, column}, options) do - formatter_opts = Keyword.get(options, :formatter_opts, []) - locals_without_parens = Keyword.get(formatter_opts, :locals_without_parens, []) - line_length = Keyword.get(formatter_opts, :line_length, 98) - - code = - ast - |> Code.quoted_to_algebra( - escape: false, - locals_without_parens: locals_without_parens - ) - |> Inspect.Algebra.format(line_length) - |> IO.iodata_to_binary() - - lines = code |> SourceFile.lines() - - case lines do - [_] -> - {line, column + String.length(code)} - - _ -> - last_line = List.last(lines) - {line + length(lines) - 1, String.length(last_line)} - end - end -end diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index 18212c895..2b60c8375 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -20,7 +20,6 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Providers.FoldingRange import ElixirLS.LanguageServer.RangeUtils - alias ElixirLS.LanguageServer.AstUtils defp token_length(:end), do: 3 defp token_length(token) when token in [:"(", :"[", :"{", :")", :"]", :"}"], do: 1 @@ -46,20 +45,23 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do formatted_lines = FoldingRange.Line.format_string(text) - comment_groups = - formatted_lines - |> FoldingRange.CommentBlock.group_comments() - - parse_result = - Code.string_to_quoted(text, + # AST node ranges and comments both come from the error-tolerant toxic2 parser. + # `range: true` attaches `range: {{start_line, start_col}, {end_line, end_col}}` (end-exclusive, + # 1-based) to every node that corresponds to source; the literal_encoder gives bare literals a + # meta slot so they carry a range too. + {ast, _diagnostics, comments} = + Toxic2.string_to_quoted_with_comments(text, token_metadata: true, - columns: true, - unescape: false, + range: true, literal_encoder: fn literal, meta -> {:ok, {:__block__, meta, [literal]}} end ) + parse_result = {:ok, ast} + + comment_groups = group_comments(comments) + cell_pairs = formatted_lines |> Enum.map(&FoldingRange.Indentation.extract_cell/1) @@ -362,47 +364,29 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do @empty_node {:__block__, [], []} - def ast_node_ranges({:ok, ast}, line, character, options) do - node_range_options = [ - formatter_opts: Keyword.get(options, :formatter_opts, []) - ] + def ast_node_ranges({:ok, ast}, line, character, _options) do + # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose + # map args would crash `Macro.traverse`; neutralize them here so this function is safe for any + # toxic2 AST regardless of caller. + ast = neutralize_errors(ast) {_new_ast, {acc, [@empty_node]}} = Macro.traverse( ast, {[], [@empty_node]}, fn - ast, {acc, [parent_ast_from_stack | _] = parent_ast} -> + ast, {acc, [_parent_ast_from_stack | _] = parent_ast} -> matching_range = - case AstUtils.node_range(ast, node_range_options) do - range(start_line, start_character, end_line, end_character) -> - start_character = - if match?({:%{}, _, _}, ast) and match?({:%, _, _}, parent_ast_from_stack) and - Version.match?(System.version(), "< 1.17.0-dev") do - # workaround elixir bug - # https://github.com/elixir-lang/elixir/commit/fd4e6b530c0e010712b06909c89820b08e49c238 - # undo column offset for structs inner map node - start_character + 1 - else - start_character - end - - range = range(start_line, start_character, end_line, end_character) - + case node_range_from_meta(ast) do + range(start_line, start_character, end_line, end_character) = range -> if (start_line < line or (start_line == line and start_character <= character)) and (end_line > line or (end_line == line and end_character >= character)) do - # dbg({ast, range, parent_ast_from_stack}) - # {ast, {[range | acc], [ast | parent_ast]}} range else - # dbg({ast, range, {line, character}, "outside"}) - # {ast, {acc, [ast | parent_ast]}} nil end nil -> - # dbg({ast, "nil"}) - # {ast, {acc, [ast | parent_ast]}} nil end @@ -487,6 +471,117 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do def ast_node_ranges(_, _, _, _), do: [] + # Read a node's source range straight from the toxic2 `range:` meta (end-exclusive, 1-based), + # converting to the provider's 0-based ranges. A 2-tuple (keyword/tuple pair) has no meta of its + # own, so its range spans from its key's start to its value's end. + # the map-update `|` (`%{m | k: v}`) carries no `range:` of its own; span it across its operands + defp node_range_from_meta({:|, meta, [left, right]}) do + range_from_meta(meta) || union_ranges(node_range_from_meta(left), node_range_from_meta(right)) + end + + defp node_range_from_meta({_form, meta, _args}) when is_list(meta) do + case range_from_meta(meta) do + # interpolation's `Kernel.to_string` node is macro-generated (no `range:`), but carries the + # `#{` start (line/column) and the `}` (closing) - derive the `#{...}` span from those + nil -> interpolation_range(meta) + range -> range + end + end + + defp node_range_from_meta({left, right}) do + case {child_range(left), child_range(right)} do + {range(sl, sc, _, _), range(_, _, el, ec)} -> range(sl, sc, el, ec) + {range(sl, sc, el, ec), nil} -> range(sl, sc, el, ec) + {nil, range(sl, sc, el, ec)} -> range(sl, sc, el, ec) + {nil, nil} -> nil + end + end + + defp node_range_from_meta([_ | _] = list) do + # a keyword/pair list (`a: 1, b: 2`) is a plain list with no meta of its own; span it from the + # first pair's start to the last pair's end + if Enum.all?(list, &match?({_key, _value}, &1)) do + case {node_range_from_meta(List.first(list)), node_range_from_meta(List.last(list))} do + {range(sl, sc, _, _), range(_, _, el, ec)} -> range(sl, sc, el, ec) + _ -> nil + end + end + end + + defp node_range_from_meta(_), do: nil + + defp range_from_meta(meta) do + case Keyword.get(meta, :range) do + {{start_line, start_column}, {end_line, end_column}} -> + range(start_line - 1, start_column - 1, end_line - 1, end_column - 1) + + _ -> + nil + end + end + + defp child_range({_form, meta, _args}) when is_list(meta), do: range_from_meta(meta) + defp child_range(_), do: nil + + defp union_ranges(range(sl, sc, _, _), range(_, _, el, ec)), do: range(sl, sc, el, ec) + defp union_ranges(range(sl, sc, el, ec), nil), do: range(sl, sc, el, ec) + defp union_ranges(nil, range(sl, sc, el, ec)), do: range(sl, sc, el, ec) + defp union_ranges(nil, nil), do: nil + + defp interpolation_range(meta) do + with true <- Keyword.get(meta, :from_interpolation, false), + [line: closing_line, column: closing_column] <- Keyword.get(meta, :closing), + start_line when is_integer(start_line) <- Keyword.get(meta, :line), + start_column when is_integer(start_column) <- Keyword.get(meta, :column) do + range(start_line - 1, start_column - 1, closing_line - 1, closing_column) + else + _ -> nil + end + end + + # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` placeholder + # nodes whose map args would crash `Macro.traverse`. Rewrite them to a harmless empty-arg node. + defp neutralize_errors({:__error__, meta, args}) when not is_list(args), + do: {:__error__, meta, []} + + # defensive: any 3-tuple whose args is not a list (only `{:__error__, _, %{}}` today) would crash + # `Macro.traverse`; drop the non-list args so traversal is always safe + defp neutralize_errors({form, meta, args}) when not is_list(args), + do: {neutralize_errors(form), meta, []} + + defp neutralize_errors({form, meta, args}), + do: {neutralize_errors(form), meta, neutralize_errors(args)} + + defp neutralize_errors({left, right}), + do: {neutralize_errors(left), neutralize_errors(right)} + + defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) + + defp neutralize_errors(other), do: other + + # Group toxic2 comments into blocks compatible with `comment_block_ranges/4`: each block is a list + # of `{{row, column}, "#"}` cells in reverse source order (most recent first), matching + # `FoldingRange.CommentBlock.group_comments/1`. Only full-line comments form blocks (an inline + # comment has `previous_eol_count == 0`); a blank line between comments (`previous_eol_count >= 2`) + # starts a new block. + defp group_comments(comments) do + comments + |> Enum.filter(&(&1.previous_eol_count > 0)) + |> Enum.reduce([], fn comment, groups -> + cell = {comment.line - 1, comment.column - 1} + entry = {cell, "#"} + + case groups do + [[{{previous_row, _}, _} | _] = current | rest] + when comment.previous_eol_count == 1 and comment.line - 1 == previous_row + 1 -> + [[entry | current] | rest] + + _ -> + [[entry] | groups] + end + end) + end + def surround_context_ranges(text, line, character) do case Code.Fragment.surround_context(text, {line + 1, character + 1}) do :none -> diff --git a/apps/language_server/mix.exs b/apps/language_server/mix.exs index 468e1d43c..b81e732b5 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -43,7 +43,7 @@ defmodule ElixirLS.LanguageServer.MixProject do defp deps do [ {:elixir_ls_utils, in_umbrella: true}, - {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, + {:elixir_sense, path: "../../../../elixir_sense"}, {:erl2ex_vendored, github: "elixir-lsp/erl2ex", ref: @dep_versions[:erl2ex_vendored]}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", ref: @dep_versions[:dialyxir_vendored], runtime: false}, diff --git a/apps/language_server/test/ast_utils_test.exs b/apps/language_server/test/ast_utils_test.exs deleted file mode 100644 index 77f96f5dc..000000000 --- a/apps/language_server/test/ast_utils_test.exs +++ /dev/null @@ -1,483 +0,0 @@ -defmodule ElixirLS.LanguageServer.AstUtilsTest do - use ExUnit.Case - - import ElixirLS.LanguageServer.RangeUtils - import ElixirLS.LanguageServer.AstUtils - - defp get_range(code) do - # IO.puts(code) - - {:ok, ast} = - Code.string_to_quoted(code, - columns: true, - token_metadata: true, - unescape: false, - literal_encoder: &{:ok, {:__block__, &2, [&1]}} - ) - - # dbg(ast) - node_range(ast) - end - - describe "literals" do - test "true" do - assert get_range("true") == range(0, 0, 0, 4) - end - - test "false" do - assert get_range("false") == range(0, 0, 0, 5) - end - - test "nil" do - assert get_range("nil") == range(0, 0, 0, 3) - end - - if Version.match?(System.version(), ">= 1.18.0") do - test "true as atom" do - assert get_range(":true") == range(0, 0, 0, 5) - end - end - - test "integer" do - assert get_range("1234") == range(0, 0, 0, 4) - - assert node_range({:__block__, [token: "2", line: 1, column: 10], [2]}) == - range(0, 9, 0, 10) - end - - test "float" do - assert get_range("123.4") == range(0, 0, 0, 5) - end - - test "atom" do - assert get_range(":abc") == range(0, 0, 0, 4) - end - - test "quoted atom string" do - assert get_range(":\"abc\"") == range(0, 0, 0, 6) - end - - test "quoted atom charlist" do - assert get_range(":'abc'") == range(0, 0, 0, 6) - end - - test "quoted atom string interpolated" do - assert get_range(":\"ab\#{inspect(self())}c\"") == range(0, 0, 0, 24) - end - - test "quoted atom charlist interpolated" do - assert get_range(":'ab\#{inspect(self())}c'") == range(0, 0, 0, 24) - end - - test "string" do - assert get_range("\"abc\"") == range(0, 0, 0, 5) - end - - test "charlist" do - assert get_range("'abc'") == range(0, 0, 0, 5) - end - - test "string with newlines" do - assert get_range("\"ab\nc\"") == range(0, 0, 1, 2) - end - - test "charlist with newlines" do - assert get_range("'ab\nc'") == range(0, 0, 1, 2) - end - - test "string heredoc" do - assert get_range("\"\"\"\nabc\n\"\"\"") == range(0, 0, 2, 3) - end - - test "string heredoc with indentation" do - assert get_range("\"\"\"\n abc\n \"\"\"") == range(0, 0, 2, 5) - end - - test "charlist heredoc" do - assert get_range("'''\nabc\n'''") == range(0, 0, 2, 3) - end - - test "charlist heredoc with indentation" do - assert get_range("'''\n abc\n '''") == range(0, 0, 2, 5) - end - - test "string interpolated" do - assert get_range("\"abc \#{inspect(a)} sd\"") == range(0, 0, 0, 22) - end - - test "charlist interpolated" do - assert get_range("'abc \#{inspect(a)} sd'") == range(0, 0, 0, 22) - end - - test "string heredoc interpolated" do - assert get_range("\"\"\"\nab\#{inspect(a)}c\n\"\"\"") == range(0, 0, 2, 3) - end - - test "charlist heredoc interpolated" do - assert get_range("'''\nab\#{inspect(a)}c\n'''") == range(0, 0, 2, 3) - end - - test "sigil" do - assert get_range("~w(asd fgh)") == range(0, 0, 0, 11) - end - - test "sigil with modifier" do - assert get_range("~w(asd fgh)a") == range(0, 0, 0, 12) - end - - test "sigil with interpolation" do - text = "~s(asd \#{inspect(self())} fgh)" - assert get_range(text) == range(0, 0, 0, 30) - end - - test "sigil with heredoc string" do - text = """ - ~S\"\"\" - some text - \"\"\" - """ - - assert get_range(text) == range(0, 0, 2, 3) - end - - test "sigil with heredoc charlist" do - text = """ - ~S''' - some text - ''' - """ - - assert get_range(text) == range(0, 0, 2, 3) - end - - test "empty tuple" do - assert get_range("{}") == range(0, 0, 0, 2) - end - - test "1 element tuple" do - assert get_range("{:ok}") == range(0, 0, 0, 5) - end - - test "2 element tuple" do - assert get_range("{:ok, 123}") == range(0, 0, 0, 10) - end - - test "3 element tuple" do - assert get_range("{:ok, 123, nil}") == range(0, 0, 0, 15) - end - - test "empty list" do - assert get_range("[]") == range(0, 0, 0, 2) - end - - test "1 element list" do - assert get_range("[123]") == range(0, 0, 0, 5) - end - - test "2 element list" do - assert get_range("[123, 456]") == range(0, 0, 0, 10) - end - - test "1 element list with cons operator" do - assert get_range("[123 | abc]") == range(0, 0, 0, 11) - end - - test "2 element list with cons operator" do - assert get_range("[123, 456 | abc]") == range(0, 0, 0, 16) - end - - test "keyword" do - assert get_range("[abc: 2]") == range(0, 0, 0, 8) - end - - test "empty map" do - assert get_range("%{}") == range(0, 0, 0, 3) - end - - test "map with string key" do - assert get_range("%{\"abc\" => 1}") == range(0, 0, 0, 13) - end - - test "map with atom key" do - assert get_range("%{abc: 1}") == range(0, 0, 0, 9) - end - - test "map update syntax" do - assert get_range("%{var | abc: 1}") == range(0, 0, 0, 15) - end - - test "alias" do - assert get_range("Some") == range(0, 0, 0, 4) - end - - test "alias nested" do - assert get_range("Some.Foo") == range(0, 0, 0, 8) - end - - test "empty struct" do - assert get_range("%Some{}") == range(0, 0, 0, 7) - end - - test "struct with atom key" do - assert get_range("%Some{abc: 1}") == range(0, 0, 0, 13) - end - - test "struct update syntax" do - assert get_range("%Some{var | abc: 1}") == range(0, 0, 0, 19) - end - - test "empty bitstring" do - assert get_range("<<>>") == range(0, 0, 0, 4) - end - - test "bitstring with content" do - assert get_range("<< 0 >>") == range(0, 0, 0, 7) - end - - test "variable" do - assert get_range("var") == range(0, 0, 0, 3) - end - - test "module attribute" do - assert get_range("@attr") == range(0, 0, 0, 5) - end - - test "module attribute definition" do - assert get_range("@attr 123") == range(0, 0, 0, 9) - end - - test "binary operator" do - assert get_range("var + foo") == range(0, 0, 0, 9) - end - - test "nested binary operators" do - assert get_range("var * 3 + foo / x") == range(0, 0, 0, 17) - end - - # Parser is simplifying the expression and not including the parens - # we handle parens meta in selection ranges - # test "nested binary operators with parens" do - # assert get_range("var * 3 * (foo + x)") == range(0, 0, 0, 19) - # end - - test "nested binary and unary operators" do - assert get_range("var * 3 + foo / -x") == range(0, 0, 0, 18) - end - - test "if" do - text = """ - if true do - 1 - end - """ - - assert get_range(text) == range(0, 0, 2, 3) - end - - test "if else" do - text = """ - if true do - 1 - else - 2 - end - """ - - assert get_range(text) == range(0, 0, 4, 3) - end - - test "if short notation" do - text = """ - if true, do: 1 - """ - - assert get_range(text) == range(0, 0, 0, 14) - end - - test "case" do - text = """ - case x do - ^abc -> - :ok - true -> - :error - end - """ - - assert get_range(text) == range(0, 0, 5, 3) - end - - test "cond" do - text = """ - cond do - abc == 1 -> - :ok - true -> - :error - end - """ - - assert get_range(text) == range(0, 0, 5, 3) - end - - test "local call" do - assert get_range("local(123)") == range(0, 0, 0, 10) - end - - test "variable call" do - assert get_range("local.(123)") == range(0, 0, 0, 11) - end - - test "nested call" do - assert get_range("local.prop.foo") == range(0, 0, 0, 14) - end - - test "access" do - assert get_range("local[\"some\"]") == range(0, 0, 0, 13) - end - - test "nested access" do - assert get_range("local[\"some\"][1]") == range(0, 0, 0, 16) - end - - test "remote call" do - assert get_range("Some.fun(123)") == range(0, 0, 0, 13) - end - - test "remote call on atom" do - assert get_range(":some.fun(123)") == range(0, 0, 0, 14) - end - - test "remote call quoted string" do - assert get_range("Some.\"0fun\"(123)") == range(0, 0, 0, 16) - end - - test "remote call quoted charlist" do - assert get_range("Some.'0fun'(123)") == range(0, 0, 0, 16) - end - - test "remote call pipe" do - text = """ - 123 - |> Some.fun1() - """ - - assert get_range(text) == range(0, 0, 1, 14) - end - - test "remote call pipe no parens" do - text = """ - 123 - |> Some.fun1 - """ - - assert get_range(text) == range(0, 0, 1, 12) - end - - test "local call pipe" do - text = """ - 123 - |> local() - """ - - assert get_range(text) == range(0, 0, 1, 10) - end - - test "local call pipe no parens" do - text = """ - 123 - |> local - """ - - assert get_range(text) == range(0, 0, 1, 8) - end - - test "local call no parens" do - assert get_range("local 123") == range(0, 0, 0, 9) - end - - test "remote call no parens" do - assert get_range("Some.fun 123") == range(0, 0, 0, 12) - end - - test "local capture" do - assert get_range("&local/1") == range(0, 0, 0, 8) - end - - test "remote capture" do - assert get_range("&Some.fun/1") == range(0, 0, 0, 11) - end - - test "remote capture quoted" do - assert get_range("&Some.\"fun\"/1") == range(0, 0, 0, 13) - end - - test "anonymous capture" do - assert get_range("& &1 + 1") == range(0, 0, 0, 8) - end - - test "complicated local call" do - text = """ - fun(%My{} = my, keyword: 123, other: [:a, ""]) - """ - - assert get_range(text) == range(0, 0, 0, 46) - end - - test "block" do - text = """ - a = foo() - b = bar() - :ok - """ - - assert get_range(text) == range(0, 0, 2, 3) - end - - test "anonymous function no args" do - test = """ - fn -> 1 end - """ - - assert get_range(test) == range(0, 0, 0, 11) - end - - test "anonymous function multiple args" do - test = """ - fn a, b -> 1 end - """ - - assert get_range(test) == range(0, 0, 0, 16) - end - - test "anonymous function multiple clauses" do - test = """ - fn - 1 -> 1 - _ -> 2 - end - """ - - assert get_range(test) == range(0, 0, 3, 3) - end - - test "with" do - text = """ - with {:ok, x} <- foo() do - x - end - """ - - assert get_range(text) == range(0, 0, 2, 3) - end - - test "def short notation" do - test = ~S""" - defp name(%Config{} = config), - do: :"#{__MODULE__}_#{config.node_id}_#{config.channel_unique_id}" - """ - - assert get_range(test) == range(0, 0, 1, 68) - end - end -end diff --git a/apps/language_server/test/providers/document_symbols_test.exs b/apps/language_server/test/providers/document_symbols_test.exs index bf4220fc2..75cde47e7 100644 --- a/apps/language_server/test/providers/document_symbols_test.exs +++ b/apps/language_server/test/providers/document_symbols_test.exs @@ -2231,7 +2231,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do ] = document_symbols end - test "handles a file with compilation errors by returning an empty list" do + test "handles a file with compilation errors by returning recovered symbols" do uri = "file:///project/test.exs" text = """ @@ -2244,7 +2244,21 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do parser_context = ParserContextBuilder.from_string(text) - assert {:ok, []} = get_document_symbols(uri, parser_context, true) + # the error tolerant toxic2 parser recovers an AST from a file with + # parse errors so symbols are still returned + assert {:ok, + [ + %GenLSP.Structures.DocumentSymbol{ + detail: "defmodule", + name: "aA", + children: [ + %GenLSP.Structures.DocumentSymbol{ + detail: "def", + name: "hello/0" + } + ] + } + ]} = get_document_symbols(uri, parser_context, true) end describe "invalid documents" do From adac7bd0707295d2576a417339500e7e3052cc7d Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 16:46:54 +0200 Subject: [PATCH 02/15] Reimplement folding ranges provider over toxic2 Derive structural and comment folds from the error-tolerant toxic2 parser (node source ranges + Toxic2.string_to_quoted_with_comments) instead of the Elixir-tokenizer-backed token-pair / special-token passes. The line-based indentation pass is kept (it supplies assignment/clause folds that have no single closing token) and AST folds override it at shared start lines, as the token-pair pass used to. Comments inside strings/heredocs are no longer mistaken for fold-able comment blocks. Co-Authored-By: Claude Fable 5 --- .../providers/folding_range.ex | 220 ++++++++++++++---- 1 file changed, 180 insertions(+), 40 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex index 74999a9cc..28183a0ae 100644 --- a/apps/language_server/lib/language_server/providers/folding_range.ex +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -12,41 +12,36 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do ### High level - We make multiple passes (currently 4) through the source text and create - folding ranges from each pass. - Then we merge the ranges from each pass to provide the final ranges. - Each pass gets a priority to help break ties (the priority is an integer, - higher integers win). + We make multiple passes through the source text and create folding ranges from + each pass, then merge them. Each pass gets a priority to break ties (higher + wins); when two passes produce a range with the same start line, the + higher-priority (and then longer) range is kept. ### Indentation pass (priority: 1) We use the indentation level -- determined by the column of the first - non-whitespace character on each line -- to provide baseline ranges. - All ranges from this pass are `kind?: :region` ranges. + non-whitespace character on each line -- to provide baseline ranges (e.g. + multi-line assignments and clause bodies that have no single closing token). + All ranges from this pass are `kind: "region"` ranges. ### Comment block pass (priority: 2) - We let "comment blocks", consecutive lines starting with `#`, from regions. - All ranges from this pass are `kind?: :comment` ranges. + Consecutive full-line comments (from `Toxic2.string_to_quoted_with_comments`) + form `kind: "comment"` ranges. Comments inside strings/heredocs are correctly + ignored because they come from the parser, not a line scan. - ### Token-pairs pass (priority: 3) + ### AST region pass (priority: 3) - We use pairs of tokens, e.g. `do` and `end`, to provide another pass of - ranges. - All ranges from this pass are `kind?: :region` ranges. - - ### Special tokens pass (priority: 3) - - We find strings (regular/charlist strings/heredocs) and sigils in a pass as - they're delimited by a few special tokens. - Ranges from this pass are either - - `kind?: :comment` if the token is paired with `@doc` or `@moduledoc`, or - - `kind?: :region` otherwise. + We fold the source ranges of the error-tolerant toxic2 AST nodes + (`range: true`): `do`/`end` blocks, delimited containers/calls + (`()`/`[]`/`{}`/`<<>>`/`fn`), and strings/heredocs/sigils. A string argument of + `@doc`/`@moduledoc`/`@typedoc`/`@shortdoc` folds as `kind: "comment"`; the rest + are `kind: "region"`. This replaces the previous token-pair and special-token + passes. ## Notes - Each pass may return ranges in any order. - But all ranges are valid, i.e. end_line > start_line. + All ranges are valid, i.e. end_line > start_line. """ alias __MODULE__ @@ -84,22 +79,28 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do end defp do_provide(text) do - input = convert_text_to_input(text) + # The structural (do/end, delimiters, heredocs) and comment ranges come from the error-tolerant + # toxic2 parser - node source ranges (`range: true`) replace the old token-pair/special-token + # passes, and comments come from `Toxic2.string_to_quoted_with_comments`. The indentation pass + # stays (it is pure line analysis and provides the assignment / clause folds that have no single + # closing token). Priorities mirror the original: AST regions (3) override indentation (1) at a + # shared start line, exactly as the token-pair pass used to. + {ast, _diagnostics, comments} = + Toxic2.string_to_quoted_with_comments(text, + token_metadata: true, + range: true, + literal_encoder: fn literal, meta -> {:ok, {:__block__, meta, [literal]}} end + ) + + lines = FoldingRange.Line.format_string(text) passes_with_priority = [ - {1, FoldingRange.Indentation}, - {2, FoldingRange.CommentBlock}, - {3, FoldingRange.TokenPair}, - {3, FoldingRange.SpecialToken} + {1, indentation_ranges(lines)}, + {2, comment_ranges(comments)}, + {3, ast_ranges(neutralize_errors(ast))} ] - ranges = - passes_with_priority - |> Enum.map(fn {priority, pass} -> - ranges = ranges_from_pass(pass, input) - {priority, ranges} - end) - |> merge_ranges_with_priorities() + ranges = merge_ranges_with_priorities(passes_with_priority) {:ok, ranges} end @@ -111,14 +112,153 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do } end - defp ranges_from_pass(pass, input) do - with {:ok, ranges} <- pass.provide_ranges(input) do - ranges - else - _ -> [] + defp indentation_ranges(lines) do + # Indentation only reads `:lines`, but its spec takes the full input map; pass empty tokens + # rather than run the (unused) tokenizer. + {:ok, ranges} = FoldingRange.Indentation.provide_ranges(%{tokens: [], lines: lines}) + ranges + end + + # --- comment-block folds (from toxic2 comments) ------------------------ + + # Group contiguous full-line comments (inline comments have previous_eol_count 0; a blank line + # between comments, previous_eol_count >= 2, splits the block) and fold each multi-line block. + defp comment_ranges(comments) do + comments + |> Enum.filter(&(&1.previous_eol_count > 0)) + |> Enum.reduce([], fn comment, groups -> + line = comment.line - 1 + + case groups do + [[previous | _] = group | rest] + when comment.previous_eol_count == 1 and line == previous + 1 -> + [[line | group] | rest] + + _ -> + [[line] | groups] + end + end) + |> Enum.flat_map(fn lines -> + last_line = hd(lines) + first_line = List.last(lines) + + if last_line > first_line do + [ + %GenLSP.Structures.FoldingRange{ + start_line: first_line, + end_line: last_line, + kind: "comment" + } + ] + else + [] + end + end) + end + + # --- structural folds (from toxic2 AST node ranges) -------------------- + + @doc_attributes [:doc, :moduledoc, :typedoc, :shortdoc] + + defp ast_ranges(ast) do + {_ast, {ranges, _doc_ranges}} = + Macro.prewalk(ast, {[], MapSet.new()}, fn node, {ranges, doc_ranges} -> + doc_ranges = collect_doc_string(node, doc_ranges) + + ranges = + case fold_for(node) do + {start_line, end_line, range} when end_line > start_line -> + kind = if MapSet.member?(doc_ranges, range), do: "comment", else: "region" + + [ + %GenLSP.Structures.FoldingRange{ + start_line: start_line, + end_line: end_line, + kind: kind + } + | ranges + ] + + _ -> + ranges + end + + {node, {ranges, doc_ranges}} + end) + + ranges + end + + # `@doc`/`@moduledoc`/... with a string/heredoc argument: remember that string's range so its fold + # is marked `:comment` rather than `:region` (matching the old special-token pass). + defp collect_doc_string({:@, _, [{attr, _, [arg]}]}, doc_ranges) when attr in @doc_attributes do + case string_range(arg) do + nil -> doc_ranges + range -> MapSet.put(doc_ranges, range) + end + end + + defp collect_doc_string(_node, doc_ranges), do: doc_ranges + + defp string_range({_form, meta, _args}) when is_list(meta) do + if Keyword.has_key?(meta, :delimiter), do: Keyword.get(meta, :range) + end + + defp string_range(_node), do: nil + + # A fold for a node spans from its start line to the last line that stays visible when collapsed: + # the line before a closing `end`/`)`/`]`/`}`/`>>` or heredoc terminator. Only do/end blocks, + # delimited containers/calls, and strings/heredocs fold here; everything else (assignments, clause + # bodies, pipelines) is left to the indentation pass, which mirrors the original behavior. + defp fold_for({_form, meta, _args}) when is_list(meta) do + case Keyword.get(meta, :range) do + {{start_line, _}, {end_line, _}} = range -> + case fold_end_line(meta, end_line) do + nil -> nil + fold_end -> {start_line - 1, fold_end, range} + end + + _ -> + nil + end + end + + defp fold_for(_node), do: nil + + defp fold_end_line(meta, range_end_line) do + cond do + line = keyword_line(meta, :end) -> line - 2 + line = keyword_line(meta, :closing) -> line - 2 + Keyword.has_key?(meta, :delimiter) -> range_end_line - 2 + true -> nil + end + end + + defp keyword_line(meta, key) do + case Keyword.get(meta, key) do + sub when is_list(sub) -> Keyword.get(sub, :line) + _ -> nil end end + # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose + # map args would crash `Macro.prewalk`; rewrite them to a harmless empty-arg node. + defp neutralize_errors({:__error__, meta, args}) when not is_list(args), + do: {:__error__, meta, []} + + defp neutralize_errors({form, meta, args}) when not is_list(args), + do: {neutralize_errors(form), meta, []} + + defp neutralize_errors({form, meta, args}), + do: {neutralize_errors(form), meta, neutralize_errors(args)} + + defp neutralize_errors({left, right}), + do: {neutralize_errors(left), neutralize_errors(right)} + + defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) + + defp neutralize_errors(other), do: other + defp merge_ranges_with_priorities(range_lists_with_priorities) do range_lists_with_priorities |> Enum.flat_map(fn {priority, ranges} -> Enum.zip(Stream.cycle([priority]), ranges) end) From fc91aeafcea4bc204cce557e0a7f2ffd192e36d9 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 20:15:56 +0200 Subject: [PATCH 03/15] Reimplement document symbols provider over toxic2 ranges Parse with toxic2 (range: true, no literal_encoder) and read each node's range: meta for symbol range and selection range, replacing the token-metadata end-position heuristics (kept only as a fallback for range-less nodes). Also: preserve nil args (bare identifiers like var / __MODULE__) in neutralize_errors across document_symbols, selection_ranges and folding_range - the previous `not is_list(args)` clause turned them into zero-arity calls; and ignore error-recovery placeholders when computing function/type arity so an incomplete `def foo(` reports foo/0 rather than an inflated arity. Adjust the records test for the toxic2 call-node range (starts at `Record`). Co-Authored-By: Claude Fable 5 --- .../providers/document_symbols.ex | 69 +++++++++++++++++-- .../providers/folding_range.ex | 4 +- .../providers/selection_ranges.ex | 7 +- .../test/providers/document_symbols_test.exs | 12 ++-- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index 093dd80da..d0775224f 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -24,12 +24,47 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do :deprecated ] - def symbols(uri, %Parser.Context{ast: ast, source_file: source_file}, hierarchical) do - symbols = extract_modules(ast) |> Enum.reject(&is_nil/1) + def symbols(uri, %Parser.Context{source_file: source_file}, hierarchical) do + # Parse with the error-tolerant toxic2 parser so every node carries a `range:` (end-exclusive, + # 1-based) - that is the source of every symbol's range and selection range, replacing the old + # token-metadata end-position heuristics. No `literal_encoder` here: wrapping literals would + # change the atom/keyword shapes that the `extract_*` clauses pattern-match on (defstruct fields + # etc.). Structural nodes (def/defmodule/@/...) carry ranges regardless. + {ast, _diagnostics} = Toxic2.parse_to_ast(source_file.text, token_metadata: true, range: true) + + symbols = ast |> neutralize_errors() |> extract_modules() |> Enum.reject(&is_nil/1) {:ok, build_symbols(symbols, uri, source_file.text, hierarchical)} end + # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose + # map args would crash `Macro` traversal; rewrite them to a harmless empty-arg node. + defp neutralize_errors({:__error__, meta, args}) when not is_list(args), + do: {:__error__, meta, []} + + # only rewrite non-list, NON-NIL args (the `__error__` map payload); `nil` args is a valid AST + # node (a bare identifier/atom like `var` or `__MODULE__`) and must be preserved + defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), + do: {neutralize_errors(form), meta, []} + + defp neutralize_errors({form, meta, args}), + do: {neutralize_errors(form), meta, neutralize_errors(args)} + + defp neutralize_errors({left, right}), + do: {neutralize_errors(left), neutralize_errors(right)} + + defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) + + defp neutralize_errors(other), do: other + + # Arity from a head's args, ignoring error-recovery placeholders toxic2 injects for incomplete + # code (so `def foo(` while typing reports `foo/0`, not an inflated arity). + defp arity(args) when is_list(args) do + args |> Enum.reject(&match?({:__error__, _, _}, &1)) |> length() + end + + defp arity(_args), do: 0 + defp build_symbols(symbols, uri, text, hierarchical) defp build_symbols(symbols, uri, text, true) do @@ -198,7 +233,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do %Info{ type: type, - name: "#{name_str}/#{if(is_list(args), do: length(args), else: 0)}", + name: "#{name_str}/#{arity(args)}", detail: "@#{type_kind}", location: location, selection_location: type_head_location, @@ -236,7 +271,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do %Info{ type: if(defname in @macro_defs, do: :constant, else: :function), symbol: name_str, - name: "#{name_str}/#{if(is_list(args), do: length(args), else: 0)}", + name: "#{name_str}/#{arity(args)}", detail: defname |> to_string, location: location, selection_location: head_location, @@ -260,7 +295,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do %Info{ type: if(defname in @macro_defs, do: :constant, else: :function), symbol: name_str, - name: "#{name_str}/#{if(is_list(args), do: length(args), else: 0)}", + name: "#{name_str}/#{arity(args)}", detail: defname |> to_string, location: location, selection_location: head_location, @@ -488,6 +523,30 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do end defp location_to_range(location, text, symbol) do + case location[:range] do + {{start_line, start_column}, {end_line, end_column}} -> + # Preferred path: the node's source range straight from toxic2's `range:` meta (1-based, + # end-exclusive) - covers both the full symbol range and the name selection range. + lines = SourceFile.lines(text) + + {lsp_start_line, lsp_start_character} = + SourceFile.elixir_position_to_lsp(lines, {start_line, start_column}) + + {lsp_end_line, lsp_end_character} = + SourceFile.elixir_position_to_lsp(lines, {end_line, end_column}) + + %GenLSP.Structures.Range{ + start: %GenLSP.Structures.Position{line: lsp_start_line, character: lsp_start_character}, + end: %GenLSP.Structures.Position{line: lsp_end_line, character: lsp_end_character} + } + + _ -> + # Fallback for nodes that carry no `range:` (rare macro-generated / synthesized nodes). + location_to_range_heuristic(location, text, symbol) + end + end + + defp location_to_range_heuristic(location, text, symbol) do lines = SourceFile.lines(text) {start_line, start_character} = diff --git a/apps/language_server/lib/language_server/providers/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex index 28183a0ae..bf1511ebd 100644 --- a/apps/language_server/lib/language_server/providers/folding_range.ex +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -246,7 +246,9 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do defp neutralize_errors({:__error__, meta, args}) when not is_list(args), do: {:__error__, meta, []} - defp neutralize_errors({form, meta, args}) when not is_list(args), + # `nil` args is a valid AST node (a bare identifier/atom); only rewrite a non-list, non-nil + # payload (the `__error__` map) + defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), do: {neutralize_errors(form), meta, []} defp neutralize_errors({form, meta, args}), diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index 2b60c8375..a083403e7 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -544,9 +544,10 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do defp neutralize_errors({:__error__, meta, args}) when not is_list(args), do: {:__error__, meta, []} - # defensive: any 3-tuple whose args is not a list (only `{:__error__, _, %{}}` today) would crash - # `Macro.traverse`; drop the non-list args so traversal is always safe - defp neutralize_errors({form, meta, args}) when not is_list(args), + # defensive: a 3-tuple whose args is a non-list, NON-NIL payload (only `{:__error__, _, %{}}` + # today) would crash `Macro.traverse`; drop those args. `nil` args is a valid AST node (a bare + # identifier/atom) and must be preserved. + defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), do: {neutralize_errors(form), meta, []} defp neutralize_errors({form, meta, args}), diff --git a/apps/language_server/test/providers/document_symbols_test.exs b/apps/language_server/test/providers/document_symbols_test.exs index 75cde47e7..84f193174 100644 --- a/apps/language_server/test/providers/document_symbols_test.exs +++ b/apps/language_server/test/providers/document_symbols_test.exs @@ -1398,15 +1398,15 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do children: [], kind: 7, name: "name", - range: range(3, 15, 3, 55), - selection_range: range(3, 15, 3, 55) + range: range(3, 8, 3, 55), + selection_range: range(3, 8, 3, 55) }, %GenLSP.Structures.DocumentSymbol{ children: [], kind: 7, name: "age", - range: range(3, 15, 3, 55), - selection_range: range(3, 15, 3, 55) + range: range(3, 8, 3, 55), + selection_range: range(3, 8, 3, 55) } ], kind: 5, @@ -1453,7 +1453,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do container_name: ":user", kind: 7, location: %GenLSP.Structures.Location{ - range: range(3, 15, 3, 55), + range: range(3, 8, 3, 55), uri: "file:///project/file.ex" }, name: "name" @@ -1462,7 +1462,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbolsTest do container_name: ":user", kind: 7, location: %GenLSP.Structures.Location{ - range: range(3, 15, 3, 55), + range: range(3, 8, 3, 55), uri: "file:///project/file.ex" }, name: "age" From e4d1fe16ede74665477ca6ce98adb728a6ce5f4f Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 21:05:43 +0200 Subject: [PATCH 04/15] document_symbols: drop dead range heuristic fallback Every node extract_* takes a location from carries a toxic2 range:, so the old token-metadata heuristic was unreachable - and it harbored a latent bug (elixir_position_to_lsp({nil, nil}) returns end-of-file because nil sorts above integers). Replace location_to_range with a range:-only version that degrades a range-less node to a zero-width range at its line/column, and drop the now-dead symbol argument. Co-Authored-By: Claude Fable 5 --- .../providers/document_symbols.ex | 113 ++++-------------- 1 file changed, 20 insertions(+), 93 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index d0775224f..a3db435f4 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -422,11 +422,11 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do end selection_range = - location_to_range(selection_location, text, info.symbol) + location_to_range(selection_location, text) # range must contain selection range range = - location_to_range(info.location, text, nil) + location_to_range(info.location, text) |> maybe_extend_range(selection_range) %GenLSP.Structures.DocumentSymbol{ @@ -502,7 +502,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do kind: SymbolUtils.symbol_kind_to_code(info.type), location: %GenLSP.Structures.Location{ uri: uri, - range: location_to_range(info.location, text, nil) + range: location_to_range(info.location, text) }, container_name: parent_name } @@ -515,108 +515,35 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do kind: SymbolUtils.symbol_kind_to_code(info.type), location: %GenLSP.Structures.Location{ uri: uri, - range: location_to_range(info.location, text, nil) + range: location_to_range(info.location, text) }, container_name: parent_name } end end - defp location_to_range(location, text, symbol) do - case location[:range] do - {{start_line, start_column}, {end_line, end_column}} -> - # Preferred path: the node's source range straight from toxic2's `range:` meta (1-based, - # end-exclusive) - covers both the full symbol range and the name selection range. - lines = SourceFile.lines(text) - - {lsp_start_line, lsp_start_character} = - SourceFile.elixir_position_to_lsp(lines, {start_line, start_column}) - - {lsp_end_line, lsp_end_character} = - SourceFile.elixir_position_to_lsp(lines, {end_line, end_column}) - - %GenLSP.Structures.Range{ - start: %GenLSP.Structures.Position{line: lsp_start_line, character: lsp_start_character}, - end: %GenLSP.Structures.Position{line: lsp_end_line, character: lsp_end_character} - } - - _ -> - # Fallback for nodes that carry no `range:` (rare macro-generated / synthesized nodes). - location_to_range_heuristic(location, text, symbol) - end - end - - defp location_to_range_heuristic(location, text, symbol) do + # The node's source range straight from toxic2's `range:` meta (1-based, end-exclusive) - covers + # both the full symbol range and the name selection range. Every node `extract_*` extracts a + # location from carries a range; a node that somehow lacks one degrades to a zero-width range at + # its `line`/`column` anchor. + defp location_to_range(location, text) do lines = SourceFile.lines(text) - {start_line, start_character} = - SourceFile.elixir_position_to_lsp(lines, {location[:line], location[:column]}) + {{start_line, start_column}, {end_line, end_column}} = + case location[:range] do + {{_, _}, {_, _}} = range -> range + _ -> {{location[:line], location[:column]}, {location[:line], location[:column]}} + end - {end_line, end_character} = - cond do - end_location = location[:end_of_expression] -> - SourceFile.elixir_position_to_lsp(lines, {end_location[:line], end_location[:column]}) - - end_location = location[:end] -> - SourceFile.elixir_position_to_lsp( - lines, - {end_location[:line], end_location[:column] + 3} - ) - - end_location = location[:closing] -> - # all closing tags we expect here are 1 char width - SourceFile.elixir_position_to_lsp( - lines, - {end_location[:line], end_location[:column] + 1} - ) - - symbol != nil -> - end_char = SourceFile.elixir_character_to_lsp(symbol, String.length(to_string(symbol))) - {start_line, start_character + end_char + 1} - - parent_end_line = - location - |> Keyword.get(:parent_location, []) - |> Keyword.get(:end, []) - |> Keyword.get(:line) -> - # last expression in block does not have end_of_expression - parent_do_line = location[:parent_location][:do][:line] - - if parent_end_line > parent_do_line do - # take end location from parent and assume end_of_expression is last char in previous line - end_of_expression = - Enum.at(lines, max(parent_end_line - 2, 0), "") - |> String.length() - - SourceFile.elixir_position_to_lsp( - lines, - {parent_end_line - 1, end_of_expression + 1} - ) - else - # take end location from parent and assume end_of_expression is last char before final ; trimmed - line = Enum.at(lines, parent_end_line - 1, "") - parent_end_column = location[:parent_location][:end][:column] - - end_of_expression = - line - |> String.slice(0..(parent_end_column - 2)) - |> String.trim_trailing() - |> String.replace_trailing(";", "") - |> String.length() - - SourceFile.elixir_position_to_lsp( - lines, - {parent_end_line, end_of_expression + 1} - ) - end + {lsp_start_line, lsp_start_character} = + SourceFile.elixir_position_to_lsp(lines, {start_line, start_column}) - true -> - {start_line, start_character} - end + {lsp_end_line, lsp_end_character} = + SourceFile.elixir_position_to_lsp(lines, {end_line, end_column}) %GenLSP.Structures.Range{ - start: %GenLSP.Structures.Position{line: start_line, character: start_character}, - end: %GenLSP.Structures.Position{line: end_line, character: end_character} + start: %GenLSP.Structures.Position{line: lsp_start_line, character: lsp_start_character}, + end: %GenLSP.Structures.Position{line: lsp_end_line, character: lsp_end_character} } end From 03948a541e511d1dfcfb37153811d0afee043cd8 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 23:37:07 +0200 Subject: [PATCH 05/15] parser: build Context.ast/metadata from toxic2 ranged AST for .ex/.exs The LS Parser now produces a range-bearing toxic2 AST for Context.ast (and the metadata built from it) on every .ex/.exs parse, replacing the Code.string_to_quoted! AST and the ElixirSense fault-tolerant fallback. This is the foundation that lets range-aware providers read node ranges straight off Context.ast. - parse_file/3 stays the sole diagnostics source (Code.with_diagnostics) and the EEx/HEEx parser; it now returns a tagged {:ok, ast, diagnostics} / {:error, diagnostics} so a falsey-but-valid AST (literal nil/false) is no longer mistaken for a parse failure. - do_parse/2 decides the flag from the Code success tag: clean -> :exact, else toxic recovered something usable -> :fixed, else :not_parsable. - parse_elixir_toxic/3 builds the AST/metadata via ElixirSense.Core.Parser.parse_to_neutralized_ast(range: true, keep_range: true), keeping the catch/telemetry safety net. - fault_tolerant_parse/2 removed (toxic always recovers; cursor env is derived separately in Metadata.get_cursor_env). Co-Authored-By: Claude Fable 5 --- .../lib/language_server/parser.ex | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/apps/language_server/lib/language_server/parser.ex b/apps/language_server/lib/language_server/parser.ex index 861402ef3..ba508a78b 100644 --- a/apps/language_server/lib/language_server/parser.ex +++ b/apps/language_server/lib/language_server/parser.ex @@ -331,21 +331,38 @@ defmodule ElixirLS.LanguageServer.Parser do %Context{source_file: source_file = %SourceFile{}, path: path} = file, cursor_position \\ nil ) do - {ast, diagnostics} = parse_file(source_file.text, path, source_file.language_id) + text = source_file.text + language_id = source_file.language_id + + # `parse_file/3` stays the sole source of editor diagnostics (via + # `Code.with_diagnostics`) and the parser for EEx/HEEx. It returns a tagged + # `{:ok, ast, diagnostics}` / `{:error, diagnostics}` - the tag (not the AST + # value) tells us whether Code parsed cleanly, because a valid source can + # itself be a falsey AST (e.g. the literal `nil`/`false`). For `.ex/.exs` + # Code's AST is discarded; the AST exposed on `Context.ast` (and the + # metadata) is always the range-bearing toxic2 tree, so range-aware + # providers can read node ranges straight off it. + parse_result = parse_file(text, path, language_id) + + diagnostics = + case parse_result do + {:ok, _ast, diagnostics} -> diagnostics + {:error, diagnostics} -> diagnostics + end {flag, ast, metadata} = - if ast do - # no syntax errors - acc = MetadataBuilder.build(ast) - metadata = ElixirSense.Core.Metadata.fill(source_file.text, acc) - - {{:exact, cursor_position}, ast, metadata} + if elixir?(path, language_id) do + parse_elixir_toxic(text, match?({:ok, _ast, _diagnostics}, parse_result), cursor_position) else - if elixir?(path, source_file.language_id) do - fault_tolerant_parse(source_file, cursor_position) - else - # no support for eex in ElixirSense.Core.Parser - {:not_parsable, @dummy_ast, @dummy_metadata} + # EEx/HEEx: keep the Code AST (no toxic2 / no range support). + case parse_result do + {:ok, code_ast, _diagnostics} -> + acc = MetadataBuilder.build(code_ast) + metadata = ElixirSense.Core.Metadata.fill(text, acc) + {{:exact, cursor_position}, code_ast, metadata} + + {:error, _diagnostics} -> + {:not_parsable, @dummy_ast, @dummy_metadata} end end @@ -359,24 +376,30 @@ defmodule ElixirLS.LanguageServer.Parser do } end - defp fault_tolerant_parse(source_file = %SourceFile{}, cursor_position) do - # attempt to parse with fixing syntax errors - options = [ - errors_threshold: 3, - cursor_position: cursor_position, - fallback_to_container_cursor_to_quoted: true - ] - - case ElixirSense.Core.Parser.string_to_ast(source_file.text, options) do - {:ok, ast, modified_source, _error} -> - acc = MetadataBuilder.build(ast) - metadata = ElixirSense.Core.Metadata.fill(modified_source, acc) + # Build the range-bearing AST + metadata from toxic2. `code_ok?` is whether + # `Code.string_to_quoted!` (in `parse_file/3`) accepted the source - it decides + # `:exact` vs `:fixed`. Never raises: on any unexpected parser/builder failure + # we fall back to the dummies and `:not_parsable`, preserving the previous + # `fault_tolerant_parse` safety net. + defp parse_elixir_toxic(text, code_ok?, cursor_position) do + {ast, _toxic_diagnostics} = + ElixirSense.Core.Parser.parse_to_neutralized_ast(text, range: true, keep_range: true) + + flag = + cond do + code_ok? -> {:exact, cursor_position} + usable_ast?(ast) -> {:fixed, cursor_position} + true -> {:not_parsable, cursor_position} + end - {{:fixed, cursor_position}, ast, metadata} + case flag do + {:not_parsable, _} -> + {flag, @dummy_ast, @dummy_metadata} _ -> - # we can't fix it - {{:not_parsable, cursor_position}, @dummy_ast, @dummy_metadata} + acc = MetadataBuilder.build(ast) + metadata = ElixirSense.Core.Metadata.fill(text, acc) + {flag, ast, metadata} end catch kind, err -> @@ -411,6 +434,13 @@ defmodule ElixirLS.LanguageServer.Parser do {{:not_parsable, cursor_position}, @dummy_ast, @dummy_metadata} end + # toxic2 always returns *some* tree; treat an empty block or a bare error node + # as "nothing recovered" so a truly broken file still reports `:not_parsable`. + defp usable_ast?(nil), do: false + defp usable_ast?({:__block__, _, []}), do: false + defp usable_ast?({:__error__, _, _}), do: false + defp usable_ast?(_ast), do: true + defp get_path(uri) do case uri do "file:" <> _ -> @@ -578,8 +608,8 @@ defmodule ElixirLS.LanguageServer.Parser do end) case result do - {:ok, ast} -> {ast, warning_diagnostics} - {:error, diagnostic} -> {nil, [diagnostic | warning_diagnostics]} + {:ok, ast} -> {:ok, ast, warning_diagnostics} + {:error, diagnostic} -> {:error, [diagnostic | warning_diagnostics]} end end From 94c6c8efd96c2886f64ba46ebca0aa5456f04925 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 23:45:31 +0200 Subject: [PATCH 06/15] providers: dedup neutralize_errors onto shared ElixirSense.Core.Parser helper DocumentSymbols, FoldingRange and SelectionRanges each carried a byte-identical private neutralize_errors/1. Replace all three with the shared ElixirSense.Core.Parser.neutralize_errors/3 (keep_range: true so the range: meta survives). document_symbols/folding_range pass their parse diagnostics (also gets the call-arg sentinel cleaning); selection_ranges' self-neutralizing ast_node_ranges/4 passes [] (range-only, and __error__ nodes carry no range, so the diagnostics-driven cleaning is a no-op there). Co-Authored-By: Claude Fable 5 --- .../providers/document_symbols.ex | 33 ++++++------------- .../providers/folding_range.ex | 24 ++------------ .../providers/selection_ranges.ex | 26 ++------------- 3 files changed, 15 insertions(+), 68 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index a3db435f4..f3501a9a6 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -30,33 +30,20 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do # token-metadata end-position heuristics. No `literal_encoder` here: wrapping literals would # change the atom/keyword shapes that the `extract_*` clauses pattern-match on (defstruct fields # etc.). Structural nodes (def/defmodule/@/...) carry ranges regardless. - {ast, _diagnostics} = Toxic2.parse_to_ast(source_file.text, token_metadata: true, range: true) - - symbols = ast |> neutralize_errors() |> extract_modules() |> Enum.reject(&is_nil/1) + {ast, diagnostics} = Toxic2.parse_to_ast(source_file.text, token_metadata: true, range: true) + + # `{:__error__, ...}` placeholder nodes (best-effort recovery on invalid code) carry map args + # that crash `Macro` traversal; neutralize them via the shared helper (keep_range: true so the + # `range:` meta survives - it is the source of every symbol's range). + symbols = + ast + |> ElixirSense.Core.Parser.neutralize_errors(diagnostics, true) + |> extract_modules() + |> Enum.reject(&is_nil/1) {:ok, build_symbols(symbols, uri, source_file.text, hierarchical)} end - # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose - # map args would crash `Macro` traversal; rewrite them to a harmless empty-arg node. - defp neutralize_errors({:__error__, meta, args}) when not is_list(args), - do: {:__error__, meta, []} - - # only rewrite non-list, NON-NIL args (the `__error__` map payload); `nil` args is a valid AST - # node (a bare identifier/atom like `var` or `__MODULE__`) and must be preserved - defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), - do: {neutralize_errors(form), meta, []} - - defp neutralize_errors({form, meta, args}), - do: {neutralize_errors(form), meta, neutralize_errors(args)} - - defp neutralize_errors({left, right}), - do: {neutralize_errors(left), neutralize_errors(right)} - - defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) - - defp neutralize_errors(other), do: other - # Arity from a head's args, ignoring error-recovery placeholders toxic2 injects for incomplete # code (so `def foo(` while typing reports `foo/0`, not an inflated arity). defp arity(args) when is_list(args) do diff --git a/apps/language_server/lib/language_server/providers/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex index bf1511ebd..ed5c56c53 100644 --- a/apps/language_server/lib/language_server/providers/folding_range.ex +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -85,7 +85,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do # stays (it is pure line analysis and provides the assignment / clause folds that have no single # closing token). Priorities mirror the original: AST regions (3) override indentation (1) at a # shared start line, exactly as the token-pair pass used to. - {ast, _diagnostics, comments} = + {ast, diagnostics, comments} = Toxic2.string_to_quoted_with_comments(text, token_metadata: true, range: true, @@ -97,7 +97,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do passes_with_priority = [ {1, indentation_ranges(lines)}, {2, comment_ranges(comments)}, - {3, ast_ranges(neutralize_errors(ast))} + {3, ast_ranges(ElixirSense.Core.Parser.neutralize_errors(ast, diagnostics, true))} ] ranges = merge_ranges_with_priorities(passes_with_priority) @@ -241,26 +241,6 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do end end - # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose - # map args would crash `Macro.prewalk`; rewrite them to a harmless empty-arg node. - defp neutralize_errors({:__error__, meta, args}) when not is_list(args), - do: {:__error__, meta, []} - - # `nil` args is a valid AST node (a bare identifier/atom); only rewrite a non-list, non-nil - # payload (the `__error__` map) - defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), - do: {neutralize_errors(form), meta, []} - - defp neutralize_errors({form, meta, args}), - do: {neutralize_errors(form), meta, neutralize_errors(args)} - - defp neutralize_errors({left, right}), - do: {neutralize_errors(left), neutralize_errors(right)} - - defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) - - defp neutralize_errors(other), do: other - defp merge_ranges_with_priorities(range_lists_with_priorities) do range_lists_with_priorities |> Enum.flat_map(fn {priority, ranges} -> Enum.zip(Stream.cycle([priority]), ranges) end) diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index a083403e7..0ce073b6b 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -367,8 +367,9 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do def ast_node_ranges({:ok, ast}, line, character, _options) do # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` nodes whose # map args would crash `Macro.traverse`; neutralize them here so this function is safe for any - # toxic2 AST regardless of caller. - ast = neutralize_errors(ast) + # toxic2 AST regardless of caller. No diagnostics in scope (this is range-only and `__error__` + # nodes carry no `range:`, so the diagnostics-driven call-arg cleaning is a no-op for ranges). + ast = ElixirSense.Core.Parser.neutralize_errors(ast, [], true) {_new_ast, {acc, [@empty_node]}} = Macro.traverse( @@ -539,27 +540,6 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do end end - # toxic2 returns a best-effort AST for invalid code with `{:__error__, meta, %{...}}` placeholder - # nodes whose map args would crash `Macro.traverse`. Rewrite them to a harmless empty-arg node. - defp neutralize_errors({:__error__, meta, args}) when not is_list(args), - do: {:__error__, meta, []} - - # defensive: a 3-tuple whose args is a non-list, NON-NIL payload (only `{:__error__, _, %{}}` - # today) would crash `Macro.traverse`; drop those args. `nil` args is a valid AST node (a bare - # identifier/atom) and must be preserved. - defp neutralize_errors({form, meta, args}) when not is_list(args) and not is_nil(args), - do: {neutralize_errors(form), meta, []} - - defp neutralize_errors({form, meta, args}), - do: {neutralize_errors(form), meta, neutralize_errors(args)} - - defp neutralize_errors({left, right}), - do: {neutralize_errors(left), neutralize_errors(right)} - - defp neutralize_errors(list) when is_list(list), do: Enum.map(list, &neutralize_errors/1) - - defp neutralize_errors(other), do: other - # Group toxic2 comments into blocks compatible with `comment_block_ranges/4`: each block is a list # of `{{row, column}, "#"}` cells in reverse source order (most recent first), matching # `FoldingRange.CommentBlock.group_comments/1`. Only full-line comments form blocks (an inline From 9476ff68b697c8716e41653e5aa11cad074aab70 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 00:03:30 +0200 Subject: [PATCH 07/15] Route navigation providers through SurroundContext.Toxic seam (stage 0) definition/references/implementation/declaration/call_hierarchy/hover locators and the llm_environment command now classify the symbol under the cursor via ElixirSense.Core.SurroundContext.Toxic.surround_context/2 (stage 0 delegates to Code.Fragment). Completion (cursor_context/container_cursor_to_quoted) untouched. Co-Authored-By: Claude Fable 5 --- .../lib/language_server/providers/call_hierarchy/locator.ex | 2 +- .../lib/language_server/providers/declaration/locator.ex | 2 +- .../lib/language_server/providers/definition/locator.ex | 2 +- .../providers/execute_command/llm_environment.ex | 2 +- .../language_server/lib/language_server/providers/hover/docs.ex | 2 +- .../lib/language_server/providers/implementation/locator.ex | 2 +- .../lib/language_server/providers/references/locator.ex | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/call_hierarchy/locator.ex b/apps/language_server/lib/language_server/providers/call_hierarchy/locator.ex index 5057a5564..9760c77da 100644 --- a/apps/language_server/lib/language_server/providers/call_hierarchy/locator.ex +++ b/apps/language_server/lib/language_server/providers/call_hierarchy/locator.ex @@ -12,7 +12,7 @@ defmodule ElixirLS.LanguageServer.Providers.CallHierarchy.Locator do alias ElixirSense.Core.Parser def prepare(code, line, column, trace, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> # If no context, check if we're on a function definition line check_function_definition(code, line, column, trace, options) diff --git a/apps/language_server/lib/language_server/providers/declaration/locator.ex b/apps/language_server/lib/language_server/providers/declaration/locator.ex index 0e5bd0580..b7040d169 100644 --- a/apps/language_server/lib/language_server/providers/declaration/locator.ex +++ b/apps/language_server/lib/language_server/providers/declaration/locator.ex @@ -27,7 +27,7 @@ defmodule ElixirLS.LanguageServer.Providers.Declaration.Locator do Returns either a single `%Location{}` or a list of locations if multiple declarations are found. """ def declaration(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> nil diff --git a/apps/language_server/lib/language_server/providers/definition/locator.ex b/apps/language_server/lib/language_server/providers/definition/locator.ex index a017c899a..f66fd2a94 100644 --- a/apps/language_server/lib/language_server/providers/definition/locator.ex +++ b/apps/language_server/lib/language_server/providers/definition/locator.ex @@ -26,7 +26,7 @@ defmodule ElixirLS.LanguageServer.Providers.Definition.Locator do alias ElixirLS.LanguageServer.Plugins.Phoenix.Scope def definition(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> nil diff --git a/apps/language_server/lib/language_server/providers/execute_command/llm_environment.ex b/apps/language_server/lib/language_server/providers/execute_command/llm_environment.ex index e1b371678..6eea7d5f1 100644 --- a/apps/language_server/lib/language_server/providers/execute_command/llm_environment.ex +++ b/apps/language_server/lib/language_server/providers/execute_command/llm_environment.ex @@ -97,7 +97,7 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.LlmEnvironment do metadata = Parser.parse_string(text, true, false, {line, column}) # Get context at cursor - context = Code.Fragment.surround_context(text, {line, column}) + context = ElixirSense.Core.SurroundContext.Toxic.surround_context(text, {line, column}) # Get environment env = diff --git a/apps/language_server/lib/language_server/providers/hover/docs.ex b/apps/language_server/lib/language_server/providers/hover/docs.ex index 806d36164..12c9b5637 100644 --- a/apps/language_server/lib/language_server/providers/hover/docs.ex +++ b/apps/language_server/lib/language_server/providers/hover/docs.ex @@ -71,7 +71,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover.Docs do |> Kernel.--([:exception, :message]) def docs(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> nil diff --git a/apps/language_server/lib/language_server/providers/implementation/locator.ex b/apps/language_server/lib/language_server/providers/implementation/locator.ex index be1873858..6d4be3ede 100644 --- a/apps/language_server/lib/language_server/providers/implementation/locator.ex +++ b/apps/language_server/lib/language_server/providers/implementation/locator.ex @@ -22,7 +22,7 @@ defmodule ElixirLS.LanguageServer.Providers.Implementation.Locator do require ElixirSense.Core.Introspection, as: Introspection def implementations(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> [] diff --git a/apps/language_server/lib/language_server/providers/references/locator.ex b/apps/language_server/lib/language_server/providers/references/locator.ex index 53cc8e592..702d53809 100644 --- a/apps/language_server/lib/language_server/providers/references/locator.ex +++ b/apps/language_server/lib/language_server/providers/references/locator.ex @@ -21,7 +21,7 @@ defmodule ElixirLS.LanguageServer.Providers.References.Locator do alias ElixirSense.Core.Source def references(code, line, column, trace, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> [] From 615f8a05b1c06b02c7192baafce257dedf0f4f3d Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 03:20:08 +0200 Subject: [PATCH 08/15] selection_ranges: derive delimiter pairs from toxic2, drop the tokenizer Replaces the Elixir-tokenizer passes (token_pair_ranges via FoldingRange.Token/ TokenPair, special_token_group_ranges via FoldingRange.SpecialToken, and the stop-token machinery) with a toxic2-AST pass, delimiter_pair_ranges/4. It derives outer/inner ranges for ()/[]/{}/%{}/<<>>, calls, bracket access x[y], and do/else/rescue/after/catch/end blocks from the toxic2 closing:/do:/end:/section-key range: metadata, plus a stab pattern .. -> range. String/heredoc/sigil ranges now come from ast_node_ranges (the toxic nodes carry range:), so the special-token pass is gone. Both selection and folding providers no longer use :elixir_tokenizer for their output (FoldingRange was already migrated). Adversarial review found a crash: a cursor exactly on a block section keyword (else/rescue/...) made two sibling, non-nested ranges and tripped the "increasingly narrowing" merge invariant. Fixed by selecting the cursor's section with half-open containment (end exclusive). A fuzz over real files dropped selection-range crashes from 238 (old tokenizer code) to 6 pre-existing "no intersection" cases in the shared merge. Bracket-access ranges (lost in the first cut) restored via the from_brackets meta. Regression tests added. Co-Authored-By: Claude Fable 5 --- .../providers/selection_ranges.ex | 446 +++++++++--------- .../test/providers/selection_ranges_test.exs | 36 ++ 2 files changed, 266 insertions(+), 216 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index 0ce073b6b..b63ead316 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -8,41 +8,26 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do different approaches. Each produces different ranges (possibly contradictory) that are finally merged and combined Algorithms providers currently used: - 1. Token pairs (), [], do-end etc. with stop tokens , ; eol eof | - 2. Special token groups (regular/charlist strings/heredocs) and sigils - 3. Comment blocks - 4. Code.Fragment.surround_context - 5. AST - - First 3 algorithms reuse passes from folding ranges provider with some modifications + 1. Delimiter pairs `()` `[]` `{}` `%{}` `<<>>` and `do`/`else`/`rescue`/`after`/`catch`/`end` + blocks, derived from the toxic2 `closing:` / `do:` / `end:` node metadata + 2. Indentation cell pairs (line analysis) + 3. Comment blocks (from the toxic2 comment stream) + 4. `Code.Fragment.surround_context` (symbol under cursor) + 5. AST node ranges (toxic2 `range:` metadata) + + The AST/delimiter/comment passes all come from the error-tolerant toxic2 parser; string/heredoc/ + sigil ranges come from the AST nodes (not a separate token pass). The indentation pass is pure + line analysis. """ alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Providers.FoldingRange import ElixirLS.LanguageServer.RangeUtils - defp token_length(:end), do: 3 - defp token_length(token) when token in [:"(", :"[", :"{", :")", :"]", :"}"], do: 1 - defp token_length(token) when token in [:"<<", :">>", :do, :fn], do: 2 - defp token_length(_), do: 0 - - @stop_tokens [:",", :";", :eol, :eof, :pipe_op] - def selection_ranges(text, positions, options \\ []) do lines = SourceFile.lines(text) full_file_range = full_range(lines) - tokens = FoldingRange.Token.format_string(text) - - token_pairs = FoldingRange.TokenPair.pair_tokens(tokens) - - stop_tokens = get_stop_tokens_in_token_pairs(tokens, token_pairs) - - special_token_groups = - for group <- FoldingRange.SpecialToken.group_tokens(tokens) do - FoldingRange.Helpers.first_and_last_of_list(group) - end - formatted_lines = FoldingRange.Line.format_string(text) # AST node ranges and comments both come from the error-tolerant toxic2 parser. @@ -74,13 +59,10 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do cell_pair_ranges = cell_pair_ranges(lines, cell_pairs, line, character) - token_pair_ranges = - token_pair_ranges(lines, token_pairs, stop_tokens, line, character) + delimiter_pair_ranges = + delimiter_pair_ranges(parse_result, lines, line, character) |> deduplicate - special_token_group_ranges = - special_token_group_ranges(special_token_groups, line, character) - comment_block_ranges = comment_block_ranges(lines, comment_groups, line, character) ast_node_ranges = ast_node_ranges(parse_result, line, character, options) @@ -88,9 +70,8 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do surround_context_ranges = surround_context_ranges(text, line, character) merged_ranges = - [full_file_range | token_pair_ranges] + [full_file_range | delimiter_pair_ranges] |> merge_ranges_lists([full_file_range | cell_pair_ranges]) - |> merge_ranges_lists([full_file_range | special_token_group_ranges]) |> merge_ranges_lists([full_file_range | comment_block_ranges]) |> merge_ranges_lists([full_file_range | surround_context_ranges]) |> merge_ranges_lists([full_file_range | ast_node_ranges]) @@ -132,149 +113,6 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do end) end - def token_pair_ranges(lines, token_pairs, stop_tokens, line, character) do - token_pairs - |> Enum.filter(fn {{_, {start_line, start_character, _}, _}, - {end_token, {end_line, end_character, _}, _}} -> - end_token_length = token_length(end_token) - - (start_line < line or (start_line == line and start_character <= character)) and - (end_line > line or - (end_line == line and end_character + end_token_length >= character)) - end) - |> Enum.reduce([], fn {{start_token, {start_line, start_character, _}, _}, - {end_token, {end_line, end_character, _}, _}} = pair, - acc -> - stop_tokens_in_pair = Map.get(stop_tokens, pair, []) - start_token_length = token_length(start_token) - end_token_length = token_length(end_token) - - outer_range = - range(start_line, start_character, end_line, end_character + end_token_length) - - case end_token do - :end -> - if line < start_line + 1 or line > end_line - 1 or start_line + 1 == end_line do - # do not include inner range if cursor is outside, e.g. - # do - # ^ - [outer_range | acc] - else - line_length = lines |> Enum.at(end_line - 1, "") |> String.length() - inner_range = range(start_line + 1, 0, end_line - 1, line_length) - - if empty?(inner_range) do - # degenerate case where the inner range is an empty line - # in this case we should not include the inner range - [outer_range | acc] - else - find_stop_token_range(stop_tokens_in_pair, pair, inner_range, line, character) ++ - [inner_range, outer_range | acc] - end - end - - _ -> - if (start_line == line and start_character + start_token_length > character) or - (end_line == line and end_character < character) do - # do not include inner range if cursor is outside, e.g. - # << 123 >> - # ^ ^ - [outer_range | acc] - else - inner_range = - range( - start_line, - start_character + start_token_length, - end_line, - end_character - ) - - find_stop_token_range(stop_tokens_in_pair, pair, inner_range, line, character) ++ - [ - inner_range, - outer_range | acc - ] - end - end - end) - |> Enum.reverse() - end - - defp find_stop_token_range([], _, _, _, _), do: [] - - defp find_stop_token_range(tokens, {begin_token, end_token}, inner_range, line, character) do - {_, found} = - Enum.reduce_while(tokens ++ [{end_token, nil, nil}], {{begin_token, nil, nil}, []}, fn - {token, before_stop, _} = token_tuple, {{previous_token, _, after_previous}, _} -> - {_, {start_line, start_character, _}, _} = previous_token - {_, {end_line, end_character, _}, _} = token - - range_between_stop_tokens = range(start_line, start_character, end_line, end_character) - - if in?(range_between_stop_tokens, {line, character}) do - # dbg({previous_token, after_previous, before_stop, token}) - {end_line, end_character} = - case before_stop do - {kind, _, _} when kind in [:bin_string, :list_string] -> - {end_line, end_character} - - {kind, {before_start_line, before_start_character, list}, _} when is_list(list) -> - length_modifier = - if kind == :atom do - 1 - else - 0 - end - - {before_start_line, before_start_character + length(list) + length_modifier} - - {_, {before_start_line, before_start_character, _}, list} when is_list(list) -> - {before_start_line, before_start_character + length(list)} - - {:atom_quoted, {before_start_line, before_start_character, _}, atom} -> - {before_start_line, before_start_character + String.length(to_string(atom)) + 3} - - _ -> - {end_line, end_character} - end - - {start_line, start_character} = - case after_previous do - {_, {after_end_line, after_end_character, _}, _} -> - {after_end_line, after_end_character} - - nil -> - {start_line, start_character} - end - - computed_range = range(start_line, start_character, end_line, end_character) - - trimmed_range = - if not empty?(computed_range) do - try do - intersection( - range(start_line, start_character, end_line, end_character), - inner_range - ) - rescue - # No intersection possible - ArgumentError -> nil - end - end - - if trimmed_range && in?(trimmed_range, {line, character}) do - {:halt, {token_tuple, [trimmed_range]}} - else - {:halt, {token_tuple, []}} - end - else - {:cont, {token_tuple, []}} - end - end) - - found - end - def cell_pair_ranges(lines, cell_pairs, line, character) do for {{start_line, start_character}, {end_line, _end_line_start_character}} <- cell_pairs, @@ -306,17 +144,226 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do |> sort_ranges_widest_to_narrowest() end - def special_token_group_ranges(special_token_groups, line, character) do - for {{_end_token, {end_line, end_character, _}, _}, - {_start_token, {start_line, start_character, _}, _}} <- special_token_groups, - end_token_length = 0, - (start_line < line or (start_line == line and start_character <= character)) and - (end_line > line or - (end_line == line and end_character + end_token_length >= character)) do - range(start_line, start_character, end_line, end_character) + # Outer/inner ranges for delimiter pairs - `()`/`[]`/`{}`/`%{}`/`<<>>` and `do`/`end` blocks - + # derived from the toxic2 `closing:` / `do:` / `end:` node metadata. This replaces the old + # tokenizer-driven token-pair pass (FoldingRange.Token/TokenPair). String/heredoc/sigil ranges, + # which the old special-token pass produced, already come from `ast_node_ranges` (the toxic AST + # nodes carry `range:`), so they are not reproduced here. + def delimiter_pair_ranges({:ok, ast}, lines, line, character) do + ast = ElixirSense.Core.Parser.neutralize_errors(ast, [], true) + + {_ast, {acc, _stack}} = + Macro.traverse( + ast, + {[], [nil]}, + fn node, {acc, [parent | _] = stack} -> + new = pair_ranges_for(node, parent, lines, line, character) + stack = if match?({_, _, _}, node), do: [node | stack], else: stack + {node, {new ++ acc, stack}} + end, + fn + {_, _, _} = node, {acc, [_ | tail]} -> {node, {acc, tail}} + other, {acc, stack} -> {other, {acc, stack}} + end + ) + + acc + |> sort_ranges_widest_to_narrowest() + |> deduplicate() + end + + def delimiter_pair_ranges(_, _, _, _), do: [] + + defp pair_ranges_for({:->, meta, _args}, _parent, _lines, line, character) + when is_list(meta) do + # stab clause `pattern -> body`: emit the "pattern .. ->" span (the clause node range and the + # pattern/body node ranges already come from `ast_node_ranges`). The `:->` node meta line/column + # is the arrow position. + with range(csl, csc, _, _) <- range_from_meta(meta), + arrow_line when is_integer(arrow_line) <- Keyword.get(meta, :line), + arrow_col when is_integer(arrow_col) <- Keyword.get(meta, :column) do + pattern_with_arrow = range(csl, csc, arrow_line - 1, arrow_col - 1 + 2) + if in?(pattern_with_arrow, {line, character}), do: [pattern_with_arrow], else: [] + else + _ -> [] end end + defp pair_ranges_for({_form, meta, _args} = node, parent, lines, line, character) + when is_list(meta) do + cond do + Keyword.has_key?(meta, :do) and Keyword.has_key?(meta, :end) -> + do_block_ranges(node, lines, line, character) + + Keyword.has_key?(meta, :closing) and Keyword.has_key?(meta, :range) -> + container_ranges(node, parent, line, character) + + true -> + [] + end + end + + defp pair_ranges_for(_node, _parent, _lines, _line, _character), do: [] + + # `(`/`[`/`{`/`%{`/`<<` ... pairs. `closing:` gives the close delimiter; the open delimiter and + # the delimiter lengths depend on the node kind. + defp container_ranges({form, meta, args}, parent, line, character) do + [line: close_line1, column: close_col1] = Keyword.fetch!(meta, :closing) + # the node's own line/column anchors the open delimiter: for a list/tuple/map/bitstring it is + # the range start; for a call `fun(`/`Mod.fun(` it is the (function) name start. + node_line = Keyword.fetch!(meta, :line) - 1 + node_col = Keyword.fetch!(meta, :column) - 1 + + {open_line, open_col, open_len, close_len} = + if Keyword.get(meta, :from_brackets) == true do + # `x[y]` is lowered to `Access.get(x, y)`; the `[` sits right after the first argument `x`. + case args do + [first | _] -> + case node_range_from_meta(first) do + range(_, _, el, ec) -> {el, ec, 1, 1} + _ -> {node_line, node_col, 1, 1} + end + + _ -> + {node_line, node_col, 1, 1} + end + else + delimiters(form, parent, node_line, node_col) + end + + close_line = close_line1 - 1 + close_col = close_col1 - 1 + outer = range(open_line, open_col, close_line, close_col + close_len) + + if in?(outer, {line, character}) do + inner_start = open_col + open_len + cursor_past_open = open_line < line or (open_line == line and inner_start <= character) + cursor_before_close = close_line > line or (close_line == line and close_col >= character) + + if cursor_past_open and cursor_before_close do + [range(open_line, inner_start, close_line, close_col), outer] + else + [outer] + end + else + [] + end + end + + # Returns {open_line, open_col, open_len, close_len} (0-based) for the node kind. + defp delimiters(:%{}, parent, rsl, rsc) do + # struct map (`%Mod{...}`): the node range starts at `{`. bare map (`%{...}`): range starts at + # `%`, so the `{` is one column further right. + open_col = if match?({:%, _, _}, parent), do: rsc, else: rsc + 1 + {rsl, open_col, 1, 1} + end + + defp delimiters(:<<>>, _parent, rsl, rsc), do: {rsl, rsc, 2, 2} + + defp delimiters(form, _parent, rsl, rsc) when form in [:__block__, :{}, :%] do + # literal list/tuple wrapped by the literal_encoder, 3+ element tuple, or struct `%` node + {rsl, rsc, 1, 1} + end + + defp delimiters({:., _dmeta, [_left, sym]}, _parent, rsl, rsc) when is_atom(sym) do + # remote call `Mod.fun(...)` - the `(` sits right after the function name + {rsl, rsc + String.length(Atom.to_string(sym)), 1, 1} + end + + defp delimiters(form, _parent, rsl, rsc) when is_atom(form) do + # local call `fun(...)` - the `(` sits right after the name + {rsl, rsc + String.length(Atom.to_string(form)), 1, 1} + end + + defp delimiters(_form, _parent, rsl, rsc), do: {rsl, rsc, 1, 1} + + # `do`/`else`/`catch`/`rescue`/`after`/`end` block. Pairs CONSECUTIVE section keywords + # (`(do, else)`, `(else, end)`, or just `(do, end)`) and, for each pair containing the cursor, + # emits an outer range (keyword start .. next keyword start) and an inner range. For a `..end` + # pair the inner is the body lines (line-based); for a `..keyword` pair the inner runs from the + # END of the first keyword to the next keyword. The `do`/`end` positions come from the node meta; + # the `else`/`catch`/`rescue`/`after` positions come from the section keys in the node's args + # (wrapped with `range:` by the literal_encoder). This mirrors the old token-pair behavior. + defp do_block_ranges({_form, meta, _args} = node, lines, line, character) do + [line: do_line1, column: do_col1] = Keyword.fetch!(meta, :do) + [line: end_line1, column: end_col1] = Keyword.fetch!(meta, :end) + + sections = + [{:do, {do_line1 - 1, do_col1 - 1}} | block_section_keywords(node)] + |> Enum.uniq_by(fn {_name, pos} -> pos end) + |> Enum.sort_by(fn {_name, pos} -> pos end) + + boundaries = sections ++ [{:end, {end_line1 - 1, end_col1 - 1}}] + + boundaries + |> Enum.zip(tl(boundaries)) + |> Enum.flat_map(fn {{name, {sl, sc}}, {next_name, {el, ec}}} -> + {outer, inner} = + if next_name == :end do + outer = range(sl, sc, el, ec + 3) + + inner = + if line > sl and line < el do + line_length = lines |> Enum.at(el - 1, "") |> String.length() + ir = range(sl + 1, 0, el - 1, line_length) + if empty?(ir), do: nil, else: ir + end + + {outer, inner} + else + outer = range(sl, sc, el, ec) + ir = range(sl, sc + keyword_length(name), el, ec) + {outer, if(empty?(ir), do: nil, else: ir)} + end + + # Sections of a multi-section block (do/else/rescue/...) are PEERS, not nested. Select the + # cursor's section with half-open containment (end EXCLUSIVE) so a cursor exactly on a section + # keyword belongs to that section only - otherwise two sibling, non-nested ranges would be + # emitted and break the "increasingly narrowing" invariant. + if half_open?(outer, line, character) do + [outer, inner] |> Enum.reject(&is_nil/1) + else + [] + end + end) + end + + # cursor in [range_start, range_end): start inclusive, end EXCLUSIVE. + defp half_open?(range(sl, sc, el, ec), line, character) do + (sl < line or (sl == line and sc <= character)) and + (el > line or (el == line and ec > character)) + end + + defp keyword_length(:do), do: 2 + defp keyword_length(:else), do: 4 + defp keyword_length(:catch), do: 5 + defp keyword_length(:rescue), do: 6 + defp keyword_length(:after), do: 5 + + # Section keywords (`else`/`catch`/`rescue`/`after`) with start positions, read from the + # block-section keys in the node's last argument. + defp block_section_keywords({_form, _meta, args}) when is_list(args) do + case List.last(args) do + list when is_list(list) -> + for {key, _body} <- list, kw = section_keyword(key), do: kw + + _ -> + [] + end + end + + defp block_section_keywords(_node), do: [] + + defp section_keyword({:__block__, kmeta, [name]}) + when name in [:else, :catch, :rescue, :after] do + case range_from_meta(kmeta) do + range(sl, sc, _, _) -> {name, {sl, sc}} + _ -> nil + end + end + + defp section_keyword(_key), do: nil + def comment_block_ranges(lines, comment_groups, line, character) do for group <- comment_groups, group != [], @@ -572,37 +619,4 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do [range(start_line - 1, start_character - 1, end_line - 1, end_character - 1)] end end - - def get_stop_tokens_in_token_pairs([], _token_pairs), do: %{} - - def get_stop_tokens_in_token_pairs(tokens, token_pairs) do - tokens_next = tl(tokens) ++ [nil] - tokens_prev = [nil | Enum.slice(tokens, 0..-2//1)] - tokens_prev_next = Enum.zip([tokens_prev, tokens, tokens_next]) - - for {prev_token, {token, {line, character, _}, _} = token_tuple, next_token} <- - tokens_prev_next, - token in @stop_tokens do - pair = - token_pairs - |> Enum.filter(fn {{_, {start_line, start_character, _}, _}, - {_, {end_line, end_character, _}, _}} -> - in?(range(start_line, start_character, end_line, end_character), {line, character}) - end) - |> Enum.min_by( - fn {{_, {start_line, start_character, _}, _}, {_, {end_line, end_character, _}, _}} -> - {end_line - start_line, end_character - start_character} - end, - &<=/2, - fn -> nil end - ) - - {pair, {token_tuple, prev_token, next_token}} - end - |> Enum.group_by(&elem(&1, 0)) - |> Enum.map(fn {pair, tuples} -> - {pair, Enum.map(tuples, &elem(&1, 1))} - end) - |> Map.new() - end end diff --git a/apps/language_server/test/providers/selection_ranges_test.exs b/apps/language_server/test/providers/selection_ranges_test.exs index 5b3142c14..b04ddcd0b 100644 --- a/apps/language_server/test/providers/selection_ranges_test.exs +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -966,6 +966,42 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRangesTest do assert_range(ranges, range(0, 7, 0, 11)) end + describe "bracket access" do + test "cursor on key" do + text = """ + map[key] + """ + + ranges = get_ranges(text, 0, 4) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # [key] outer + assert_range(ranges, range(0, 3, 0, 8)) + # key inner + assert_range(ranges, range(0, 4, 0, 7)) + end + end + + # Regression: a cursor exactly ON a block section keyword used to emit two sibling (non-nested) + # ranges and crash the "increasingly narrowing" merge invariant. (found by adversarial review) + describe "block section boundaries do not crash" do + for {label, text, l, c} <- [ + {"if/else on else line", "if a do\n :ok\nelse\n :error\nend\n", 2, 0}, + {"if/else single line on else", "if a do :ok else :err end\n", 0, 18}, + {"case on a clause", "case x do\n 1 -> :a\n 2 -> :b\nend\n", 1, 2}, + {"try rescue/after", "try do\n x\nrescue\n e -> e\nafter\n y\nend\n", 4, 2}, + {"with/else", "with {:ok, x} <- foo() do\n x\nelse\n e -> e\nend\n", 2, 0} + ] do + test label do + assert [%GenLSP.Structures.SelectionRange{}] = + SelectionRanges.selection_ranges(unquote(text), [ + %GenLSP.Structures.Position{line: unquote(l), character: unquote(c)} + ]) + end + end + end + describe "keyword args" do test "single line" do text = """ From 41fdf45b47b26a71d2a5e192446c99ab4bcdecb2 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 08:27:48 +0200 Subject: [PATCH 09/15] folding_range: delete dead Token/TokenPair/SpecialToken tokenizer modules These FoldingRange submodules were lib-dead after both providers moved off the Elixir tokenizer (FoldingRange.provide and SelectionRanges no longer use them). Remove the modules and their tests. convert_text_to_input and @type input drop the :tokens field (now lines-only); Indentation and CommentBlock provide_ranges only ever read :lines, so their doctests and the folding_range_test passes keep working. The only remaining ElixirSense.Core.Normalized.Tokenizer user is now elixir_sense's Source.which_func. Co-Authored-By: Claude Fable 5 --- .../providers/folding_range.ex | 6 +- .../providers/folding_range/special_token.ex | 134 -------- .../providers/folding_range/token.ex | 50 --- .../providers/folding_range/token_pairs.ex | 116 ------- .../folding_range/special_token_test.exs | 7 - .../folding_range/token_pairs_test.exs | 7 - .../test/providers/folding_range_test.exs | 287 ------------------ 7 files changed, 1 insertion(+), 606 deletions(-) delete mode 100644 apps/language_server/lib/language_server/providers/folding_range/special_token.ex delete mode 100644 apps/language_server/lib/language_server/providers/folding_range/token.ex delete mode 100644 apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex delete mode 100644 apps/language_server/test/providers/folding_range/special_token_test.exs delete mode 100644 apps/language_server/test/providers/folding_range/token_pairs_test.exs diff --git a/apps/language_server/lib/language_server/providers/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex index ed5c56c53..606066d6b 100644 --- a/apps/language_server/lib/language_server/providers/folding_range.ex +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -47,7 +47,6 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do alias __MODULE__ @type input :: %{ - tokens: [FoldingRange.Token.t()], lines: [FoldingRange.Line.t()] } @@ -107,15 +106,12 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange do def convert_text_to_input(text) do %{ - tokens: FoldingRange.Token.format_string(text), lines: FoldingRange.Line.format_string(text) } end defp indentation_ranges(lines) do - # Indentation only reads `:lines`, but its spec takes the full input map; pass empty tokens - # rather than run the (unused) tokenizer. - {:ok, ranges} = FoldingRange.Indentation.provide_ranges(%{tokens: [], lines: lines}) + {:ok, ranges} = FoldingRange.Indentation.provide_ranges(%{lines: lines}) ranges end diff --git a/apps/language_server/lib/language_server/providers/folding_range/special_token.ex b/apps/language_server/lib/language_server/providers/folding_range/special_token.ex deleted file mode 100644 index 70621d901..000000000 --- a/apps/language_server/lib/language_server/providers/folding_range/special_token.ex +++ /dev/null @@ -1,134 +0,0 @@ -defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do - @moduledoc """ - Code folding based on "special" tokens. - - Several tokens, like `"..."`s, define ranges all on their own. - This module converts these tokens to ranges. - These ranges can be either `kind: "comment"` or `kind: "region"`. - """ - - alias ElixirLS.LanguageServer.Providers.FoldingRange - alias ElixirLS.LanguageServer.Providers.FoldingRange.Token - - @kinds [ - :bin_heredoc, - :bin_string, - :list_heredoc, - :list_string, - :sigil - ] - - @docs [:moduledoc, :typedoc, :doc] - - @doc """ - Provides ranges based on "special" tokens - - ## Example - - iex> alias ElixirLS.LanguageServer.Providers.FoldingRange - iex> text = \""" - ...> defmodule A do # 0 - ...> def hello() do # 1 - ...> " - ...> regular string # 3 - ...> " - ...> ' - ...> charlist string # 6 - ...> ' - ...> end # 8 - ...> end # 9 - ...> \""" - iex> FoldingRange.convert_text_to_input(text) - ...> |> FoldingRange.SpecialToken.provide_ranges() - {:ok, [ - %GenLSP.Structures.FoldingRange{start_line: 5, end_line: 6, kind: "region"}, - %GenLSP.Structures.FoldingRange{start_line: 2, end_line: 3, kind: "region"}, - ]} - """ - @spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]} - def provide_ranges(%{tokens: tokens}) do - ranges = - tokens - |> group_tokens() - |> convert_groups_to_ranges() - - {:ok, ranges} - end - - @spec group_tokens([Token.t()]) :: [[Token.t()]] - def group_tokens(tokens) do - do_group_tokens(tokens, []) - end - - defp do_group_tokens([], acc), do: acc - - # Don't create folding ranges for @doc false - defp do_group_tokens( - [{:at_op, _, _}, {:identifier, _, doc_identifier}, {false, _, _} | rest], - acc - ) - when doc_identifier in @docs do - do_group_tokens(rest, acc) - end - - # Start a folding range for `@doc` and `@moduledoc` - defp do_group_tokens( - [{:at_op, _, _} = at_op, {:identifier, _, doc_identifier} = token | rest], - acc - ) - when doc_identifier in @docs do - acc = [[token, at_op] | acc] - do_group_tokens(rest, acc) - end - - # Amend the folding range - defp do_group_tokens([{k, _, _} = token | rest], [[{:identifier, _, _} | _] = head | tail]) - when k in @kinds do - acc = [[token | head] | tail] - do_group_tokens(rest, acc) - end - - # Start a new folding range - defp do_group_tokens([{k, _, _} = token | rest], acc) when k in @kinds do - acc = [[token] | acc] - do_group_tokens(rest, acc) - end - - # Finish the open folding range - defp do_group_tokens([{:eol, _, _} = token | rest], [[{k, _, _} | _] = head | tail]) - when k in @kinds do - acc = [[token | head] | tail] - do_group_tokens(rest, acc) - end - - defp do_group_tokens([_unmatched_token | rest], acc) do - do_group_tokens(rest, acc) - end - - @spec convert_groups_to_ranges([[Token.t()]]) :: [FoldingRange.t()] - defp convert_groups_to_ranges(groups) do - groups - |> Enum.map(fn group -> - # Each group comes out of group_tokens/1 reversed - {last, first} = FoldingRange.Helpers.first_and_last_of_list(group) - classify_group(first, last) - end) - |> Enum.map(fn {start_line, end_line, kind} -> - %GenLSP.Structures.FoldingRange{ - start_line: start_line, - end_line: end_line - 1, - kind: kind - } - end) - |> Enum.filter(fn range -> range.end_line > range.start_line end) - end - - defp classify_group({kind, {start_line, _, _}, _}, {_, {end_line, _, _}, _}) do - kind = - if kind == :at_op, - do: GenLSP.Enumerations.FoldingRangeKind.comment(), - else: GenLSP.Enumerations.FoldingRangeKind.region() - - {start_line, end_line, kind} - end -end diff --git a/apps/language_server/lib/language_server/providers/folding_range/token.ex b/apps/language_server/lib/language_server/providers/folding_range/token.ex deleted file mode 100644 index 5ad88fb24..000000000 --- a/apps/language_server/lib/language_server/providers/folding_range/token.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Token do - @moduledoc """ - This module normalizes the tokens provided by - - `ElixirSense.Core.Normalized.Tokenizer` - """ - - alias ElixirSense.Core.Normalized.Tokenizer - - @type t :: {atom(), {non_neg_integer(), non_neg_integer(), any()}, any()} - - @doc """ - Make pattern-matching easier by forcing all token tuples to be 3-tuples. - Also convert start_info to 0-indexing as ranges are 0-indexed. - """ - @spec format_string(String.t()) :: [t()] - def format_string(text) do - reversed_tokens = text |> Tokenizer.tokenize() - - reversed_tokens - # This reverses the tokens, but they come out of Tokenizer.tokenize/1 - # already reversed. - |> Enum.reduce([], fn tuple, acc -> - tuple = - case tuple do - {a, {b1, b2, b3}} -> - {a, {b1 - 1, b2 - 1, b3}, nil} - - {a, {b1, b2, b3}, c} -> - {a, {b1 - 1, b2 - 1, b3}, c} - - # Handle 'not in' operator token format from Elixir 1.19+ - # {:in_op, {start_line, start_col, nil}, :"not in", {end_line, end_col, nil}} - {:in_op, {b1, b2, b3}, :"not in", {_d1, _d2, _d3}} -> - {:in_op, {b1 - 1, b2 - 1, b3}, :"not in"} - - {:sigil, {b1, b2, b3}, _, _, _, _, delimiter} -> - {:sigil, {b1 - 1, b2 - 1, b3}, delimiter} - - {:bin_heredoc, {b1, b2, b3}, _, _} -> - {:bin_heredoc, {b1 - 1, b2 - 1, b3}, nil} - - {:list_heredoc, {b1, b2, b3}, _, _} -> - {:list_heredoc, {b1 - 1, b2 - 1, b3}, nil} - end - - [tuple | acc] - end) - end -end diff --git a/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex b/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex deleted file mode 100644 index f9d507004..000000000 --- a/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex +++ /dev/null @@ -1,116 +0,0 @@ -defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair do - @moduledoc """ - Code folding based on pairs of tokens - - Certain pairs of tokens, like `do` and `end`, natrually define ranges. - These ranges all have `kind: "region"`. - - Note that we exclude the line that the 2nd of the pair, e.g. `end`, is on. - This is so that when collapsed, both tokens are visible. - """ - - alias ElixirLS.LanguageServer.Providers.FoldingRange - alias ElixirLS.LanguageServer.Providers.FoldingRange.Token - - @token_pairs %{ - "(": [:")"], - "[": [:"]"], - "{": [:"}"], - "<<": [:">>"], - # do blocks - do: [:block_identifier, :end], - block_identifier: [:block_identifier, :end], - # other special forms that are not covered by :block_identifier - with: [:do], - for: [:do], - case: [:do], - fn: [:end] - } - - @doc """ - Provides ranges based on token pairs - - ## Example - - iex> alias ElixirLS.LanguageServer.Providers.FoldingRange - iex> text = \""" - ...> defmodule Module do # 0 - ...> def some_function() do # 1 - ...> 4 # 2 - ...> end # 3 - ...> end # 4 - ...> \""" - iex> FoldingRange.convert_text_to_input(text) - ...> |> TokenPair.provide_ranges() - {:ok, [ - %GenLSP.Structures.FoldingRange{start_line: 0, end_line: 3, kind: "region"}, - %GenLSP.Structures.FoldingRange{start_line: 1, end_line: 2, kind: "region"} - ]} - """ - @spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]} - def provide_ranges(%{tokens: tokens}) do - ranges = - tokens - |> pair_tokens() - |> convert_token_pairs_to_ranges() - - {:ok, ranges} - end - - @spec pair_tokens([Token.t()]) :: [{Token.t(), Token.t()}] - def pair_tokens(tokens) do - do_pair_tokens(tokens, [], []) - end - - # Note - # Tokenizer.tokenize/1 doesn't differentiate between successful and failed - # attempts to tokenize the string. - # This could mean the returned tokens are unbalanced. - # Therefore, the stack may not be empty when the base clause is hit. - # We're choosing to return the successfully paired tokens rather than to - # return an error if not all tokens could be paired. - defp do_pair_tokens([], _stack, pairs), do: pairs - - defp do_pair_tokens([{head_kind, _, _} = head | tail_tokens], [], pairs) do - new_stack = if @token_pairs |> Map.has_key?(head_kind), do: [head], else: [] - do_pair_tokens(tail_tokens, new_stack, pairs) - end - - defp do_pair_tokens( - [{head_kind, _, _} = head | tail_tokens], - [{top_kind, _, _} = top | tail_stack] = stack, - pairs - ) do - head_matches_any? = @token_pairs |> Map.has_key?(head_kind) - # Map.fetch!/2 will always succeed because we only push matches to the stack. - head_matches_top? = @token_pairs |> Map.fetch!(top_kind) |> Enum.member?(head_kind) - - {new_stack, new_pairs} = - case {head_matches_any?, head_matches_top?} do - {false, false} -> {stack, pairs} - {false, true} -> {tail_stack, [{top, head} | pairs]} - {true, false} -> {[head | stack], pairs} - {true, true} -> {[head | tail_stack], [{top, head} | pairs]} - end - - do_pair_tokens(tail_tokens, new_stack, new_pairs) - end - - @spec convert_token_pairs_to_ranges([{Token.t(), Token.t()}]) :: [FoldingRange.t()] - defp convert_token_pairs_to_ranges(token_pairs) do - token_pairs - |> Enum.map(fn {{_, {start_line, _, _}, _}, {_, {end_line, _, _}, _}} -> - # -1 for end_line because the range should stop 1 short - # e.g. both "do" and "end" should be visible when collapsed - {start_line, end_line - 1} - end) - |> Enum.filter(fn {start_line, end_line} -> end_line > start_line end) - |> Enum.map(fn {start_line, end_line} -> - %GenLSP.Structures.FoldingRange{ - start_line: start_line, - end_line: end_line, - kind: GenLSP.Enumerations.FoldingRangeKind.region() - } - end) - end -end diff --git a/apps/language_server/test/providers/folding_range/special_token_test.exs b/apps/language_server/test/providers/folding_range/special_token_test.exs deleted file mode 100644 index abccf2bbf..000000000 --- a/apps/language_server/test/providers/folding_range/special_token_test.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialTokenTest do - use ExUnit.Case - - alias ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken - - doctest(SpecialToken) -end diff --git a/apps/language_server/test/providers/folding_range/token_pairs_test.exs b/apps/language_server/test/providers/folding_range/token_pairs_test.exs deleted file mode 100644 index a4e092c91..000000000 --- a/apps/language_server/test/providers/folding_range/token_pairs_test.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPairTest do - use ExUnit.Case - - alias ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair - - doctest(TokenPair) -end diff --git a/apps/language_server/test/providers/folding_range_test.exs b/apps/language_server/test/providers/folding_range_test.exs index f633ba531..d7001b79d 100644 --- a/apps/language_server/test/providers/folding_range_test.exs +++ b/apps/language_server/test/providers/folding_range_test.exs @@ -95,293 +95,6 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRangeTest do end end - describe "token pairs" do - setup [:fold_via_token_pairs] - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - :world # 2 - end # 3 - end # 4 - """ - test "basic test", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - :world # 2 - end # 3 - end # 4 - """ - test "unusual indentation", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - if true do # 2 - :hello # 3 - else # 4 - :error # 5 - end # 6 - end # 7 - end # 8 - """ - test "if-do-else-end", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {2, 3}, {4, 5}], text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - try do # 2 - :hello # 3 - rescue # 4 - ArgumentError -> # 5 - IO.puts("rescue") # 6 - catch # 7 - value -> # 8 - IO.puts("catch") # 9 - else # 10 - value -> # 11 - IO.puts("else") # 12 - after # 13 - IO.puts("after") # 14 - end # 15 - end # 16 - end # 17 - """ - test "try block", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - expected = [{0, 16}, {1, 15}, {2, 3}, {4, 6}, {7, 9}, {10, 12}, {13, 14}] - assert compare_condensed_ranges(ranges, expected, text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - a = 20 # 2 - - case a do # 4 - 20 -> # 5 - :ok # 6 - - _ -> # 8 - :error # 9 - end # 10 - end # 11 - end # 12 - """ - test "1 defmodule, 1 def, 1 case", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 11}, {1, 10}, {4, 9}], text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - <<0>> # 2 - << # 3 - 1, 2, 3, # 4 - 4, 5, 6 # 5 - >> # 6 - end # 7 - end # 8 - """ - test "binaries", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {3, 5}], text) - end - - @tag text: """ - defmodule A do # 0 - @moduledoc "This is module A" # 1 - end # 2 - - defmodule B do # 4 - @moduledoc "This is module B" # 5 - end # 6 - """ - test "2 defmodules in the top-level of file", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 1}, {4, 5}], text) - end - - @tag text: """ - defmodule A do # 0 - def compare_and_hello(list) do # 1 - assert list == [ # 2 - %{"a" => 1, "b" => 2}, # 3 - %{"a" => 3, "b" => 4}, # 4 - ] # 5 - - :world # 7 - end # 8 - end # 9 - """ - test "1 defmodule, 1 def, 1 list", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 8}, {1, 7}, {2, 4}], text) - end - - @tag text: """ - defmodule A do # 0 - def f(%{"key" => value} = map) do # 1 - case NaiveDateTime.from_iso8601(value) do # 2 - {:ok, ndt} -> # 3 - dt = # 4 - ndt # 5 - |> DateTime.from_naive!("Etc/UTC") # 6 - |> Map.put(:microsecond, {0, 6}) # 7 - - %{map | "key" => dt} # 9 - - e -> # 11 - Logger.warning(\"\"\" - Could not use data map from #\{inspect(value)\} # 13 - #\{inspect(e)\} # 14 - \"\"\") - - :could_not_parse_value # 17 - end # 18 - end # 19 - end # 20 - """ - test "complicated function", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{0, 19}, {1, 18}, {2, 17}, {12, 14}], text) - end - - defp fold_via_token_pairs(%{text: text} = context) do - ranges_result = - text - |> FoldingRange.convert_text_to_input() - |> FoldingRange.TokenPair.provide_ranges() - - {:ok, Map.put(context, :ranges_result, ranges_result)} - end - end - - describe "special tokens" do - setup [:fold_via_special_tokens] - - @tag text: """ - defmodule A do # 0 - @moduledoc \"\"\" - @moduledoc heredoc # 2 - \"\"\" - - @doc \"\"\" - @doc heredoc # 6 - \"\"\" - def hello() do # 8 - \"\"\" - regular heredoc # 10 - \"\"\" - end # 12 - end # 13 - """ - test "@moduledoc, @doc, and stand-alone heredocs", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - expected = [{1, 2, "comment"}, {5, 6, "comment"}, {9, 10, "region"}] - assert compare_condensed_ranges(ranges, expected, text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - " - regular string # 3 - " - ' - charlist string # 6 - ' - \"\"\" - regular heredoc # 9 - \"\"\" - ''' - charlist heredoc # 12 - ''' - end # 14 - end # 15 - """ - test "charlist heredocs", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{2, 3}, {5, 6}, {8, 9}, {11, 12}], text) - end - - @tag text: """ - defmodule A do # 0 - def hello() do # 1 - ~r/ - hello # 3 - / - ~r| - hello # 6 - | - ~r" - hello # 9 - " - ~r' - hello # 12 - ' - ~r( - hello # 15 - ) - ~r[ - hello # 18 - ] - ~r{ - hello # 21 - } - ~r< - hello # 24 - > - end # 26 - end # 27 - """ - test "sigil delimiters", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - expected = [{2, 3}, {5, 6}, {8, 9}, {11, 12}, {14, 15}, {17, 18}, {20, 21}, {23, 24}] - assert compare_condensed_ranges(ranges, expected, text) - end - - @tag text: """ - defmodule A do # 0 - @moduledoc ~S\"\"\" - sigil @moduledoc # 2 - \"\"\" - - @doc ~S\"\"\" - sigil @doc # 6 - \"\"\" - def hello() do # 8 - :world # 9 - end # 10 - end # 11 - """ - test "@doc with ~S sigil", %{ranges_result: ranges_result, text: text} do - assert {:ok, ranges} = ranges_result - assert compare_condensed_ranges(ranges, [{1, 2, "comment"}, {5, 6, "comment"}], text) - end - - defp fold_via_special_tokens(%{text: text} = context) do - ranges_result = - text - |> FoldingRange.convert_text_to_input() - |> FoldingRange.SpecialToken.provide_ranges() - - {:ok, Map.put(context, :ranges_result, ranges_result)} - end - end - describe "comment blocks" do setup [:fold_via_comment_blocks] From 067ac8cb6b179a23d33cfb6f958fd2f885d7d38f Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 11:20:32 +0200 Subject: [PATCH 10/15] test: cover Ecto.Query.extract_bindings join associations elixir-ls already extracts join bindings (backported in 906c8c8d) but had no unit test for it - only commented-out integration TODOs. Port elixir_sense's focused, self-contained QueryTest (mock Post/Comment schemas) to lock in the behavior. Co-Authored-By: Claude Fable 5 --- .../providers/plugins/ecto/query_test.exs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/language_server/test/providers/plugins/ecto/query_test.exs diff --git a/apps/language_server/test/providers/plugins/ecto/query_test.exs b/apps/language_server/test/providers/plugins/ecto/query_test.exs new file mode 100644 index 000000000..b536c5fda --- /dev/null +++ b/apps/language_server/test/providers/plugins/ecto/query_test.exs @@ -0,0 +1,29 @@ +defmodule ElixirLS.LanguageServer.Plugins.Ecto.QueryTest do + use ExUnit.Case, async: true + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirLS.LanguageServer.Plugins.Ecto.Query + + defmodule Post do + def __schema__(:association, :comments), + do: %{related: ElixirLS.LanguageServer.Plugins.Ecto.QueryTest.Comment} + end + + defmodule Comment do + end + + test "extract_bindings includes join associations" do + prefix = + "from p in ElixirLS.LanguageServer.Plugins.Ecto.QueryTest.Post, " <> + "join: c in assoc(p, :comments), where: c" + + info = Source.which_func(prefix, %Binding{}) + + assert %{ + "p" => %{type: Post}, + "c" => %{type: Comment} + } = Query.extract_bindings(prefix, info, %State.Env{}, %ElixirSense.Core.Metadata{}) + end +end From ea7be7d2521f5cafe7ee3007ca5829ab80056026 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 12:03:54 +0200 Subject: [PATCH 11/15] selection_ranges: half-open containment for delimiter pairs (fix adjacent bracket crash) container_ranges decided cursor containment with the inclusive in?/2 on end-exclusive ranges, so adjacent bracket accesses (foo[bar][baz]) both claimed the shared boundary column and emitted two non-nested sibling ranges, raising "ranges_1 is not increasingly narrowing" in the merge. Use the half-open check (end exclusive) like do_block_ranges already does. Found by gpt-5.5 adversarial review. Co-Authored-By: Claude Fable 5 --- .../lib/language_server/providers/selection_ranges.ex | 4 +++- .../test/providers/selection_ranges_test.exs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index b63ead316..c5dcd3577 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -235,7 +235,9 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do close_col = close_col1 - 1 outer = range(open_line, open_col, close_line, close_col + close_len) - if in?(outer, {line, character}) do + # half-open (end EXCLUSIVE): adjacent sibling containers (`foo[bar][baz]`) share a boundary + # column; an inclusive check would emit both non-nested ranges and break the merge invariant. + if half_open?(outer, line, character) do inner_start = open_col + open_len cursor_past_open = open_line < line or (open_line == line and inner_start <= character) cursor_before_close = close_line > line or (close_line == line and close_col >= character) diff --git a/apps/language_server/test/providers/selection_ranges_test.exs b/apps/language_server/test/providers/selection_ranges_test.exs index b04ddcd0b..5154c2aa0 100644 --- a/apps/language_server/test/providers/selection_ranges_test.exs +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -981,6 +981,17 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRangesTest do # key inner assert_range(ranges, range(0, 4, 0, 7)) end + + # regression (gpt-5.5 review): adjacent bracket accesses share a boundary column; an inclusive + # containment check emitted both non-nested sibling ranges and crashed the merge invariant. + test "adjacent bracket accesses do not crash on the boundary" do + for c <- 0..13 do + assert [%GenLSP.Structures.SelectionRange{}] = + SelectionRanges.selection_ranges("foo[bar][baz]\n", [ + %GenLSP.Structures.Position{line: 0, character: c} + ]) + end + end end # Regression: a cursor exactly ON a block section keyword used to emit two sibling (non-nested) From 206b0d465062ef345787034adfc01a3e4883120b Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 18:10:34 +0200 Subject: [PATCH 12/15] providers: drop direct Code.Fragment.surround_context, route through Toxic selection_ranges: the symbol-under-cursor pass called Code.Fragment.surround_context directly. Route it through ElixirSense.Core.SurroundContext.Toxic instead - the same toxic2-backed entry point the navigation providers already use. Navigable shapes now get their span from the AST range: metadata; only purely lexical units (a bare do/end, exotic operators) reach Toxic's internal Code.Fragment fallback. AST ranges alone don't cover these symbol-level spans (e.g. the do/end keyword units, the dot-path callee), so the pass is kept rather than removed. document_symbols: drop the stale 'extract module name location from Code.Fragment.surround_context?' TODO - module name locations come from the toxic2 AST range metadata now. This removes the last direct Code.Fragment.surround_context call in the providers; the only remaining uses are Toxic's own internal fallback. Co-Authored-By: Claude Fable 5 --- .../lib/language_server/providers/document_symbols.ex | 1 - .../lib/language_server/providers/selection_ranges.ex | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index f3501a9a6..44760795e 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -107,7 +107,6 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do _ -> {nil, nil} end - # TODO extract module name location from Code.Fragment.surround_context? # TODO better selection ranges for defimpl? {extract_module_name(module_expression), symbol, module_name_location, module_body} diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index c5dcd3577..cd426b8bb 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -12,7 +12,8 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do blocks, derived from the toxic2 `closing:` / `do:` / `end:` node metadata 2. Indentation cell pairs (line analysis) 3. Comment blocks (from the toxic2 comment stream) - 4. `Code.Fragment.surround_context` (symbol under cursor) + 4. Symbol under cursor, via `ElixirSense.Core.SurroundContext.Toxic` (AST-based spans for + navigable shapes; it falls back internally for purely lexical units like a bare `do`/`end`) 5. AST node ranges (toxic2 `range:` metadata) The AST/delimiter/comment passes all come from the error-tolerant toxic2 parser; string/heredoc/ @@ -612,8 +613,12 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do end) end + # Symbol under the cursor. Goes through the toxic2-backed classifier (the same entry point the + # navigation providers use) rather than `Code.Fragment.surround_context` directly: spans for + # navigable shapes come from the AST `range:` metadata, and only lexical-only units (a bare + # `do`/`end`, exotic operators) reach the internal Code.Fragment fallback. def surround_context_ranges(text, line, character) do - case Code.Fragment.surround_context(text, {line + 1, character + 1}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(text, {line + 1, character + 1}) do :none -> [] From 398c831a09525e06112499395450e0483273a027 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 18:21:56 +0200 Subject: [PATCH 13/15] selection_ranges: reuse parsed AST for the symbol-under-cursor pass Pass the AST already parsed at the top of selection_ranges/3 into SurroundContext.Toxic.surround_context/3 instead of having it re-parse the source on every cursor position. Co-Authored-By: Claude Fable 5 --- .../language_server/providers/selection_ranges.ex | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index cd426b8bb..3849d1e40 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -68,7 +68,7 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do ast_node_ranges = ast_node_ranges(parse_result, line, character, options) - surround_context_ranges = surround_context_ranges(text, line, character) + surround_context_ranges = surround_context_ranges(ast, text, line, character) merged_ranges = [full_file_range | delimiter_pair_ranges] @@ -616,9 +616,14 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do # Symbol under the cursor. Goes through the toxic2-backed classifier (the same entry point the # navigation providers use) rather than `Code.Fragment.surround_context` directly: spans for # navigable shapes come from the AST `range:` metadata, and only lexical-only units (a bare - # `do`/`end`, exotic operators) reach the internal Code.Fragment fallback. - def surround_context_ranges(text, line, character) do - case ElixirSense.Core.SurroundContext.Toxic.surround_context(text, {line + 1, character + 1}) do + # `do`/`end`, exotic operators) reach the internal Code.Fragment fallback. The already-parsed + # `ast` is reused so this does not trigger a second parse per cursor position. + def surround_context_ranges(ast, text, line, character) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context( + ast, + text, + {line + 1, character + 1} + ) do :none -> [] From 27fdcc6702c99d6e1f57107699d3e5f44bdca9b5 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 18:50:48 +0200 Subject: [PATCH 14/15] deps: pin elixir_sense to toxic2-parser SHA; require Elixir ~> 1.19 Move elixir_sense from a local path dep back to the git dep model and pin it to the pushed toxic2-parser SHA (b928399b) via dep_versions.exs + mix.lock. This transitively pulls toxic2 (lukaszsamson/toxic2). elixir_sense/toxic2 require Elixir ~> 1.19, so drop the 1.16/1.17/1.18 jobs from the CI matrix (smoke tests + test matrix, Linux + Windows). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 35 ++++------------------------------- apps/debug_adapter/mix.exs | 2 +- apps/elixir_ls_utils/mix.exs | 2 +- apps/language_server/mix.exs | 2 +- dep_versions.exs | 2 +- mix.lock | 3 ++- 6 files changed, 10 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0fc0e85f..0de7ca658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,7 @@ jobs: fail-fast: false matrix: include: - - elixir: 1.16.x - otp: 26.x - - elixir: 1.17.x - otp: 27.x - - elixir: 1.18.x - otp: 27.x + # toxic2/elixir_sense require Elixir ~> 1.19; 1.16/1.17/1.18 dropped. - elixir: 1.19.x otp: 28.x - elixir: 1.20.x @@ -61,12 +56,7 @@ jobs: fail-fast: false matrix: include: - - elixir: 1.16.x - otp: 26.x - - elixir: 1.17.x - otp: 27.x - - elixir: 1.18.x - otp: 27.x + # toxic2/elixir_sense require Elixir ~> 1.19; 1.16/1.17/1.18 dropped. - elixir: 1.19.x otp: 28.x - elixir: 1.20.x @@ -107,25 +97,8 @@ jobs: fail-fast: false matrix: include: - # Minimum OTP for each supported Elixir - - elixir: 1.16.x - otp: 26.x - os: ubuntu-22.04 - - elixir: 1.16.x - otp: 26.x - os: windows-2022 - - elixir: 1.17.x - otp: 26.x - os: ubuntu-22.04 - - elixir: 1.17.x - otp: 26.x - os: windows-2022 - - elixir: 1.18.x - otp: 26.x - os: ubuntu-22.04 - - elixir: 1.18.x - otp: 26.x - os: windows-2022 + # Minimum OTP for each supported Elixir. + # toxic2/elixir_sense require Elixir ~> 1.19; 1.16/1.17/1.18 dropped. - elixir: 1.19.x otp: 26.x os: ubuntu-22.04 diff --git a/apps/debug_adapter/mix.exs b/apps/debug_adapter/mix.exs index c2aedc608..67ccf7a61 100644 --- a/apps/debug_adapter/mix.exs +++ b/apps/debug_adapter/mix.exs @@ -38,7 +38,7 @@ defmodule ElixirLS.DebugAdapter.MixProject do defp deps do [ - {:elixir_sense, path: "../../../../elixir_sense"}, + {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, {:schematic_v, github: "elixir-lsp/schematic_vendored", ref: @dep_versions[:schematic_vendored]}, {:typed_struct, "~> 0.3"}, diff --git a/apps/elixir_ls_utils/mix.exs b/apps/elixir_ls_utils/mix.exs index d1fb545a8..28866fc93 100644 --- a/apps/elixir_ls_utils/mix.exs +++ b/apps/elixir_ls_utils/mix.exs @@ -37,7 +37,7 @@ defmodule ElixirLS.Utils.MixProject do defp deps do [ - {:elixir_sense, path: "../../../../elixir_sense"}, + {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, {:jason_v, github: "elixir-lsp/jason", ref: @dep_versions[:jason_v]}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", ref: @dep_versions[:dialyxir_vendored], runtime: false} diff --git a/apps/language_server/mix.exs b/apps/language_server/mix.exs index b81e732b5..468e1d43c 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -43,7 +43,7 @@ defmodule ElixirLS.LanguageServer.MixProject do defp deps do [ {:elixir_ls_utils, in_umbrella: true}, - {:elixir_sense, path: "../../../../elixir_sense"}, + {:elixir_sense, github: "elixir-lsp/elixir_sense", ref: @dep_versions[:elixir_sense]}, {:erl2ex_vendored, github: "elixir-lsp/erl2ex", ref: @dep_versions[:erl2ex_vendored]}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", ref: @dep_versions[:dialyxir_vendored], runtime: false}, diff --git a/dep_versions.exs b/dep_versions.exs index f4e3f0fc6..fd1a991c7 100644 --- a/dep_versions.exs +++ b/dep_versions.exs @@ -1,5 +1,5 @@ [ - elixir_sense: "3befd73206c70d4e1dbf6f5088d955c59c92f271", + elixir_sense: "b928399b3677f8c19b5afedf8a259c725202802f", dialyxir_vendored: "accfec9393079abc4a82b7e79a4997f59f085b67", jason_v: "f1c10fa9c445cb9f300266122ef18671054b2330", erl2ex_vendored: "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311", diff --git a/mix.lock b/mix.lock index 00a8902ef..409f65416 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,7 @@ "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir_vendored": {:git, "https://github.com/elixir-lsp/dialyxir.git", "accfec9393079abc4a82b7e79a4997f59f085b67", [ref: "accfec9393079abc4a82b7e79a4997f59f085b67"]}, - "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "3befd73206c70d4e1dbf6f5088d955c59c92f271", [ref: "3befd73206c70d4e1dbf6f5088d955c59c92f271"]}, + "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "b928399b3677f8c19b5afedf8a259c725202802f", [ref: "b928399b3677f8c19b5afedf8a259c725202802f"]}, "erl2ex_vendored": {:git, "https://github.com/elixir-lsp/erl2ex.git", "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311", [ref: "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311"]}, "erlex_vendored": {:git, "https://github.com/elixir-lsp/erlex.git", "50b8307f90451a5d0288fb239fb6405b5ca1f1a4", [ref: "50b8307f90451a5d0288fb239fb6405b5ca1f1a4"]}, "jason_v": {:git, "https://github.com/elixir-lsp/jason.git", "f1c10fa9c445cb9f300266122ef18671054b2330", [ref: "f1c10fa9c445cb9f300266122ef18671054b2330"]}, @@ -12,5 +12,6 @@ "schematic_v": {:git, "https://github.com/elixir-lsp/schematic_vendored.git", "7ecf3cd4b3ee319abf7d0914d325657a1f56cffd", [ref: "7ecf3cd4b3ee319abf7d0914d325657a1f56cffd"]}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, + "toxic2": {:git, "https://github.com/lukaszsamson/toxic2.git", "6fde2f89acf94e9231e28245bf0c61f4fd4e0422", [ref: "6fde2f89acf94e9231e28245bf0c61f4fd4e0422"]}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, } From f8454b33de0c997021ebd6940681a9d77094a443 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 19:05:21 +0200 Subject: [PATCH 15/15] deps: bump pinned elixir_sense to a2b0e592 (static-analysis warning fixes) Co-Authored-By: Claude Fable 5 --- dep_versions.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dep_versions.exs b/dep_versions.exs index fd1a991c7..9b74d596d 100644 --- a/dep_versions.exs +++ b/dep_versions.exs @@ -1,5 +1,5 @@ [ - elixir_sense: "b928399b3677f8c19b5afedf8a259c725202802f", + elixir_sense: "a2b0e592b0518aa8423577cb03f811a78100eac6", dialyxir_vendored: "accfec9393079abc4a82b7e79a4997f59f085b67", jason_v: "f1c10fa9c445cb9f300266122ef18671054b2330", erl2ex_vendored: "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311", diff --git a/mix.lock b/mix.lock index 409f65416..7681eefdd 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,7 @@ "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir_vendored": {:git, "https://github.com/elixir-lsp/dialyxir.git", "accfec9393079abc4a82b7e79a4997f59f085b67", [ref: "accfec9393079abc4a82b7e79a4997f59f085b67"]}, - "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "b928399b3677f8c19b5afedf8a259c725202802f", [ref: "b928399b3677f8c19b5afedf8a259c725202802f"]}, + "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "a2b0e592b0518aa8423577cb03f811a78100eac6", [ref: "a2b0e592b0518aa8423577cb03f811a78100eac6"]}, "erl2ex_vendored": {:git, "https://github.com/elixir-lsp/erl2ex.git", "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311", [ref: "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311"]}, "erlex_vendored": {:git, "https://github.com/elixir-lsp/erlex.git", "50b8307f90451a5d0288fb239fb6405b5ca1f1a4", [ref: "50b8307f90451a5d0288fb239fb6405b5ca1f1a4"]}, "jason_v": {:git, "https://github.com/elixir-lsp/jason.git", "f1c10fa9c445cb9f300266122ef18671054b2330", [ref: "f1c10fa9c445cb9f300266122ef18671054b2330"]},