Skip to content

Commit ec60127

Browse files
authored
Merge pull request #1361 from gcv/fix-relative-modules
Completions for relative imports (., .., …) and partials
2 parents 7ff8a47 + d7a5fad commit ec60127

File tree

3 files changed

+229
-4
lines changed

3 files changed

+229
-4
lines changed

src/requests/completions.jl

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function textDocument_completion_request(params::CompletionParams, server::Langu
6262
CSTParser.Tokenize.Tokens.CMD,
6363
CSTParser.Tokenize.Tokens.TRIPLE_CMD))
6464
string_completion(t, state)
65-
elseif state.x isa EXPR && is_in_import_statement(state.x)
65+
elseif state.x isa EXPR && is_in_import_statement(state.x) || _relative_dot_depth_at(state.doc, state.offset) > 0
6666
import_completions(ppt, pt, t, is_at_end, state.x, state)
6767
elseif t isa CSTParser.Tokens.Token && t.kind == CSTParser.Tokens.DOT && pt isa CSTParser.Tokens.Token && pt.kind == CSTParser.Tokens.IDENTIFIER
6868
# getfield completion, no partial
@@ -173,6 +173,118 @@ function string_macro_altname(s)
173173
end
174174
end
175175

176+
# Find innermost module EXPR containing x (or nothing)
177+
function _current_module_expr(x)::Union{EXPR,Nothing}
178+
y = x
179+
while y isa EXPR
180+
if CSTParser.defines_module(y)
181+
return y
182+
end
183+
y = parentof(y)
184+
end
185+
return nothing
186+
end
187+
188+
# Ascend n module EXPR ancestors (0 => same)
189+
function _module_ancestor_expr(modexpr::Union{EXPR,Nothing}, n::Int)
190+
n <= 0 && return modexpr
191+
y = modexpr
192+
while n > 0 && y isa EXPR
193+
z = parentof(y)
194+
while z isa EXPR && !CSTParser.defines_module(z)
195+
z = parentof(z)
196+
end
197+
y = z
198+
n -= 1
199+
end
200+
return y
201+
end
202+
203+
# Count contiguous '.' for relative import at the current cursor/line end.
204+
# Handles: "import .", "import ..", "import ...", "import .Foo", "import ..Foo", etc.
205+
function _relative_dot_depth_at(doc::Document, offset::Int)
206+
s = get_text(doc)
207+
k = offset + 1 # 1-based
208+
209+
# Skip trailing whitespace (space, tab, CR, LF)
210+
while k > firstindex(s)
211+
p = prevind(s, k)
212+
c = s[p]
213+
if c == ' ' || c == '\t' || c == '\r' || c == '\n'
214+
k = p
215+
else
216+
break
217+
end
218+
end
219+
k == firstindex(s) && return 0
220+
p = prevind(s, k)
221+
c = s[p]
222+
223+
# Case A: cursor directly after dots (e.g. "import ..")
224+
if c == '.'
225+
cnt = 0
226+
q = p
227+
while q >= firstindex(s) && s[q] == '.'
228+
cnt += 1
229+
q = prevind(s, q)
230+
end
231+
# q is now the char before the first dot (or before start)
232+
if q >= firstindex(s) && Base.is_id_char(s[q])
233+
return 0 # dots follow an identifier => not relative (e.g. Base.M)
234+
end
235+
return cnt
236+
end
237+
238+
# Case B: cursor after identifier (e.g. "import ..Foo")
239+
if Base.is_id_char(c)
240+
j = p
241+
while j > firstindex(s) && Base.is_id_char(s[j])
242+
j = prevind(s, j)
243+
end
244+
j > firstindex(s) || return 0
245+
if s[j] == '.'
246+
cnt = 0
247+
q = j
248+
while q >= firstindex(s) && s[q] == '.'
249+
cnt += 1
250+
q = prevind(s, q)
251+
end
252+
# q is char before the first dot
253+
if q >= firstindex(s) && Base.is_id_char(s[q])
254+
return 0 # dots follow an identifier => qualified, not relative
255+
end
256+
return cnt
257+
end
258+
end
259+
260+
return 0
261+
end
262+
263+
# Collect immediate child module names by scanning CST (works for :module and :file)
264+
function _child_module_names(x::EXPR)
265+
names = String[]
266+
# For module: body in args[3]; for file: args is the body
267+
if CSTParser.defines_module(x)
268+
b = length(x.args) >= 3 ? x.args[3] : nothing
269+
if b isa EXPR && headof(b) === :block && b.args !== nothing
270+
for a in b.args
271+
if a isa EXPR && CSTParser.defines_module(a)
272+
n = CSTParser.isidentifier(a.args[2]) ? valof(a.args[2]) : String(to_codeobject(a.args[2]))
273+
push!(names, String(n))
274+
end
275+
end
276+
end
277+
elseif headof(x) === :file && x.args !== nothing
278+
for a in x.args
279+
if a isa EXPR && CSTParser.defines_module(a)
280+
n = CSTParser.isidentifier(a.args[2]) ? valof(a.args[2]) : String(to_codeobject(a.args[2]))
281+
push!(names, String(n))
282+
end
283+
end
284+
end
285+
return names
286+
end
287+
176288
function collect_completions(m::SymbolServer.ModuleStore, spartial, state::CompletionState, inclexported=false, dotcomps=false)
177289
possible_names = String[]
178290
for val in m.vals
@@ -442,9 +554,42 @@ end
442554
is_in_import_statement(x::EXPR) = is_in_fexpr(x, x -> headof(x) in (:using, :import))
443555

444556
function import_completions(ppt, pt, t, is_at_end, x, state::CompletionState)
445-
import_statement = StaticLint.get_parent_fexpr(x, x -> headof(x) === :using || headof(x) === :import)
557+
# 1) Relative import completions: . .. ... and partials
558+
depth = _relative_dot_depth_at(state.doc, state.offset)
559+
if depth > 0
560+
# Find a nearby EXPR so we can locate the current module reliably even at EOL
561+
x0 = x isa EXPR ? x : get_expr(getcst(state.doc), state.offset, 0, true)
562+
# Current and ancestor module EXPR by CST
563+
cur_modexpr = x0 isa EXPR ? _current_module_expr(x0) : nothing
564+
target_modexpr = cur_modexpr === nothing ? nothing : _module_ancestor_expr(cur_modexpr, depth - 1)
565+
# Child module names by scanning CST
566+
names = if target_modexpr isa EXPR
567+
_child_module_names(target_modexpr)
568+
elseif cur_modexpr === nothing && depth == 1
569+
_child_module_names(getcst(state.doc)) # file-level '.'
570+
else
571+
String[]
572+
end
573+
if !isempty(names)
574+
partial = (t.kind == CSTParser.Tokenize.Tokens.IDENTIFIER && is_at_end) ? t.val : ""
575+
for n in names
576+
if isempty(partial) || startswith(n, partial)
577+
add_completion_item(state, CompletionItem(
578+
n,
579+
CompletionItemKinds.Module,
580+
missing,
581+
MarkupContent(n),
582+
texteditfor(state, partial, n)
583+
))
584+
end
585+
end
586+
end
587+
return
588+
end
446589

447-
import_root = get_import_root(import_statement)
590+
# 2) Non-relative path: proceed with original logic, but guard x
591+
import_statement = x isa EXPR ? StaticLint.get_parent_fexpr(x, y -> headof(y) === :using || headof(y) === :import) : nothing
592+
import_root = import_statement isa EXPR ? get_import_root(import_statement) : nothing
448593

449594
if (t.kind == CSTParser.Tokens.WHITESPACE && pt.kind (CSTParser.Tokens.USING, CSTParser.Tokens.IMPORT, CSTParser.Tokens.IMPORTALL, CSTParser.Tokens.COMMA, CSTParser.Tokens.COLON)) ||
450595
(t.kind in (CSTParser.Tokens.COMMA, CSTParser.Tokens.COLON))

test/requests/test_completions.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ end
4848

4949
settestdoc("""module M end
5050
import .""")
51-
@test_broken completion_test(1, 8).items[1].label == "M"
51+
@test completion_test(1, 8).items[1].label == "M"
5252
closetestdoc()
5353

5454
settestdoc("import Base.M")
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
### To run this test:
2+
### $ julia --project -e 'using TestItemRunner; @run_package_tests filter=ti->ti.name=="relative module completions"'
3+
4+
@testitem "relative module completions" begin
5+
using LanguageServer
6+
include(pkgdir(LanguageServer, "test", "test_shared_server.jl"))
7+
8+
# Helper returns context but does not print
9+
function ctx(line::Int, col::Int)
10+
items = completion_test(line, col).items
11+
labels = [i.label for i in items]
12+
doc = LanguageServer.getdocument(server, uri"untitled:testdoc")
13+
mod = LanguageServer.julia_getModuleAt_request(
14+
LanguageServer.VersionedTextDocumentPositionParams(
15+
LanguageServer.TextDocumentIdentifier(uri"untitled:testdoc"),
16+
0,
17+
LanguageServer.Position(line, col)
18+
),
19+
server,
20+
server.jr_endpoint
21+
)
22+
text = LanguageServer.get_text(doc)
23+
lines = split(text, '\n'; keepempty=true)
24+
line_txt = line+1 <= length(lines) ? lines[line+1] : ""
25+
return (labels, mod, line_txt)
26+
end
27+
28+
# Assertion helper: only prints on failure
29+
function expect_has(line::Int, col::Int, expected::String)
30+
labels, mod, line_txt = ctx(line, col)
31+
ok = any(l -> l == expected, labels)
32+
if !ok
33+
@info "Relative completion failed" line=line col=col expected=expected moduleAt=mod lineText=line_txt labels=labels
34+
end
35+
@test ok
36+
end
37+
38+
# Test content: both import and using
39+
settestdoc("""
40+
module A
41+
module B
42+
module C
43+
module Submodule end
44+
import .
45+
import ..
46+
import ...
47+
import .Sub
48+
import ..Sib
49+
import ...Gran
50+
using .
51+
using ..
52+
using ...
53+
using .Sub
54+
using ..Sib
55+
using ...Gran
56+
end
57+
module Sibling end
58+
end
59+
module Grandsibling end
60+
end
61+
""")
62+
63+
col = 1000
64+
65+
# import . .. ... and partials
66+
expect_has(4, col, "Submodule")
67+
expect_has(5, col, "Sibling")
68+
expect_has(6, col, "Grandsibling")
69+
expect_has(7, col, "Submodule")
70+
expect_has(8, col, "Sibling")
71+
expect_has(9, col, "Grandsibling")
72+
73+
# using . .. ... and partials
74+
expect_has(10, col, "Submodule")
75+
expect_has(11, col, "Sibling")
76+
expect_has(12, col, "Grandsibling")
77+
expect_has(13, col, "Submodule")
78+
expect_has(14, col, "Sibling")
79+
expect_has(15, col, "Grandsibling")
80+
end

0 commit comments

Comments
 (0)