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/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/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 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/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index 093dd80da..44760795e 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,34 @@ 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) + + # `{:__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 + # 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 @@ -85,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} @@ -198,7 +219,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 +257,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 +281,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, @@ -387,11 +408,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{ @@ -467,7 +488,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 } @@ -480,84 +501,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 + # 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 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/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex index 74999a9cc..606066d6b 100644 --- a/apps/language_server/lib/language_server/providers/folding_range.ex +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -12,47 +12,41 @@ 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__ @type input :: %{ - tokens: [FoldingRange.Token.t()], lines: [FoldingRange.Line.t()] } @@ -84,38 +78,162 @@ 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(ElixirSense.Core.Parser.neutralize_errors(ast, diagnostics, true))} ] - 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 def convert_text_to_input(text) do %{ - tokens: FoldingRange.Token.format_string(text), lines: FoldingRange.Line.format_string(text) } end - defp ranges_from_pass(pass, input) do - with {:ok, ranges} <- pass.provide_ranges(input) do - ranges - else - _ -> [] + defp indentation_ranges(lines) do + {:ok, ranges} = FoldingRange.Indentation.provide_ranges(%{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 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/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 -> [] 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..3849d1e40 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -8,58 +8,46 @@ 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. 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/ + 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 - alias ElixirLS.LanguageServer.AstUtils - - 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) - 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) @@ -72,23 +60,19 @@ 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) - surround_context_ranges = surround_context_ranges(text, line, character) + surround_context_ranges = surround_context_ranges(ast, 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]) @@ -130,149 +114,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, @@ -304,17 +145,228 @@ 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) + + # 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) + + 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 != [], @@ -362,47 +414,30 @@ 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. 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( 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,46 +522,113 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do def ast_node_ranges(_, _, _, _), do: [] - def surround_context_ranges(text, line, character) do - case Code.Fragment.surround_context(text, {line + 1, character + 1}) do - :none -> - [] + # 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 - %{begin: {start_line, start_character}, end: {end_line, end_character}} -> - [range(start_line - 1, start_character - 1, end_line - 1, end_character - 1)] + 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 - 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 - ) + 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) - {pair, {token_tuple, prev_token, next_token}} + _ -> + nil end - |> Enum.group_by(&elem(&1, 0)) - |> Enum.map(fn {pair, tuples} -> - {pair, Enum.map(tuples, &elem(&1, 1))} + 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 + + # 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) - |> Map.new() + 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. 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 -> + [] + + %{begin: {start_line, start_character}, end: {end_line, end_character}} -> + [range(start_line - 1, start_character - 1, end_line - 1, end_character - 1)] + end end end 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..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" @@ -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 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] 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 diff --git a/apps/language_server/test/providers/selection_ranges_test.exs b/apps/language_server/test/providers/selection_ranges_test.exs index 5b3142c14..5154c2aa0 100644 --- a/apps/language_server/test/providers/selection_ranges_test.exs +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -966,6 +966,53 @@ 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 + + # 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) + # 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 = """ diff --git a/dep_versions.exs b/dep_versions.exs index f4e3f0fc6..9b74d596d 100644 --- a/dep_versions.exs +++ b/dep_versions.exs @@ -1,5 +1,5 @@ [ - elixir_sense: "3befd73206c70d4e1dbf6f5088d955c59c92f271", + elixir_sense: "a2b0e592b0518aa8423577cb03f811a78100eac6", dialyxir_vendored: "accfec9393079abc4a82b7e79a4997f59f085b67", jason_v: "f1c10fa9c445cb9f300266122ef18671054b2330", erl2ex_vendored: "04f93e55f46d35d0aa3c149616f2c7a6a1ad9311", diff --git a/mix.lock b/mix.lock index 00a8902ef..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", "3befd73206c70d4e1dbf6f5088d955c59c92f271", [ref: "3befd73206c70d4e1dbf6f5088d955c59c92f271"]}, + "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"]}, @@ -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"}, }