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 Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/requests/actions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions src/requests/init.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions src/requests/workspace.jl
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/utilities.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
212 changes: 212 additions & 0 deletions test/test_watched_folders.jl
Original file line number Diff line number Diff line change
@@ -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
Loading