From 2f2cf87d4da9ea788f6ff3f3002ec16f110baf72 Mon Sep 17 00:00:00 2001 From: Mitchell Hanberg Date: Wed, 28 Jun 2023 00:46:38 -0400 Subject: [PATCH] feat: document symbols Closes #41 --- lib/next_ls.ex | 32 ++- lib/next_ls/document_symbol.ex | 222 +++++++++++++++ lib/next_ls/symbol_table.ex | 37 ++- test/next_ls/document_symbol_test.exs | 377 ++++++++++++++++++++++++++ test/next_ls_test.exs | 12 +- 5 files changed, 659 insertions(+), 21 deletions(-) create mode 100644 lib/next_ls/document_symbol.ex create mode 100644 test/next_ls/document_symbol_test.exs diff --git a/lib/next_ls.ex b/lib/next_ls.ex index bcde85c6..9081ff7a 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -20,6 +20,7 @@ defmodule NextLS do alias GenLSP.Requests.{ Initialize, Shutdown, + TextDocumentDocumentSymbol, TextDocumentFormatting, WorkspaceSymbol } @@ -28,21 +29,21 @@ defmodule NextLS do DidOpenTextDocumentParams, InitializeParams, InitializeResult, + Location, Position, Range, - Location, SaveOptions, ServerCapabilities, + SymbolInformation, TextDocumentItem, TextDocumentSyncOptions, TextEdit, WorkDoneProgressBegin, - WorkDoneProgressEnd, - SymbolInformation + WorkDoneProgressEnd } - alias NextLS.Runtime alias NextLS.DiagnosticCache + alias NextLS.Runtime alias NextLS.SymbolTable def start_link(args) do @@ -85,10 +86,7 @@ defmodule NextLS do end @impl true - def handle_request( - %Initialize{params: %InitializeParams{root_uri: root_uri}}, - lsp - ) do + def handle_request(%Initialize{params: %InitializeParams{root_uri: root_uri}}, lsp) do {:reply, %InitializeResult{ capabilities: %ServerCapabilities{ @@ -98,12 +96,28 @@ defmodule NextLS do change: TextDocumentSyncKind.full() }, document_formatting_provider: true, - workspace_symbol_provider: true + workspace_symbol_provider: true, + document_symbol_provider: true }, server_info: %{name: "NextLS"} }, assign(lsp, root_uri: root_uri)} end + def handle_request(%TextDocumentDocumentSymbol{params: %{text_document: %{uri: uri}}}, lsp) do + symbols = + try do + lsp.assigns.documents[uri] + |> Enum.join("\n") + |> NextLS.DocumentSymbol.fetch() + rescue + e -> + GenLSP.error(lsp, Exception.format_banner(:error, e, __STACKTRACE__)) + nil + end + + {:reply, symbols, lsp} + end + def handle_request(%WorkspaceSymbol{params: %{query: query}}, lsp) do filter = fn sym -> if query == "" do diff --git a/lib/next_ls/document_symbol.ex b/lib/next_ls/document_symbol.ex new file mode 100644 index 00000000..e31fc18f --- /dev/null +++ b/lib/next_ls/document_symbol.ex @@ -0,0 +1,222 @@ +defmodule NextLS.DocumentSymbol do + alias GenLSP.Structures.{ + Position, + Range, + DocumentSymbol + } + + # we set the literal encoder so that we can know when atoms and strings start and end + # this makes it useful for knowing the exact locations of struct field definitions + @spec fetch(text :: String.t()) :: list(DocumentSymbol.t()) + def fetch(text) do + text + |> Code.string_to_quoted!( + literal_encoder: fn literal, meta -> + if is_atom(literal) or is_binary(literal) do + {:ok, {:__literal__, meta, [literal]}} + else + {:ok, literal} + end + end, + unescape: false, + token_metadata: true, + columns: true + ) + |> walker(nil) + |> List.wrap() + end + + defp walker([{{:__literal__, _, [:do]}, {_, _, _exprs} = ast}], mod) do + walker(ast, mod) + end + + defp walker({:__block__, _, exprs}, mod) do + for expr <- exprs, sym = walker(expr, mod), sym != nil do + sym + end + end + + defp walker({:defmodule, meta, [name | children]}, _mod) do + name = Macro.to_string(unliteral(name)) + + %DocumentSymbol{ + name: name, + kind: GenLSP.Enumerations.SymbolKind.module(), + children: List.flatten(for(child <- children, sym = walker(child, name), sym != nil, do: sym)), + range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:end][:line] - 1, character: meta[:end][:column] - 1} + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker({:describe, meta, [name | children]}, mod) do + name = ("describe " <> Macro.to_string(unliteral(name))) |> String.replace("\n", "") + + %DocumentSymbol{ + name: name, + kind: GenLSP.Enumerations.SymbolKind.class(), + children: List.flatten(for(child <- children, sym = walker(child, mod), sym != nil, do: sym)), + range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:end][:line] - 1, character: meta[:end][:column] - 1} + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker({:defstruct, meta, [fields]}, mod) do + fields = + for field <- fields do + {name, start_line, start_column} = + case field do + {:__literal__, meta, [name]} -> + start_line = meta[:line] - 1 + start_column = meta[:column] - 1 + name = Macro.to_string(name) + + {name, start_line, start_column} + + {{:__literal__, meta, [name]}, default} -> + start_line = meta[:line] - 1 + start_column = meta[:column] - 1 + name = to_string(name) <> ": " <> Macro.to_string(unliteral(default)) + + {name, start_line, start_column} + end + + %DocumentSymbol{ + name: name, + children: [], + kind: GenLSP.Enumerations.SymbolKind.field(), + range: %Range{ + start: %Position{ + line: start_line, + character: start_column + }, + end: %Position{ + line: start_line, + character: start_column + String.length(name) + } + }, + selection_range: %Range{ + start: %Position{line: start_line, character: start_column}, + end: %Position{line: start_line, character: start_column} + } + } + end + + %DocumentSymbol{ + name: "%#{mod}{}", + children: fields, + kind: elixir_kind_to_lsp_kind(:defstruct), + range: %Range{ + start: %Position{ + line: meta[:line] - 1, + character: meta[:column] - 1 + }, + end: %Position{ + line: meta[:end_of_expression][:line] - 1, + character: meta[:end_of_expression][:column] - 1 + } + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker({:@, meta, [{_name, _, value}]} = attribute, _) when length(value) > 0 do + %DocumentSymbol{ + name: attribute |> unliteral() |> Macro.to_string() |> String.replace("\n", ""), + children: [], + kind: elixir_kind_to_lsp_kind(:@), + range: %Range{ + start: %Position{ + line: meta[:line] - 1, + character: meta[:column] - 1 + }, + end: %Position{ + line: (meta[:end_of_expression] || meta)[:line] - 1, + character: (meta[:end_of_expression] || meta)[:column] - 1 + } + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker({type, meta, [name | _children]}, _) when type in [:test, :feature, :property] do + %DocumentSymbol{ + name: "#{type} #{Macro.to_string(unliteral(name))}" |> String.replace("\n", ""), + children: [], + kind: GenLSP.Enumerations.SymbolKind.constructor(), + range: %Range{ + start: %Position{ + line: meta[:line] - 1, + character: meta[:column] - 1 + }, + end: %Position{ + line: (meta[:end] || meta[:end_of_expression] || meta)[:line] - 1, + character: (meta[:end] || meta[:end_of_expression] || meta)[:column] - 1 + } + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker({type, meta, [name | _children]}, _) when type in [:def, :defp, :defmacro, :defmacro] do + %DocumentSymbol{ + name: "#{type} #{name |> unliteral() |> Macro.to_string()}" |> String.replace("\n", ""), + children: [], + kind: elixir_kind_to_lsp_kind(type), + range: %Range{ + start: %Position{ + line: meta[:line] - 1, + character: meta[:column] - 1 + }, + end: %Position{ + line: (meta[:end] || meta[:end_of_expression] || meta)[:line] - 1, + character: (meta[:end] || meta[:end_of_expression] || meta)[:column] - 1 + } + }, + selection_range: %Range{ + start: %Position{line: meta[:line] - 1, character: meta[:column] - 1}, + end: %Position{line: meta[:line] - 1, character: meta[:column] - 1} + } + } + end + + defp walker(_ast, _) do + nil + end + + defp unliteral(ast) do + Macro.prewalk(ast, fn + {:__literal__, _, [literal]} -> + literal + + node -> + node + end) + end + + defp elixir_kind_to_lsp_kind(:defstruct), do: GenLSP.Enumerations.SymbolKind.struct() + defp elixir_kind_to_lsp_kind(:@), do: GenLSP.Enumerations.SymbolKind.property() + + defp elixir_kind_to_lsp_kind(kind) when kind in [:def, :defp, :defmacro, :defmacrop, :test, :describe], + do: GenLSP.Enumerations.SymbolKind.function() +end diff --git a/lib/next_ls/symbol_table.ex b/lib/next_ls/symbol_table.ex index 695676b7..9bae8c49 100644 --- a/lib/next_ls/symbol_table.ex +++ b/lib/next_ls/symbol_table.ex @@ -5,6 +5,16 @@ defmodule NextLS.SymbolTable do defmodule Symbol do defstruct [:file, :module, :type, :name, :line, :col] + @type t :: %__MODULE__{ + file: String.t(), + module: module(), + type: atom(), + name: atom(), + line: integer(), + col: integer() + } + + @spec new(keyword()) :: t() def new(args) do struct(__MODULE__, args) end @@ -20,6 +30,10 @@ defmodule NextLS.SymbolTable do @spec symbols(pid() | atom()) :: list(struct()) def symbols(server), do: GenServer.call(server, :symbols) + @spec symbols(pid() | atom(), String.t()) :: list(struct()) + def symbols(server, file), do: GenServer.call(server, {:symbols, file}) + + @spec close(pid() | atom()) :: :ok | {:error, term()} def close(server), do: GenServer.call(server, :close) def init(args) do @@ -36,10 +50,26 @@ defmodule NextLS.SymbolTable do {:ok, %{table: name}} end + def handle_call({:symbols, file}, _, state) do + symbols = + case :dets.lookup(state.table, file) do + [{_, symbols} | _rest] -> symbols + _ -> [] + end + + {:reply, symbols, state} + end + def handle_call(:symbols, _, state) do symbols = :dets.foldl( - fn {_key, symbol}, acc -> [symbol | acc] end, + fn {_key, symbol}, acc -> + if String.match?(to_string(symbol.name), ~r/__.*__/) do + acc + else + [symbol | acc] + end + end, [], state.table ) @@ -63,6 +93,7 @@ defmodule NextLS.SymbolTable do } = symbols :dets.delete(state.table, mod) + :dets.delete(state.table, file) :dets.insert( state.table, @@ -94,9 +125,7 @@ defmodule NextLS.SymbolTable do ) end - for {name, {:v1, type, _meta, clauses}} <- defs, - not String.match?(to_string(name), ~r/__.*__/), - {meta, _, _, _} <- clauses do + for {name, {:v1, type, _meta, clauses}} <- defs, {meta, _, _, _} <- clauses do :dets.insert( state.table, {mod, diff --git a/test/next_ls/document_symbol_test.exs b/test/next_ls/document_symbol_test.exs new file mode 100644 index 00000000..6255f0e7 --- /dev/null +++ b/test/next_ls/document_symbol_test.exs @@ -0,0 +1,377 @@ +defmodule NextLS.DocumentSymbolTest do + use ExUnit.Case, async: true + + alias GenLSP.Structures.DocumentSymbol + alias GenLSP.Structures.Position + alias GenLSP.Structures.Range + + test "normal module" do + code = """ + defmodule Foo do + defstruct [:foo, bar: "yo"] + + defmodule State do + defstruct [:yo] + + def new(attrs) do + struct(%__MODULE__{}, attrs) + end + end + + @spec run(any(), any(), any()) :: :something + def run(foo, bar, baz) do + :something + end + end + """ + + result = NextLS.DocumentSymbol.fetch(code) + + assert [ + %DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 13, line: 1}, + start: %Position{character: 13, line: 1} + }, + range: %Range{ + end: %Position{character: 17, line: 1}, + start: %Position{character: 13, line: 1} + }, + kind: 8, + name: ":foo" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 19, line: 1}, + start: %Position{character: 19, line: 1} + }, + range: %Range{ + end: %Position{character: 28, line: 1}, + start: %Position{character: 19, line: 1} + }, + kind: 8, + name: "bar: \"yo\"" + } + ], + selection_range: %Range{ + end: %Position{character: 2, line: 1}, + start: %Position{character: 2, line: 1} + }, + range: %Range{ + end: %Position{character: 29, line: 1}, + start: %Position{character: 2, line: 1} + }, + kind: 23, + name: "%Foo{}" + }, + %DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 15, line: 4}, + start: %Position{character: 15, line: 4} + }, + range: %Range{ + end: %Position{character: 18, line: 4}, + start: %Position{character: 15, line: 4} + }, + kind: 8, + name: ":yo" + } + ], + selection_range: %Range{ + end: %Position{character: 4, line: 4}, + start: %Position{character: 4, line: 4} + }, + range: %Range{ + end: %Position{character: 19, line: 4}, + start: %Position{character: 4, line: 4} + }, + kind: 23, + name: "%State{}" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 4, line: 6}, + start: %Position{character: 4, line: 6} + }, + range: %Range{ + end: %Position{character: 4, line: 8}, + start: %Position{character: 4, line: 6} + }, + kind: 12, + name: "def new(attrs)" + } + ], + selection_range: %Range{ + end: %Position{character: 2, line: 3}, + start: %Position{character: 2, line: 3} + }, + range: %Range{ + end: %Position{character: 2, line: 9}, + start: %Position{character: 2, line: 3} + }, + kind: 2, + name: "State" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 2, line: 11}, + start: %Position{character: 2, line: 11} + }, + range: %Range{ + end: %Position{character: 46, line: 11}, + start: %Position{character: 2, line: 11} + }, + kind: 7, + name: "@spec run(any(), any(), any()) :: :something" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 2, line: 12}, + start: %Position{character: 2, line: 12} + }, + range: %Range{ + end: %Position{character: 2, line: 14}, + start: %Position{character: 2, line: 12} + }, + kind: 12, + name: "def run(foo, bar, baz)" + } + ], + selection_range: %Range{ + end: %Position{character: 0, line: 0}, + start: %Position{character: 0, line: 0} + }, + range: %Range{ + end: %Position{character: 0, line: 15}, + start: %Position{character: 0, line: 0} + }, + kind: 2, + name: "Foo" + } + ] = result + end + + test "test module" do + code = """ + defmodule FooTest do + describe "foo" do + test "a thing", %{foo: foo} do + assert true + end + + feature "does a browser thing", %{session: session} do + assert true + end + end + + property "the property holds" do + assert true + end + + test "a thing", %{foo: foo} do + assert true + end + end + """ + + result = NextLS.DocumentSymbol.fetch(code) + + assert [ + %GenLSP.Structures.DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [ + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 4, line: 2}, + start: %Position{character: 4, line: 2} + }, + range: %Range{ + end: %Position{character: 4, line: 4}, + start: %Position{character: 4, line: 2} + }, + deprecated: nil, + tags: nil, + kind: 9, + detail: nil, + name: "test \"a thing\"" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 4, line: 6}, + start: %Position{character: 4, line: 6} + }, + range: %Range{ + end: %Position{character: 4, line: 8}, + start: %Position{character: 4, line: 6} + }, + deprecated: nil, + tags: nil, + kind: 9, + detail: nil, + name: "feature \"does a browser thing\"" + } + ], + selection_range: %Range{ + end: %Position{character: 2, line: 1}, + start: %Position{character: 2, line: 1} + }, + range: %Range{ + end: %Position{character: 2, line: 9}, + start: %Position{character: 2, line: 1} + }, + deprecated: nil, + tags: nil, + kind: 5, + detail: nil, + name: "describe \"foo\"" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 2, line: 11}, + start: %Position{character: 2, line: 11} + }, + range: %Range{ + end: %Position{character: 2, line: 13}, + start: %Position{character: 2, line: 11} + }, + deprecated: nil, + tags: nil, + kind: 9, + detail: nil, + name: "property \"the property holds\"" + }, + %DocumentSymbol{ + children: [], + selection_range: %Range{ + end: %Position{character: 2, line: 15}, + start: %Position{character: 2, line: 15} + }, + range: %Range{ + end: %Position{character: 2, line: 17}, + start: %Position{character: 2, line: 15} + }, + deprecated: nil, + tags: nil, + kind: 9, + detail: nil, + name: "test \"a thing\"" + } + ], + selection_range: %Range{ + end: %Position{character: 0, line: 0}, + start: %Position{character: 0, line: 0} + }, + range: %Range{ + end: %Position{character: 0, line: 18}, + start: %Position{character: 0, line: 0} + }, + deprecated: nil, + tags: nil, + kind: 2, + detail: nil, + name: "FooTest" + } + ] == result + end + + test "two modules in one file" do + code = """ + defmodule Foo do + def run, do: :ok + end + + defmodule Bar do + def run, do: :ok + end + """ + + result = NextLS.DocumentSymbol.fetch(code) + + assert [ + %GenLSP.Structures.DocumentSymbol{ + children: [ + %GenLSP.Structures.DocumentSymbol{ + children: [], + selection_range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 2, line: 1}, + start: %GenLSP.Structures.Position{character: 2, line: 1} + }, + range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 2, line: 1}, + start: %GenLSP.Structures.Position{character: 2, line: 1} + }, + deprecated: nil, + tags: nil, + kind: 12, + detail: nil, + name: "def run" + } + ], + selection_range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 0, line: 0}, + start: %GenLSP.Structures.Position{character: 0, line: 0} + }, + range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 0, line: 2}, + start: %GenLSP.Structures.Position{character: 0, line: 0} + }, + deprecated: nil, + tags: nil, + kind: 2, + detail: nil, + name: "Foo" + }, + %GenLSP.Structures.DocumentSymbol{ + children: [ + %GenLSP.Structures.DocumentSymbol{ + children: [], + selection_range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 2, line: 5}, + start: %GenLSP.Structures.Position{character: 2, line: 5} + }, + range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 2, line: 5}, + start: %GenLSP.Structures.Position{character: 2, line: 5} + }, + deprecated: nil, + tags: nil, + kind: 12, + detail: nil, + name: "def run" + } + ], + selection_range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 0, line: 4}, + start: %GenLSP.Structures.Position{character: 0, line: 4} + }, + range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 0, line: 6}, + start: %GenLSP.Structures.Position{character: 0, line: 4} + }, + deprecated: nil, + tags: nil, + kind: 2, + detail: nil, + name: "Bar" + } + ] == result + end +end diff --git a/test/next_ls_test.exs b/test/next_ls_test.exs index 5a311797..2bcbaff5 100644 --- a/test/next_ls_test.exs +++ b/test/next_ls_test.exs @@ -83,24 +83,20 @@ defmodule NextLSTest do assert :ok == request(client, %{ - method: "textDocument/documentSymbol", + method: "textDocument/signatureHelp", id: id, jsonrpc: "2.0", - params: %{ - textDocument: %{ - uri: "file://file/doesnt/matter.ex" - } - } + params: %{position: %{line: 0, character: 0}, textDocument: %{uri: ""}} }) assert_notification "window/logMessage", %{ - "message" => "[NextLS] Method Not Found: textDocument/documentSymbol", + "message" => "[NextLS] Method Not Found: textDocument/signatureHelp", "type" => 2 } assert_error ^id, %{ "code" => -32_601, - "message" => "Method Not Found: textDocument/documentSymbol" + "message" => "Method Not Found: textDocument/signatureHelp" } end