diff --git a/src/requests/completions.jl b/src/requests/completions.jl index 85370874..171855e4 100644 --- a/src/requests/completions.jl +++ b/src/requests/completions.jl @@ -62,7 +62,7 @@ function textDocument_completion_request(params::CompletionParams, server::Langu CSTParser.Tokenize.Tokens.CMD, CSTParser.Tokenize.Tokens.TRIPLE_CMD)) string_completion(t, state) - elseif state.x isa EXPR && is_in_import_statement(state.x) + elseif state.x isa EXPR && is_in_import_statement(state.x) || _relative_dot_depth_at(state.doc, state.offset) > 0 import_completions(ppt, pt, t, is_at_end, state.x, state) elseif t isa CSTParser.Tokens.Token && t.kind == CSTParser.Tokens.DOT && pt isa CSTParser.Tokens.Token && pt.kind == CSTParser.Tokens.IDENTIFIER # getfield completion, no partial @@ -173,6 +173,118 @@ function string_macro_altname(s) end end +# Find innermost module EXPR containing x (or nothing) +function _current_module_expr(x)::Union{EXPR,Nothing} + y = x + while y isa EXPR + if CSTParser.defines_module(y) + return y + end + y = parentof(y) + end + return nothing +end + +# Ascend n module EXPR ancestors (0 => same) +function _module_ancestor_expr(modexpr::Union{EXPR,Nothing}, n::Int) + n <= 0 && return modexpr + y = modexpr + while n > 0 && y isa EXPR + z = parentof(y) + while z isa EXPR && !CSTParser.defines_module(z) + z = parentof(z) + end + y = z + n -= 1 + end + return y +end + +# Count contiguous '.' for relative import at the current cursor/line end. +# Handles: "import .", "import ..", "import ...", "import .Foo", "import ..Foo", etc. +function _relative_dot_depth_at(doc::Document, offset::Int) + s = get_text(doc) + k = offset + 1 # 1-based + + # Skip trailing whitespace (space, tab, CR, LF) + while k > firstindex(s) + p = prevind(s, k) + c = s[p] + if c == ' ' || c == '\t' || c == '\r' || c == '\n' + k = p + else + break + end + end + k == firstindex(s) && return 0 + p = prevind(s, k) + c = s[p] + + # Case A: cursor directly after dots (e.g. "import ..") + if c == '.' + cnt = 0 + q = p + while q >= firstindex(s) && s[q] == '.' + cnt += 1 + q = prevind(s, q) + end + # q is now the char before the first dot (or before start) + if q >= firstindex(s) && Base.is_id_char(s[q]) + return 0 # dots follow an identifier => not relative (e.g. Base.M) + end + return cnt + end + + # Case B: cursor after identifier (e.g. "import ..Foo") + if Base.is_id_char(c) + j = p + while j > firstindex(s) && Base.is_id_char(s[j]) + j = prevind(s, j) + end + j > firstindex(s) || return 0 + if s[j] == '.' + cnt = 0 + q = j + while q >= firstindex(s) && s[q] == '.' + cnt += 1 + q = prevind(s, q) + end + # q is char before the first dot + if q >= firstindex(s) && Base.is_id_char(s[q]) + return 0 # dots follow an identifier => qualified, not relative + end + return cnt + end + end + + return 0 +end + +# Collect immediate child module names by scanning CST (works for :module and :file) +function _child_module_names(x::EXPR) + names = String[] + # For module: body in args[3]; for file: args is the body + if CSTParser.defines_module(x) + b = length(x.args) >= 3 ? x.args[3] : nothing + if b isa EXPR && headof(b) === :block && b.args !== nothing + for a in b.args + if a isa EXPR && CSTParser.defines_module(a) + n = CSTParser.isidentifier(a.args[2]) ? valof(a.args[2]) : String(to_codeobject(a.args[2])) + push!(names, String(n)) + end + end + end + elseif headof(x) === :file && x.args !== nothing + for a in x.args + if a isa EXPR && CSTParser.defines_module(a) + n = CSTParser.isidentifier(a.args[2]) ? valof(a.args[2]) : String(to_codeobject(a.args[2])) + push!(names, String(n)) + end + end + end + return names +end + function collect_completions(m::SymbolServer.ModuleStore, spartial, state::CompletionState, inclexported=false, dotcomps=false) possible_names = String[] for val in m.vals @@ -442,9 +554,42 @@ end is_in_import_statement(x::EXPR) = is_in_fexpr(x, x -> headof(x) in (:using, :import)) function import_completions(ppt, pt, t, is_at_end, x, state::CompletionState) - import_statement = StaticLint.get_parent_fexpr(x, x -> headof(x) === :using || headof(x) === :import) + # 1) Relative import completions: . .. ... and partials + depth = _relative_dot_depth_at(state.doc, state.offset) + if depth > 0 + # Find a nearby EXPR so we can locate the current module reliably even at EOL + x0 = x isa EXPR ? x : get_expr(getcst(state.doc), state.offset, 0, true) + # Current and ancestor module EXPR by CST + cur_modexpr = x0 isa EXPR ? _current_module_expr(x0) : nothing + target_modexpr = cur_modexpr === nothing ? nothing : _module_ancestor_expr(cur_modexpr, depth - 1) + # Child module names by scanning CST + names = if target_modexpr isa EXPR + _child_module_names(target_modexpr) + elseif cur_modexpr === nothing && depth == 1 + _child_module_names(getcst(state.doc)) # file-level '.' + else + String[] + end + if !isempty(names) + partial = (t.kind == CSTParser.Tokenize.Tokens.IDENTIFIER && is_at_end) ? t.val : "" + for n in names + if isempty(partial) || startswith(n, partial) + add_completion_item(state, CompletionItem( + n, + CompletionItemKinds.Module, + missing, + MarkupContent(n), + texteditfor(state, partial, n) + )) + end + end + end + return + end - import_root = get_import_root(import_statement) + # 2) Non-relative path: proceed with original logic, but guard x + import_statement = x isa EXPR ? StaticLint.get_parent_fexpr(x, y -> headof(y) === :using || headof(y) === :import) : nothing + import_root = import_statement isa EXPR ? get_import_root(import_statement) : nothing if (t.kind == CSTParser.Tokens.WHITESPACE && pt.kind ∈ (CSTParser.Tokens.USING, CSTParser.Tokens.IMPORT, CSTParser.Tokens.IMPORTALL, CSTParser.Tokens.COMMA, CSTParser.Tokens.COLON)) || (t.kind in (CSTParser.Tokens.COMMA, CSTParser.Tokens.COLON)) diff --git a/test/requests/test_completions.jl b/test/requests/test_completions.jl index a53fec32..f108bb41 100644 --- a/test/requests/test_completions.jl +++ b/test/requests/test_completions.jl @@ -48,7 +48,7 @@ end settestdoc("""module M end import .""") - @test_broken completion_test(1, 8).items[1].label == "M" + @test completion_test(1, 8).items[1].label == "M" closetestdoc() settestdoc("import Base.M") diff --git a/test/requests/test_relative_module_completions.jl b/test/requests/test_relative_module_completions.jl new file mode 100644 index 00000000..98a421a5 --- /dev/null +++ b/test/requests/test_relative_module_completions.jl @@ -0,0 +1,80 @@ +### To run this test: +### $ julia --project -e 'using TestItemRunner; @run_package_tests filter=ti->ti.name=="relative module completions"' + +@testitem "relative module completions" begin + using LanguageServer + include(pkgdir(LanguageServer, "test", "test_shared_server.jl")) + + # Helper returns context but does not print + function ctx(line::Int, col::Int) + items = completion_test(line, col).items + labels = [i.label for i in items] + doc = LanguageServer.getdocument(server, uri"untitled:testdoc") + mod = LanguageServer.julia_getModuleAt_request( + LanguageServer.VersionedTextDocumentPositionParams( + LanguageServer.TextDocumentIdentifier(uri"untitled:testdoc"), + 0, + LanguageServer.Position(line, col) + ), + server, + server.jr_endpoint + ) + text = LanguageServer.get_text(doc) + lines = split(text, '\n'; keepempty=true) + line_txt = line+1 <= length(lines) ? lines[line+1] : "" + return (labels, mod, line_txt) + end + + # Assertion helper: only prints on failure + function expect_has(line::Int, col::Int, expected::String) + labels, mod, line_txt = ctx(line, col) + ok = any(l -> l == expected, labels) + if !ok + @info "Relative completion failed" line=line col=col expected=expected moduleAt=mod lineText=line_txt labels=labels + end + @test ok + end + + # Test content: both import and using + settestdoc(""" +module A + module B + module C + module Submodule end + import . + import .. + import ... + import .Sub + import ..Sib + import ...Gran + using . + using .. + using ... + using .Sub + using ..Sib + using ...Gran + end + module Sibling end + end + module Grandsibling end +end +""") + + col = 1000 + + # import . .. ... and partials + expect_has(4, col, "Submodule") + expect_has(5, col, "Sibling") + expect_has(6, col, "Grandsibling") + expect_has(7, col, "Submodule") + expect_has(8, col, "Sibling") + expect_has(9, col, "Grandsibling") + + # using . .. ... and partials + expect_has(10, col, "Submodule") + expect_has(11, col, "Sibling") + expect_has(12, col, "Grandsibling") + expect_has(13, col, "Submodule") + expect_has(14, col, "Sibling") + expect_has(15, col, "Grandsibling") +end