From e2128953316144655295b9549a6991c92c474350 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Wed, 26 Aug 2026 13:53:09 -0700 Subject: [PATCH 1/2] Handle atomic folder renames/deletes in watched-files handling VS Code reports an atomic folder rename or delete as a single didChangeWatchedFiles event for the folder path, with no events for the files inside, and none of the registered file-extension globs match a folder path - so the server never noticed and kept stale files (e.g. test items from a renamed test/ folder). For VS Code clients, additionally register a '**' watcher for create/delete (no glob can match directories only) and restrict the extension globs to change events so every event kind has exactly one source. Other clients keep the previous registration, since some expand '**' into one OS watcher per directory. In the notification handler, sweep all tracked files below a deleted path (prefix match with a trailing slash so removing test/ does not sweep a sibling test2/) and scan the contents of a created directory. Co-Authored-By: Claude Fable 5 --- Project.toml | 2 +- src/requests/actions.jl | 2 +- src/requests/init.jl | 26 +++++- src/requests/workspace.jl | 42 +++++++++ src/utilities.jl | 6 ++ test/test_watched_folders.jl | 172 +++++++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 test/test_watched_folders.jl diff --git a/Project.toml b/Project.toml index 0d05b671..d04e9259 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "LanguageServer" uuid = "2b0e0bc5-e4fd-59b4-8912-456d1b03d8d7" -version = "5.1.0" +version = "5.2.0" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" diff --git a/src/requests/actions.jl b/src/requests/actions.jl index 0abe58cb..23723a11 100644 --- a/src/requests/actions.jl +++ b/src/requests/actions.jl @@ -52,7 +52,7 @@ function textDocument_codeAction_request(params::CodeActionParams, server::Langu kind = _jw_action_kind_to_lsp(a.kind) # VS Code workaround: SourceOrganizeImports doesn't show in the UI if kind !== missing && kind == CodeActionKinds.SourceOrganizeImports && - server.clientInfo !== missing && occursin("code", lowercase(server.clientInfo.name)) + client_is_vscode(server) kind = CodeActionKinds.RefactorRewrite end preferred = client_preferred_support(server) && a.is_preferred ? true : missing diff --git a/src/requests/init.jl b/src/requests/init.jl index e66e8f67..f49a10b3 100644 --- a/src/requests/init.jl +++ b/src/requests/init.jl @@ -178,13 +178,29 @@ function initialized_notification(params::InitializedParams, server::LanguageSer server.clientCapabilities.workspace.didChangeWatchedFiles.dynamicRegistration && server.clientCapabilities.workspace.didChangeWatchedFiles.relativePatternSupport + file_watchers = [ + FileSystemWatcher("**/*.{jl,jmd,md}", missing), + FileSystemWatcher("**/{Project.toml,JuliaProject.toml,Manifest.toml,JuliaManifest.toml,JuliaLint.toml,JuliaFormat.toml,JuliaTestItems.toml}", missing), + FileSystemWatcher("**/{JuliaManifest,Manifest}-v$(VERSION.major).$(VERSION.minor).toml", missing), + ] + + if client_is_vscode(server) + # VS Code reports an atomic folder rename or delete as a single event + # for the folder path, with no events for the files inside, and no + # glob can match "directories only". Watch everything for + # create/delete (the workspace watcher is recursive anyway, so this + # only widens event delivery) and let the notification handler sort + # out directories vs. relevant files; the extension globs then only + # need to deliver content changes. Not registered for other clients: + # some (e.g. Emacs-based ones) expand `**` into one OS watcher per + # directory. + file_watchers = [FileSystemWatcher(w.globPattern, WatchKinds.Change) for w in file_watchers] + push!(file_watchers, FileSystemWatcher("**", WatchKinds.Create | WatchKinds.Delete)) + end + push!( client_capabilities_registrations, - Registration("workspace/didChangeWatchedFiles", "workspace/didChangeWatchedFiles", DidChangeWatchedFilesRegistrationOptions([ - FileSystemWatcher("**/*.{jl,jmd,md}", missing), - FileSystemWatcher("**/{Project.toml,JuliaProject.toml,Manifest.toml,JuliaManifest.toml,JuliaLint.toml,JuliaFormat.toml,JuliaTestItems.toml}", missing), - FileSystemWatcher("**/{JuliaManifest,Manifest}-v$(VERSION.major).$(VERSION.minor).toml", missing), - ])) + Registration("workspace/didChangeWatchedFiles", "workspace/didChangeWatchedFiles", DidChangeWatchedFilesRegistrationOptions(file_watchers)) ) end diff --git a/src/requests/workspace.jl b/src/requests/workspace.jl index 515f2599..fea378b9 100644 --- a/src/requests/workspace.jl +++ b/src/requests/workspace.jl @@ -1,3 +1,33 @@ +# An atomic folder rename or delete arrives as a single event for the folder +# path with no events for the files inside, so sweep everything tracked below +# `uri`. For a plain file the sweep matches nothing. The trailing slash matters: +# without it removing `.../test` would also sweep a sibling `.../test2`. +function remove_folder_children!(server::LanguageServerInstance, uri::URI) + prefix = string(uri) * "/" + tracked_uris = union(Set(keys(server._files_from_disc)), server._workspace_files) + for tracked in tracked_uris + startswith(string(tracked), prefix) || continue + delete!(server._files_from_disc, tracked) + # Same guard as for exact-URI deletes: files open in the editor stay in + # the workspace until the editor closes them. + haskey(server._open_file_versions, tracked) && continue + if JuliaWorkspaces.has_file(server.workspace, tracked) + JuliaWorkspaces.remove_file!(server.workspace, tracked) + end + delete!(server._workspace_files, tracked) + end +end + +# A folder appeared (typically the destination of an atomic rename): scan it +# like a workspace folder at startup and return the URIs of the added files. +function add_folder_children!(server::LanguageServerInstance, path::String) + files_to_add = collect_folder_files!(server, path) + if !isempty(files_to_add) + JuliaWorkspaces.add_files!(server.workspace, files_to_add) + end + return URI[tf.uri for tf in files_to_add] +end + function workspace_didChangeWatchedFiles_notification(params::DidChangeWatchedFilesParams, server::LanguageServerInstance, conn) @debug "workspace/didChangeWatchedFiles" change_count=length(params.changes) @@ -22,6 +52,14 @@ function workspace_didChangeWatchedFiles_notification(params::DidChangeWatchedFi end if change.type == FileChangeTypes.Created || change.type == FileChangeTypes.Changed + filepath = uri2filepath(uri) + if change.type == FileChangeTypes.Created && filepath !== nothing && isdir(filepath) + # A created directory (e.g. the destination of an atomic folder + # rename) carries no per-file events, so scan its contents. + append!(changed_uris, add_folder_children!(server, filepath)) + continue + end + text_file = JuliaWorkspaces.read_text_file_from_uri(uri, return_nothing_on_io_error=true) # First handle case where file could not be found or has invalid content @@ -56,6 +94,10 @@ function workspace_didChangeWatchedFiles_notification(params::DidChangeWatchedFi if !haskey(server._open_file_versions, uri) delete!(server._workspace_files, uri) end + + # The deleted path may have been a directory; `isdir` cannot tell + # anymore, so always sweep (a no-op for plain files). + remove_folder_children!(server, uri) else error("Unknown change type.") end diff --git a/src/utilities.jl b/src/utilities.jl index 87077060..8568e203 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -44,6 +44,12 @@ function isvalidjlfile(path) endswith(path, ".jl") end +# Matches "Visual Studio Code" and "Visual Studio Code - Insiders", but not +# forks that report their own name (VSCodium, Cursor, ...). +function client_is_vscode(server) + server.clientInfo !== missing && occursin("code", lowercase(server.clientInfo.name)) +end + if VERSION < v"1.1" || Sys.iswindows() && VERSION < v"1.3" _splitdir_nodrive(path::String) = _splitdir_nodrive("", path) diff --git a/test/test_watched_folders.jl b/test/test_watched_folders.jl new file mode 100644 index 00000000..e55f6f76 --- /dev/null +++ b/test/test_watched_folders.jl @@ -0,0 +1,172 @@ +# An atomic folder rename or delete reaches the server as a single +# didChangeWatchedFiles event for the folder path, with no events for the files +# inside. These tests cover the folder handling in +# workspace_didChangeWatchedFiles_notification. + +@testitem "Watched folders: folder delete sweeps children but not siblings" begin + import Pkg + using LanguageServer.URIs2 + using LanguageServer: LanguageServerInstance + using JuliaWorkspaces: JuliaWorkspaces, has_file + import JSONRPC + JSONRPC.send(::Nothing, ::Any, ::Any) = nothing + + server = LanguageServerInstance(IOBuffer(), IOBuffer(), dirname(Pkg.Types.Context().env.project_file)) + server.jr_endpoint = nothing + server.workspace = JuliaWorkspaces.JuliaWorkspace() + + mktempdir() do dir + test_dir = joinpath(dir, "test") + sibling_dir = joinpath(dir, "test2") + mkpath(joinpath(test_dir, "sub")) + mkpath(sibling_dir) + a_path = joinpath(test_dir, "a.jl") + b_path = joinpath(test_dir, "sub", "b.jl") + c_path = joinpath(sibling_dir, "c.jl") + for p in (a_path, b_path, c_path) + write(p, "f() = 1\n") + end + + changed = LanguageServer.add_folder_children!(server, dir) + a_uri, b_uri, c_uri = filepath2uri.((a_path, b_path, c_path)) + @test Set(changed) == Set([a_uri, b_uri, c_uri]) + @test has_file(server.workspace, a_uri) + @test has_file(server.workspace, b_uri) + + # Delete the folder on disc and report only the folder-level event, the + # way an atomic rename/delete arrives. + test_dir_uri = filepath2uri(test_dir) + rm(test_dir, recursive=true) + params = LanguageServer.DidChangeWatchedFilesParams([ + LanguageServer.FileEvent(test_dir_uri, LanguageServer.FileChangeTypes.Deleted), + ]) + LanguageServer.workspace_didChangeWatchedFiles_notification(params, server, nothing) + + @test !has_file(server.workspace, a_uri) + @test !has_file(server.workspace, b_uri) + @test !haskey(server._files_from_disc, a_uri) + @test !haskey(server._files_from_disc, b_uri) + @test !(a_uri in server._workspace_files) + @test !(b_uri in server._workspace_files) + + # `test2` is a sibling whose path shares the `test` prefix; it must + # survive the sweep. + @test has_file(server.workspace, c_uri) + @test haskey(server._files_from_disc, c_uri) + @test c_uri in server._workspace_files + end +end + +@testitem "Watched folders: folder create scans children" begin + import Pkg + using LanguageServer.URIs2 + using LanguageServer: LanguageServerInstance + using JuliaWorkspaces: JuliaWorkspaces, has_file + import JSONRPC + JSONRPC.send(::Nothing, ::Any, ::Any) = nothing + + server = LanguageServerInstance(IOBuffer(), IOBuffer(), dirname(Pkg.Types.Context().env.project_file)) + server.jr_endpoint = nothing + server.workspace = JuliaWorkspaces.JuliaWorkspace() + + mktempdir() do dir + new_dir = joinpath(dir, "test2") + mkpath(joinpath(new_dir, "sub")) + a_path = joinpath(new_dir, "a.jl") + b_path = joinpath(new_dir, "sub", "b.jl") + other_path = joinpath(new_dir, "data.bin") + write(a_path, "f() = 1\n") + write(b_path, "g() = 2\n") + write(other_path, "not julia") + + # Report only the folder-level create, the way an atomic rename arrives. + params = LanguageServer.DidChangeWatchedFilesParams([ + LanguageServer.FileEvent(filepath2uri(new_dir), LanguageServer.FileChangeTypes.Created), + ]) + LanguageServer.workspace_didChangeWatchedFiles_notification(params, server, nothing) + + a_uri, b_uri, other_uri = filepath2uri.((a_path, b_path, other_path)) + @test has_file(server.workspace, a_uri) + @test has_file(server.workspace, b_uri) + @test a_uri in server._workspace_files + @test b_uri in server._workspace_files + @test !has_file(server.workspace, other_uri) + end +end + +@testitem "Watched folders: rename reported as delete + create" begin + import Pkg + using LanguageServer.URIs2 + using LanguageServer: LanguageServerInstance + using JuliaWorkspaces: JuliaWorkspaces, has_file + import JSONRPC + JSONRPC.send(::Nothing, ::Any, ::Any) = nothing + + server = LanguageServerInstance(IOBuffer(), IOBuffer(), dirname(Pkg.Types.Context().env.project_file)) + server.jr_endpoint = nothing + server.workspace = JuliaWorkspaces.JuliaWorkspace() + + mktempdir() do dir + old_dir = joinpath(dir, "test") + mkpath(old_dir) + write(joinpath(old_dir, "a.jl"), "f() = 1\n") + LanguageServer.add_folder_children!(server, dir) + + old_uri = filepath2uri(joinpath(old_dir, "a.jl")) + @test has_file(server.workspace, old_uri) + + new_dir = joinpath(dir, "test2") + mv(old_dir, new_dir) + params = LanguageServer.DidChangeWatchedFilesParams([ + LanguageServer.FileEvent(filepath2uri(old_dir), LanguageServer.FileChangeTypes.Deleted), + LanguageServer.FileEvent(filepath2uri(new_dir), LanguageServer.FileChangeTypes.Created), + ]) + LanguageServer.workspace_didChangeWatchedFiles_notification(params, server, nothing) + + new_uri = filepath2uri(joinpath(new_dir, "a.jl")) + @test !has_file(server.workspace, old_uri) + @test has_file(server.workspace, new_uri) + @test new_uri in server._workspace_files + @test !(old_uri in server._workspace_files) + end +end + +@testitem "Watched folders: open files under a deleted folder stay in the workspace" begin + import Pkg + using LanguageServer.URIs2 + using LanguageServer: LanguageServerInstance + using JuliaWorkspaces: JuliaWorkspaces, has_file + import JSONRPC + JSONRPC.send(::Nothing, ::Any, ::Any) = nothing + + server = LanguageServerInstance(IOBuffer(), IOBuffer(), dirname(Pkg.Types.Context().env.project_file)) + server.jr_endpoint = nothing + server.workspace = JuliaWorkspaces.JuliaWorkspace() + + mktempdir() do dir + test_dir = joinpath(dir, "test") + mkpath(test_dir) + open_path = joinpath(test_dir, "open.jl") + closed_path = joinpath(test_dir, "closed.jl") + write(open_path, "f() = 1\n") + write(closed_path, "g() = 2\n") + LanguageServer.add_folder_children!(server, dir) + + open_uri = filepath2uri(open_path) + closed_uri = filepath2uri(closed_path) + # Pretend the editor has open.jl open. + server._open_file_versions[open_uri] = 1 + + rm(test_dir, recursive=true) + params = LanguageServer.DidChangeWatchedFilesParams([ + LanguageServer.FileEvent(filepath2uri(test_dir), LanguageServer.FileChangeTypes.Deleted), + ]) + LanguageServer.workspace_didChangeWatchedFiles_notification(params, server, nothing) + + # The open file keeps its in-memory content until the editor closes it; + # only its from-disc record is dropped. + @test has_file(server.workspace, open_uri) + @test !haskey(server._files_from_disc, open_uri) + @test !has_file(server.workspace, closed_uri) + end +end From 56ca7c34e008204b6849584cf9a38c478fa79a66 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Wed, 26 Aug 2026 15:27:21 -0700 Subject: [PATCH 2/2] Broaden directory-watching gate: Code-OSS family + explicit override The folder-event problem is specific to VS Code's watcher, but every Code-OSS fork (VSCodium, Cursor, Windsurf, Positron, ...) embeds the same LSP client and watcher while reporting a different clientInfo name, so the previous "code" substring check missed them. Replace the gate with a gopls-style three-state design: the julialangDirectoryWatching initialization option ("on"/"off", booleans accepted) explicitly overrides, and the default "auto" mode sniffs clientInfo.name against the Code-OSS family. Other clients stay off by default - most watcher backends synthesize per-file events on folder renames so the file globs already suffice, and some expand ** into one OS watcher per directory - but any client can now opt in. Co-Authored-By: Claude Fable 5 --- src/requests/init.jl | 23 +++++++++++-------- src/utilities.jl | 44 ++++++++++++++++++++++++++++++++++++ test/test_watched_folders.jl | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/requests/init.jl b/src/requests/init.jl index f49a10b3..615e5fd7 100644 --- a/src/requests/init.jl +++ b/src/requests/init.jl @@ -184,16 +184,19 @@ function initialized_notification(params::InitializedParams, server::LanguageSer FileSystemWatcher("**/{JuliaManifest,Manifest}-v$(VERSION.major).$(VERSION.minor).toml", missing), ] - if client_is_vscode(server) - # VS Code reports an atomic folder rename or delete as a single event - # for the folder path, with no events for the files inside, and no - # glob can match "directories only". Watch everything for - # create/delete (the workspace watcher is recursive anyway, so this - # only widens event delivery) and let the notification handler sort - # out directories vs. relevant files; the extension globs then only - # need to deliver content changes. Not registered for other clients: - # some (e.g. Emacs-based ones) expand `**` into one OS watcher per - # directory. + if should_watch_directories(server) + # VS Code (and the Code-OSS family) reports an atomic folder rename + # or delete as a single event for the folder path, with no events + # for the files inside, and no glob can match "directories only". + # Watch everything for create/delete (the workspace watcher is + # recursive anyway, so this only widens event delivery) and let the + # notification handler sort out directories vs. relevant files; the + # extension globs then only need to deliver content changes. Off by + # default for other clients — most watcher backends synthesize + # per-file events so the file globs suffice, and some (e.g. + # Emacs-based ones) expand `**` into one OS watcher per directory — + # but overridable via the `julialangDirectoryWatching` + # initialization option (see `should_watch_directories`). file_watchers = [FileSystemWatcher(w.globPattern, WatchKinds.Change) for w in file_watchers] push!(file_watchers, FileSystemWatcher("**", WatchKinds.Create | WatchKinds.Delete)) end diff --git a/src/utilities.jl b/src/utilities.jl index 8568e203..9140ea06 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -50,6 +50,50 @@ function client_is_vscode(server) server.clientInfo !== missing && occursin("code", lowercase(server.clientInfo.name)) end +""" + directory_watching_mode(server) -> "auto" | "on" | "off" + +The client's directory-watching preference from the `initializationOptions` key +`"julialangDirectoryWatching"`. Accepts `"on"`/`"off"` (booleans are mapped to +them); anything else, including the key being absent, means `"auto"`. +""" +function directory_watching_mode(server) + ismissing(server.initialization_options) && return "auto" + value = get(server.initialization_options, "julialangDirectoryWatching", nothing) + value == true && return "on" + value == false && return "off" + value in ("on", "off") ? value : "auto" +end + +# Client names (lowercase substrings) of the Code-OSS family, which all embed +# VS Code's LSP client and file watcher. "visual studio code" also covers the +# "- Insiders" variant. +const CODE_OSS_FAMILY_CLIENT_NAMES = ("visual studio code", "vscodium", "code - oss", "code-oss", "cursor", "windsurf", "positron") + +""" + should_watch_directories(server) -> Bool + +Whether to additionally register a `**` create/delete watcher so that atomic +folder renames and deletes are observed. VS Code's file watcher reports those as +a single event for the folder path with no per-child events, so without the +extra watcher the server never notices them; most other clients' watcher +backends synthesize per-file events, making the plain file-extension globs +sufficient there — and some (e.g. Emacs-based clients) expand `**` into one OS +watcher per directory, so it must not be forced on them. + +In the default `"auto"` mode this is enabled for clients of the Code-OSS family +(detected via `clientInfo.name`). Any client can override the guess through +`initializationOptions: { "julialangDirectoryWatching": "on" | "off" }`. +""" +function should_watch_directories(server) + mode = directory_watching_mode(server) + mode == "on" && return true + mode == "off" && return false + server.clientInfo === missing && return false + name = lowercase(server.clientInfo.name) + return any(occursin(family, name) for family in CODE_OSS_FAMILY_CLIENT_NAMES) +end + if VERSION < v"1.1" || Sys.iswindows() && VERSION < v"1.3" _splitdir_nodrive(path::String) = _splitdir_nodrive("", path) diff --git a/test/test_watched_folders.jl b/test/test_watched_folders.jl index e55f6f76..a0bcca31 100644 --- a/test/test_watched_folders.jl +++ b/test/test_watched_folders.jl @@ -170,3 +170,43 @@ end @test !has_file(server.workspace, closed_uri) end end + +@testitem "Watched folders: directory-watching gate" begin + import Pkg + using LanguageServer: LanguageServerInstance, InfoParams + + server = LanguageServerInstance(IOBuffer(), IOBuffer(), dirname(Pkg.Types.Context().env.project_file)) + + with_client(name) = (server.clientInfo = InfoParams(name, missing); server) + + # auto mode: Code-OSS-family clients get directory watching... + for name in ("Visual Studio Code", "Visual Studio Code - Insiders", "VSCodium", "Cursor", "Windsurf", "Positron", "Code - OSS") + @test LanguageServer.should_watch_directories(with_client(name)) + end + + # ...other clients do not. + for name in ("Neovim", "emacs", "Sublime Text LSP", "helix") + @test !LanguageServer.should_watch_directories(with_client(name)) + end + server.clientInfo = missing + @test !LanguageServer.should_watch_directories(server) + + # Explicit opt-in wins over the client guess. + with_client("Neovim") + server.initialization_options = Dict{String,Any}("julialangDirectoryWatching" => "on") + @test LanguageServer.should_watch_directories(server) + server.initialization_options = Dict{String,Any}("julialangDirectoryWatching" => true) + @test LanguageServer.should_watch_directories(server) + + # Explicit opt-out wins too. + with_client("Visual Studio Code") + server.initialization_options = Dict{String,Any}("julialangDirectoryWatching" => "off") + @test !LanguageServer.should_watch_directories(server) + server.initialization_options = Dict{String,Any}("julialangDirectoryWatching" => false) + @test !LanguageServer.should_watch_directories(server) + + # Unknown values fall back to auto. + server.initialization_options = Dict{String,Any}("julialangDirectoryWatching" => "sometimes") + @test LanguageServer.should_watch_directories(with_client("Visual Studio Code")) + @test !LanguageServer.should_watch_directories(with_client("Neovim")) +end