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..615e5fd7 100644 --- a/src/requests/init.jl +++ b/src/requests/init.jl @@ -178,13 +178,32 @@ 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 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 + 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..9140ea06 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -44,6 +44,56 @@ 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 + +""" + 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 new file mode 100644 index 00000000..a0bcca31 --- /dev/null +++ b/test/test_watched_folders.jl @@ -0,0 +1,212 @@ +# 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 + +@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