Skip to content

Completions for relative imports (., .., …) and partials #1361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
151 changes: 148 additions & 3 deletions src/requests/completions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion test/requests/test_completions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
80 changes: 80 additions & 0 deletions test/requests/test_relative_module_completions.jl
Original file line number Diff line number Diff line change
@@ -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
Loading