Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceRoot}" ],
"args": ["--disable-extensions", "--extensionDevelopmentPath=${workspaceRoot}" ],
"sourceMaps": true,
"outFiles": ["${workspaceRoot}/out"],
},
Expand Down
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
# Change log

## ## Version 1.2.3 (September 22, 2026)
## Version 1.2.4 (September 26, 2026)

* [356](https://github.com/pgourlain/vscode_erlang/issues/356) : Header files (`.hrl`) treated as modules
- Opening a `.hrl` no longer reports `no module definition` and `record ... is unused` for every record it declares: the pull-diagnostics path (`textDocument/diagnostic`, `workspace/diagnostic`) linted any file on its own, unlike the push path which only ever lints `.erl` files. Both now lint `.erl` files only

* Diagnostics are pushed only
- Every warning and error showed twice on hover, and each quick fix was offered twice (the Problems view merged the identical entries, hiding it there): the server advertised pull diagnostics next to push, and vscode-languageclient pulls open documents on open, on tab change and on refresh whatever `diagnosticPullOptions` says, into a second collection. The server no longer advertises `diagnosticProvider`, so there is one channel and one collection, whatever the client (VS Code, Neovim, Emacs, Kiro...)
- Files that are not open still get their diagnostics, now pushed too: every module of the project is linted from disk in the background after the project scan, one file at a time, and relinted when it changes on disk. Closing a document keeps its diagnostics, recomputed from disk; a deleted file, a file excluded from the project and every file when `erlang.linting` is turned off are cleared
- Fast typing: `textDocument/didOpen`, `didChange`, `didClose` and `didSave` are now applied in the order they are received. They used to be handled concurrently, so two quick incremental edits could be applied out of order and corrupt the server's copy of the document

* Header files (`.hrl`) and the modules including them
- Fixing and saving a header now relints every module that includes it, directly or through another header, open or not: a module kept showing the header's old error until the module itself was edited. Headers changed outside the editor are picked up too (the file watcher now covers `.hrl`)

* Diagnostics for files with non-ASCII names or messages
- A module whose path contains characters outside Latin-1 (e.g. a folder named in Japanese) got no diagnostics at all: turning its path into the diagnostic failed and stopped the whole lint. Accented characters in a path or a message (e.g. an unused variable `Été`) were sent to the client as Latin-1 instead of UTF-8. Paths and messages are now encoded as UTF-8

---

## Version 1.2.3 (September 22, 2026)

* [317](https://github.com/pgourlain/vscode_erlang/issues/317), [316](https://github.com/pgourlain/vscode_erlang/issues/316) : Syntax highlighting of `fun` expressions
- `fun () -> ... end` and `fun() -> ... end` (a fun with no parameter) highlighted `fun` as a function name instead of a keyword: the rule matching the `fun()` *type* of a `-spec`/`-type` also matched the zero-arity fun *expression*. It now requires that the `)` is not followed by `->`, so the `fun()` type introduced for [297](https://github.com/pgourlain/vscode_erlang/issues/297) keeps working
Expand Down
69 changes: 46 additions & 23 deletions apps/erlangbridge/src/gen_lsp_doc_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
%% API
-export([start_link/0]).

-export([document_opened/2, document_changed/2, document_range_changed/4, document_closed/1, opened_documents/0, get_document_contents/1, get_document_version/1, parse_document/1]).
-export([document_opened/2, document_changed/2, document_range_changed/4, document_closed/1, opened_documents/0, get_document_contents/1, get_document_version/1, parse_document/1, reparse/1]).
-export([project_file_added/1, project_file_changed/1, project_file_deleted/1]).
-export([get_syntax_tree/1, get_dodged_syntax_tree/1, get_references/1, get_inlayhints/1]).
-export([get_semantic_tokens_cache/1, store_semantic_tokens_cache/3]).
-export([get_diagnostics_cache/1, store_diagnostics_cache/2]).
-export([root_available/0, config_change/0, project_modules/0, get_module_file/1, get_module_files/1, get_build_dir/0, find_source_file/1]).
-export([all_project_files/0]).
-export([all_project_files/0, get_includers/1]).

%% Cache management
-export([delete_unused_caches/2,
Expand Down Expand Up @@ -132,6 +131,17 @@ parse_document(File) ->
ok
end.

%% @doc Parse File again now, from disk unless it is open - for a closed file
%% known to have changed on disk (or just closed with its edits discarded)
%% while the tree held for it is still stamped as current.
%%
%% No diagnostics cache is kept next to the trees: with push only, a file is
%% linted once per event that can change its diagnostics (open document edit,
%% close, change on disk, project scan) and never again on a client's request,
%% so there is no repeated lint for a cache to save.
reparse(File) ->
parse_and_store(File, File).

project_file_added(File) ->
gen_server:cast(?SERVER, {project_file_added, File}).

Expand Down Expand Up @@ -196,23 +206,6 @@ get_semantic_tokens_cache(File) ->
store_semantic_tokens_cache(File, ResultId, Tokens) ->
?XETS:insert(document_semantic_tokens, {File, ResultId, Tokens}).

%% @doc Diagnostics as last computed for File, or undefined when never
%% computed or computed against an older revision of the document.
%%
%% The LSP 3.17 pull endpoints (lsp_handlers:textDocument_diagnostic/2 and
%% workspace_diagnostic/2) would otherwise re-lint every project file on every
%% pull. Keyed by the same document_version counter the push path uses, and
%% dropped outright by parse_and_store/2, so a reparse always recomputes.
get_diagnostics_cache(File) ->
Version = get_document_version(File),
case ?XETS:lookup(document_diagnostics, File) of
[{File, Version, Diagnostics}] -> Diagnostics;
_ -> undefined
end.

store_diagnostics_cache(File, Diagnostics) ->
?XETS:insert(document_diagnostics, {File, get_document_version(File), Diagnostics}).

root_available() ->
gen_server:cast(?SERVER, root_available).

Expand Down Expand Up @@ -278,9 +271,9 @@ start_link() ->
safe_new_table(syntax_tree, ?XETS, set, ExtraCreateOpts),
safe_new_table(dodged_syntax_tree, ?XETS, set, ExtraCreateOpts),
safe_new_table(references, ets, bag, []),
safe_new_table(document_includes, ets, set, []),
safe_new_table(document_inlayhints, ?XETS, set, ExtraCreateOpts),
safe_new_table(document_semantic_tokens, ?XETS, set, ExtraCreateOpts),
safe_new_table(document_diagnostics, ?XETS, set, ExtraCreateOpts),
gen_server:start_link({local, ?SERVER}, ?MODULE, [],[]).

init(_Args) ->
Expand Down Expand Up @@ -347,7 +340,6 @@ terminate(_Reason, _State) ->
delete_cache_file(dodged_syntax_tree),
delete_cache_file(document_inlayhints),
delete_cache_file(document_semantic_tokens),
delete_cache_file(document_diagnostics),
ok.

code_change(_OldVersion, State, _Extra) ->
Expand Down Expand Up @@ -550,6 +542,7 @@ delete_project_files([File | Files], State) ->
?XETS:delete(syntax_tree, File),
?XETS:delete(dodged_syntax_tree, File),
ets:delete(references, File),
ets:delete(document_includes, File),
?XETS:delete(document_inlayhints, File),
?XETS:delete(document_semantic_tokens, File),
Module = filename:rootname(filename:basename(File)),
Expand Down Expand Up @@ -619,13 +612,13 @@ parse_and_store(File, ContentsFile) ->
end.

parse_and_store(File, ContentsFile, Version) ->
?XETS:delete(document_diagnostics, File),
{SyntaxTree, DodgedSyntaxTree} = lsp_parse:parse_source_file(File, ContentsFile),
case SyntaxTree of
undefined ->
ok;
_ ->
?XETS:insert(syntax_tree, {File, Version, SyntaxTree}),
ets:insert(document_includes, {File, included_files(File, SyntaxTree)}),
ets:delete(references, File),
?XETS:delete(document_inlayhints, File),
lsp_navigation:fold_references(fun (Reference, Line, Column, End, _) ->
Expand All @@ -638,6 +631,36 @@ parse_and_store(File, ContentsFile, Version) ->
_ -> ?XETS:insert(dodged_syntax_tree, {File, Version, DodgedSyntaxTree})
end.

%% Every file epp entered while parsing File - its headers, and theirs.
included_files(File, SyntaxTree) ->
lists:usort([normalize_path(Included) ||
{attribute, _, file, {Included, _}} <- SyntaxTree, Included =/= File]).

%% @doc Project files whose last parse included Hrl, directly or through
%% another header - what has to be reparsed when Hrl changes. A scan of the
%% whole index rather than a reverse one: it only runs when a header changes,
%% while a reverse index would cost a table scan on every parse to drop the
%% file's old entries.
get_includers(Hrl) ->
Target = normalize_path(Hrl),
ets:foldl(fun ({File, Included}, Acc) ->
case lists:member(Target, Included) of
true -> [File | Acc];
false -> Acc
end
end, [], document_includes).

%% Absolute, with `.` and `..` segments resolved, so that an include path
%% like "src/../include/x.hrl" matches the watcher's "include/x.hrl".
normalize_path(Path) ->
[Root | Segments] = filename:split(filename:absname(unicode:characters_to_list(Path))),
filename:join([Root | lists:reverse(lists:foldl(fun
(".", Acc) -> Acc;
("..", [_ | Acc]) -> Acc;
("..", []) -> [];
(Segment, Acc) -> [Segment | Acc]
end, [], Segments))]).

%% `undefined` for never parsed, `{Version, Tree}` otherwise - Version being
%% the document_version the contents were read at (see parse_document/1).
get_tree(TreeType, File) ->
Expand Down
20 changes: 18 additions & 2 deletions apps/erlangbridge/src/gen_lsp_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ send_to_client(Socket, Method, Body) ->
gen_tcp:send(Socket, <<Header/binary, "\r\n\r\n", Json/binary>>).


%% Document synchronisation notifications are applied right here, in the
%% socket process, in the order they came off the wire: an incremental
%% didChange is positioned against the buffer left by the previous one, so
%% two of them handled by independent processes could land in either order,
%% or both read the same buffer and lose one edit. Their handlers only touch
%% the buffer tables and hand any linting to lsp_diagnostics, so this does not
%% hold up the socket. Everything else still gets a process of its own.
dispatch(Socket, #{method := Method} = Input) when
Method =:= <<"textDocument/didOpen">>;
Method =:= <<"textDocument/didChange">>;
Method =:= <<"textDocument/didClose">>;
Method =:= <<"textDocument/didSave">> ->
do_contents(Socket, Input);
dispatch(Socket, Input) ->
spawn(fun() -> do_contents(Socket, Input) end).

handle_tcp_data(Socket, Contents, State) ->
StateWithContents = State#state{contents = <<(State#state.contents)/binary, Contents/binary>>},
StateWithLength = case StateWithContents#state.content_length of
Expand Down Expand Up @@ -223,12 +239,12 @@ handle_tcp_data(Socket, Contents, State) ->
StateWithLength;
ContentLength when ContentLength =:= byte_size(StateWithLength#state.contents) ->
{ok, Input, _} = vscode_jsone_decode:decode(StateWithLength#state.contents, [{keys, atom}]),
spawn(fun() -> do_contents(Socket, Input) end),
dispatch(Socket, Input),
StateWithLength#state{contents = <<"">>, content_length = undefined};
ContentLength when ContentLength < byte_size(StateWithLength#state.contents) ->
ShorterContents = binary:part(StateWithLength#state.contents, 0, ContentLength),
{ok, Input, _} = vscode_jsone_decode:decode(ShorterContents, [{keys, atom}]),
spawn(fun() -> do_contents(Socket, Input) end),
dispatch(Socket, Input),
handle_tcp_data(
Socket,
binary:part(StateWithLength#state.contents, ContentLength, byte_size(StateWithLength#state.contents) - ContentLength),
Expand Down
Loading
Loading