Skip to content
This repository was archived by the owner on Aug 30, 2025. It is now read-only.

Commit f7f7693

Browse files
authored
Document Symbols support (#652)
* Document Symbols support Added document symbols, which supports the following symbols: * Modules * Functions, both private and public * Typespecs * Module Attributes * ExUnit describe / setup / tests Fixes #382 * Added support for block ranges and detail ranges For document symbols, we need to provide support for block ranges for things like modules, functions and tests, so that the editor can understand if it's inside the given symbol. The LSP also would like to have selection ranges, which are more specific, and would, say highlight the function definition. * Upgraded sourceror Sourceror had a bug calculating end lines, which was causing responses not to be emitted, but only when unicode was present. It was emitting the ending several characters beyond where the `end` keyword was, and this would fail during conversion as being out of bounds.
1 parent f6ca36f commit f7f7693

File tree

20 files changed

+1072
-21
lines changed

20 files changed

+1072
-21
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file's contents are auto-generated. Do not edit.
2+
defmodule Lexical.Protocol.Types.Document.Symbol do
3+
alias Lexical.Proto
4+
alias Lexical.Protocol.Types
5+
use Proto
6+
7+
deftype children: optional(list_of(Types.Document.Symbol)),
8+
deprecated: optional(boolean()),
9+
detail: optional(string()),
10+
kind: Types.Symbol.Kind,
11+
name: string(),
12+
range: Types.Range,
13+
selection_range: Types.Range,
14+
tags: optional(list_of(Types.Symbol.Tag))
15+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# This file's contents are auto-generated. Do not edit.
2+
defmodule Lexical.Protocol.Types.Document.Symbol.Params do
3+
alias Lexical.Proto
4+
alias Lexical.Protocol.Types
5+
use Proto
6+
7+
deftype partial_result_token: optional(Types.Progress.Token),
8+
text_document: Types.TextDocument.Identifier,
9+
work_done_token: optional(Types.Progress.Token)
10+
end

apps/protocol/lib/lexical/protocol/requests.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ defmodule Lexical.Protocol.Requests do
7676
defrequest "workspace/executeCommand", Types.ExecuteCommand.Params
7777
end
7878

79+
defmodule DocumentSymbols do
80+
use Proto
81+
82+
defrequest "textDocument/documentSymbol", Types.Document.Symbol.Params
83+
end
84+
7985
# Server -> Client requests
8086

8187
defmodule RegisterCapability do

apps/protocol/lib/lexical/protocol/responses.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ defmodule Lexical.Protocol.Responses do
5050
defresponse optional(list_of(one_of([list_of(Types.Completion.Item), Types.Completion.List])))
5151
end
5252

53+
defmodule DocumentSymbols do
54+
use Proto
55+
56+
defresponse optional(list_of(Types.Document.Symbol))
57+
end
58+
5359
defmodule Shutdown do
5460
use Proto
5561
# yeah, this is odd... it has no params

apps/remote_control/lib/lexical/remote_control/api.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,8 @@ defmodule Lexical.RemoteControl.Api do
130130
def struct_definitions(%Project{} = project) do
131131
RemoteControl.call(project, CodeIntelligence.Structs, :for_project, [])
132132
end
133+
134+
def document_symbols(%Project{} = project, %Document{} = document) do
135+
RemoteControl.call(project, CodeIntelligence.Symbols, :for_document, [document])
136+
end
133137
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Lexical.RemoteControl.CodeIntelligence.Symbols do
2+
alias Lexical.Document
3+
alias Lexical.RemoteControl.CodeIntelligence.Symbols
4+
alias Lexical.RemoteControl.Search.Indexer
5+
alias Lexical.RemoteControl.Search.Indexer.Entry
6+
alias Lexical.RemoteControl.Search.Indexer.Extractors
7+
8+
@block_types [
9+
:ex_unit_describe,
10+
:ex_unit_setup,
11+
:ex_unit_setup_all,
12+
:ex_unit_test,
13+
:module,
14+
:private_function,
15+
:public_function
16+
]
17+
18+
@symbol_extractors [
19+
Extractors.FunctionDefinition,
20+
Extractors.Module,
21+
Extractors.ModuleAttribute,
22+
Extractors.StructDefinition,
23+
Extractors.ExUnit
24+
]
25+
26+
def for_document(%Document{} = document) do
27+
{:ok, entries} = Indexer.Source.index_document(document, @symbol_extractors)
28+
29+
definitions = Enum.filter(entries, &(&1.subtype == :definition))
30+
to_symbols(document, definitions)
31+
end
32+
33+
defp to_symbols(%Document{} = document, entries) do
34+
entries_by_block_id = Enum.group_by(entries, & &1.block_id)
35+
rebuild_structure(entries_by_block_id, document, :root)
36+
end
37+
38+
defp rebuild_structure(entries_by_block_id, %Document{} = document, block_id) do
39+
block_entries = Map.get(entries_by_block_id, block_id, [])
40+
41+
Enum.flat_map(block_entries, fn
42+
%Entry{type: type, subtype: :definition} = entry when type in @block_types ->
43+
result =
44+
if Map.has_key?(entries_by_block_id, entry.id) do
45+
children =
46+
entries_by_block_id
47+
|> rebuild_structure(document, entry.id)
48+
|> Enum.sort_by(fn %Symbols.Document{} = symbol ->
49+
start = symbol.range.start
50+
{start.line, start.character}
51+
end)
52+
53+
Symbols.Document.from(document, entry, children)
54+
else
55+
Symbols.Document.from(document, entry)
56+
end
57+
58+
case result do
59+
{:ok, symbol} -> [symbol]
60+
_ -> []
61+
end
62+
63+
%Entry{} = entry ->
64+
case Symbols.Document.from(document, entry) do
65+
{:ok, symbol} -> [symbol]
66+
_ -> []
67+
end
68+
end)
69+
end
70+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
defmodule Lexical.RemoteControl.CodeIntelligence.Symbols.Document do
2+
alias Lexical.Document
3+
alias Lexical.Formats
4+
alias Lexical.RemoteControl.Search.Indexer.Entry
5+
6+
defstruct [:name, :type, :range, :detail_range, :detail, children: []]
7+
8+
def from(%Document{} = document, %Entry{} = entry, children \\ []) do
9+
case name_and_type(entry.type, entry, document) do
10+
{name, type} ->
11+
range = entry.block_range || entry.range
12+
13+
{:ok,
14+
%__MODULE__{
15+
name: name,
16+
type: type,
17+
range: range,
18+
detail_range: entry.range,
19+
children: children
20+
}}
21+
22+
_ ->
23+
:error
24+
end
25+
end
26+
27+
@def_regex ~r/def\w*\s+/
28+
@do_regex ~r/\s*do\s*$/
29+
30+
defp name_and_type(function, %Entry{} = entry, %Document{} = document)
31+
when function in [:public_function, :private_function] do
32+
fragment = Document.fragment(document, entry.range.start, entry.range.end)
33+
34+
name =
35+
fragment
36+
|> String.replace(@def_regex, "")
37+
|> String.replace(@do_regex, "")
38+
39+
{name, function}
40+
end
41+
42+
@ignored_attributes ~w[spec doc moduledoc derive impl tag]
43+
@type_name_regex ~r/@type\s+[^\s]+/
44+
45+
defp name_and_type(:module_attribute, %Entry{} = entry, document) do
46+
case String.split(entry.subject, "@") do
47+
[_, name] when name in @ignored_attributes ->
48+
nil
49+
50+
[_, "type"] ->
51+
type_text = Document.fragment(document, entry.range.start, entry.range.end)
52+
53+
name =
54+
case Regex.scan(@type_name_regex, type_text) do
55+
[[match]] -> match
56+
_ -> "@type ??"
57+
end
58+
59+
{name, :type}
60+
61+
[_, name] ->
62+
{"@#{name}", :module_attribute}
63+
end
64+
end
65+
66+
defp name_and_type(ex_unit, %Entry{} = entry, document)
67+
when ex_unit in [:ex_unit_describe, :ex_unit_setup, :ex_unit_test] do
68+
name =
69+
document
70+
|> Document.fragment(entry.range.start, entry.range.end)
71+
|> String.trim()
72+
|> String.replace(@do_regex, "")
73+
74+
{name, ex_unit}
75+
end
76+
77+
defp name_and_type(:struct, %Entry{} = entry, _document) do
78+
module_name = Formats.module(entry.subject)
79+
{"%#{module_name}{}", :struct}
80+
end
81+
82+
defp name_and_type(type, %Entry{subject: name}, _document) when is_atom(name) do
83+
{Formats.module(name), type}
84+
end
85+
86+
defp name_and_type(type, %Entry{} = entry, _document) do
87+
{to_string(entry.subject), type}
88+
end
89+
end

apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
1010
:application,
1111
:id,
1212
:block_id,
13+
:block_range,
1314
:path,
1415
:range,
1516
:subject,
@@ -21,6 +22,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
2122
application: module(),
2223
subject: subject(),
2324
block_id: block_id(),
25+
block_range: Lexical.Document.Range.t() | nil,
2426
path: Path.t(),
2527
range: Lexical.Document.Range.t(),
2628
subtype: entry_subtype(),
@@ -55,16 +57,27 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
5557
new(path, Identifier.next_global!(), block.id, subject, type, :definition, range, application)
5658
end
5759

58-
def block_definition(path, %Block{} = block, subject, type, range, application) do
59-
definition(
60-
path,
61-
block.id,
62-
block.parent_id,
63-
subject,
64-
type,
65-
range,
66-
application
67-
)
60+
def block_definition(
61+
path,
62+
%Block{} = block,
63+
subject,
64+
type,
65+
block_range,
66+
detail_range,
67+
application
68+
) do
69+
definition =
70+
definition(
71+
path,
72+
block.id,
73+
block.parent_id,
74+
subject,
75+
type,
76+
detail_range,
77+
application
78+
)
79+
80+
%__MODULE__{definition | block_range: block_range}
6881
end
6982

7083
defp definition(path, id, block_id, subject, type, range, application) do

0 commit comments

Comments
 (0)