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
136 changes: 117 additions & 19 deletions handlers.v
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response {
line_nr := request.params.position.line + 1
col := request.params.position.char
path := request.params.text_document.uri

// Intercept completion on import lines
if method == .completion {
if content := app.open_files[path] {
lines := content.split_into_lines()
if line_nr - 1 < lines.len {
current_line := lines[line_nr - 1]
if current_line.trim_space().starts_with('import') {
work_dir := os.dir(uri_to_path(path))
completions := get_import_completions(current_line, work_dir)
if completions.len > 0 {
return Response{
id: request.id
result: completions
}
}
}
}
}
}

line_info := match method {
.completion, .hover {
'${line_nr}:${col}'
Expand Down Expand Up @@ -279,14 +300,98 @@ fn parse_imports(content string) []string {
return imports
}

// get_import_completions returns completion items for an `import` line.
// It lists vlib modules and local project modules matching the typed prefix.
fn get_import_completions(line string, work_dir string) []Detail {
trimmed := line.trim_space()
if !trimmed.starts_with('import') {
return []
}
// typed is everything after 'import', e.g. '', 'enc', 'encoding', 'encoding.'
typed := if trimmed.len > 7 { trimmed[7..].trim_space() } else { '' }

mut results := []Detail{}

// Split on '.' to determine nesting level.
// e.g. 'encoding.' → parts = ['encoding', ''], base = ['encoding'], prefix = ''
// e.g. 'encoding.b' → parts = ['encoding', 'b'], base = ['encoding'], prefix = 'b'
// e.g. 'enc' → parts = ['enc'], base = [], prefix = 'enc'
parts := typed.split('.')
base_path_parts := parts[..parts.len - 1] // all but last
prefix := parts.last() // filter on last segment

// Build vlib search path
vlib_dir := os.join_path(@VEXEROOT, 'vlib')
search_dir := if base_path_parts.len > 0 {
os.join_path(vlib_dir, base_path_parts.join(os.path_separator))
} else {
vlib_dir
}

// List matching subdirectories in vlib
if os.is_dir(search_dir) {
entries := os.ls(search_dir) or { [] }
for entry in entries {
if !entry.starts_with(prefix) {
continue
}
full_path := os.join_path(search_dir, entry)
if !os.is_dir(full_path) {
continue
}
// Include dirs that contain at least one non-test .v file directly,
// or that contain subdirectories (namespaces like encoding/).
children := os.ls(full_path) or { [] }
has_v := children.any(it.ends_with('.v') && !it.ends_with('_test.v'))
has_subdir := children.any(os.is_dir(os.join_path(full_path, it)))
if !has_v && !has_subdir {
continue
}
results << Detail{
kind: 9 // CompletionItemKind.Module
label: entry
detail: 'V stdlib module'
insert_text: entry
}
}
}

// Also add local project modules (top-level only, when no dots typed yet)
if work_dir != '' && base_path_parts.len == 0 {
entries := os.ls(work_dir) or { [] }
for entry in entries {
if !entry.starts_with(prefix) || entry.starts_with('.') {
continue
}
full_path := os.join_path(work_dir, entry)
if !os.is_dir(full_path) {
continue
}
v_files := os.ls(full_path) or { [] }
has_v := v_files.any(it.ends_with('.v') && !it.ends_with('_test.v'))
if !has_v {
continue
}
results << Detail{
kind: 9
label: entry
detail: 'Local module'
insert_text: entry
}
}
}

return results
}

// find_doc_comment_for_symbol searches for the vdoc comment for `symbol` across
// multiple sources in priority order:
// 1. current file lines (already split)
// 2. other open files in app.open_files
// 3. all .v files in the project working directory
// 4. vlib/builtin/ (always, for built-in functions like println)
// 5. vlib/<module>/ for each module imported in the current file
fn (app &App) find_doc_comment_for_symbol(symbol string, current_lines []string, current_file_uri string, vroot string) string {
fn (app &App) find_doc_comment_for_symbol(symbol string, current_lines []string, current_file_uri string) string {
// 1. Current file
decl_line := find_declaration_line(current_lines, symbol)
if decl_line >= 0 {
Expand Down Expand Up @@ -335,12 +440,8 @@ fn (app &App) find_doc_comment_for_symbol(symbol string, current_lines []string,
}
}

if vroot == '' {
return ''
}

// 4. vlib/builtin/ — always search for built-in symbols
builtin_dir := os.join_path(vroot, 'vlib', 'builtin')
builtin_dir := os.join_path(@VEXEROOT, 'vlib', 'builtin')
if os.is_dir(builtin_dir) {
doc := search_doc_in_vlib_dir(builtin_dir, symbol)
if doc != '' {
Expand All @@ -353,7 +454,7 @@ fn (app &App) find_doc_comment_for_symbol(symbol string, current_lines []string,
for module_path in parse_imports(current_content) {
// Convert 'v.util' → 'v/util', 'os' → 'os'
module_rel := module_path.replace('.', os.path_separator)
module_dir := os.join_path(vroot, 'vlib', module_rel)
module_dir := os.join_path(@VEXEROOT, 'vlib', module_rel)
if !os.is_dir(module_dir) {
continue
}
Expand Down Expand Up @@ -509,18 +610,15 @@ fn (mut app App) handle_inlay_hints(request Request) Response {
}

// Add vlib modules imported by this file
vroot := find_vroot()
if vroot != '' {
imported_mods := parse_imports(content)
for mod in imported_mods {
mod_path := mod.replace('.', '/')
vlib_mod_dir := os.join_path(vroot, 'vlib', mod_path)
if os.is_dir(vlib_mod_dir) {
vlib_files := os.walk_ext(vlib_mod_dir, '.v')
for vf in vlib_files {
if !vf.ends_with('_test.v') {
index_files << vf
}
imported_mods := parse_imports(content)
for mod in imported_mods {
mod_path := mod.replace('.', '/')
vlib_mod_dir := os.join_path(@VEXEROOT, 'vlib', mod_path)
if os.is_dir(vlib_mod_dir) {
vlib_files := os.walk_ext(vlib_mod_dir, '.v')
for vf in vlib_files {
if !vf.ends_with('_test.v') {
index_files << vf
}
}
}
Expand Down
73 changes: 70 additions & 3 deletions handlers_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -1891,7 +1891,7 @@ fn test_find_doc_comment_for_symbol_current_file() {
uri := 'file:///tmp/test_greet.v'
app.open_files[uri] = content
lines := content.split_into_lines()
doc := app.find_doc_comment_for_symbol('greet', lines, uri, '')
doc := app.find_doc_comment_for_symbol('greet', lines, uri)
assert doc == 'greet says hello'
}

Expand All @@ -1909,7 +1909,7 @@ fn test_find_doc_comment_for_symbol_other_open_file() {
app.open_files[current_uri] = current_content
current_lines := current_content.split_into_lines()

doc := app.find_doc_comment_for_symbol('helper', current_lines, current_uri, '')
doc := app.find_doc_comment_for_symbol('helper', current_lines, current_uri)
assert doc == 'helper does the thing'
}

Expand All @@ -1922,7 +1922,7 @@ fn test_find_doc_comment_for_symbol_not_found() {
uri := 'file:///tmp/main.v'
app.open_files[uri] = content
lines := content.split_into_lines()
doc := app.find_doc_comment_for_symbol('nonexistent', lines, uri, '')
doc := app.find_doc_comment_for_symbol('nonexistent', lines, uri)
assert doc == ''
}

Expand Down Expand Up @@ -2494,3 +2494,70 @@ greeting := get_greeting()
assert false, 'Expected []InlayHint'
}
}

// ============================================================================
// Tests for get_import_completions
// ============================================================================

fn test_import_completions_non_import_line() {
results := get_import_completions('fn main() {', '')
assert results.len == 0
}

fn test_import_completions_empty_prefix() {
results := get_import_completions('import ', '')
// Should return all vlib top-level modules (non-empty)
assert results.len > 0
// All results should have kind 9 (Module)
for r in results {
assert r.kind == 9
}
}

fn test_import_completions_partial_prefix() {
results := get_import_completions('import enc', '')
// Should return only modules starting with 'enc' (e.g. 'encoding')
assert results.len > 0
for r in results {
assert r.label.starts_with('enc')
}
}

fn test_import_completions_nested() {
encoding_dir := os.join_path(@VEXEROOT, 'vlib', 'encoding')
if !os.is_dir(encoding_dir) {
return
}
results := get_import_completions('import encoding.', '')
// Should return submodules of encoding/
assert results.len > 0
for r in results {
// insert_text is just the segment (e.g. 'base64'), not the full path,
// so the editor inserts it after the dot the user already typed.
it := r.insert_text or { '' }
assert !it.contains('.')
assert r.detail == 'V stdlib module'
}
}

fn test_import_completions_local_module() {
temp_dir := os.join_path(os.temp_dir(), 'vls_import_test_${os.getpid()}')
os.mkdir_all(temp_dir) or { panic(err) }
defer {
os.rmdir_all(temp_dir) or {}
}

// Create a local module directory with a .v file
mymod_dir := os.join_path(temp_dir, 'mymod')
os.mkdir_all(mymod_dir) or { panic(err) }
os.write_file(os.join_path(mymod_dir, 'mymod.v'), 'module mymod\n') or { panic(err) }

results := get_import_completions('import ', temp_dir)
labels := results.map(it.label)
assert 'mymod' in labels

local_results := results.filter(it.label == 'mymod')
assert local_results.len == 1
assert local_results[0].detail == 'Local module'
assert local_results[0].insert_text or { '' } == 'mymod'
}
15 changes: 1 addition & 14 deletions interop.v
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,6 @@ fn path_to_uri(path string) string {
return uri_header + normalized
}

// find_vroot locates the V installation directory by finding the `v` executable
// and returning its parent directory, provided a `vlib/` subdirectory exists there.
fn find_vroot() string {
v_exe := os.find_abs_path_of_executable('v') or { return '' }
candidate := os.dir(v_exe)
if os.is_dir(os.join_path(candidate, 'vlib')) {
return candidate
}
return ''
}

fn (mut app App) run_v_check(path string, text string) []JsonError {
real_path := uri_to_path(path)
working_dir := os.dir(real_path)
Expand Down Expand Up @@ -369,9 +358,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R
if cursor_line >= 0 && cursor_line < file_lines.len {
symbol := get_word_at_col(file_lines[cursor_line], cursor_col)
if symbol != '' {
vroot := find_vroot()
doc = app.find_doc_comment_for_symbol(symbol, file_lines, path,
vroot)
doc = app.find_doc_comment_for_symbol(symbol, file_lines, path)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion main.v
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) {
change: 1 // 1 = Full sync
}
completion_provider: CompletionProvider{
trigger_characters: ['.']
trigger_characters: ['.', ' ']
completion_item: CompletionItemCapability{
snippet_support: true
}
Expand Down