diff --git a/handlers_test.v b/handlers_test.v index c5a8cdaf..2f0f9ede 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -3765,7 +3765,7 @@ fn test_handle_rename_refuses_incomplete_oversized_sibling_index() { test_file := os.join_path(test_dir, 'main.v') content := 'module main\n\nfn target() {\n\ttarget()\n}\n' must_write_file(test_file, content) - must_write_file(os.join_path(test_dir, 'oversized.v'), 'x'.repeat(index_max_file_bytes + 1)) + must_write_file(os.join_path(test_dir, 'oversized.v'), 'x'.repeat(int(index_max_file_bytes) + 1)) uri := path_to_uri(test_file) app.open_files[uri] = content diff --git a/index_test.v b/index_test.v index 3f459705..d78354b0 100644 --- a/index_test.v +++ b/index_test.v @@ -114,7 +114,7 @@ fn test_watched_file_reindex_drops_oversized_disk_entry() { app.occurrences_for(uri) assert uri in app.ref_occurrences - os.write_file(path, 'x'.repeat(index_max_file_bytes + 1)) or { + os.write_file(path, 'x'.repeat(int(index_max_file_bytes) + 1)) or { assert false, 'grow watched file failed: ${err}' return } @@ -816,7 +816,7 @@ fn test_index_completeness_is_scoped_to_relevant_project() { return } path_b := os.join_path(root_b, 'oversized.v') - os.write_file(path_b, 'x'.repeat(index_max_file_bytes + 1)) or { + os.write_file(path_b, 'x'.repeat(int(index_max_file_bytes) + 1)) or { assert false, 'write oversized root_b file failed: ${err}' return } diff --git a/integration_test.v b/integration_test.v index d3410bdf..f8e187bf 100644 --- a/integration_test.v +++ b/integration_test.v @@ -59,9 +59,9 @@ fn create_integration_test_env() (&App, string) { integration_test_must_mkdir_all(project_dir) app := &App{ - text: '' + text: '' open_files: map[string]string{} - temp_dir: temp_dir + temp_dir: temp_dir } return app, project_dir } @@ -2793,13 +2793,14 @@ fn test_integration_sublime_text_lsp_handshake() { cleanup_integration_test_env(app, project_dir) } root_uri := path_to_uri(project_dir) - initialize := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":42,"clientInfo":{"name":"Sublime Text LSP","version":"2.13.0"},"locale":"en","rootUri":"${root_uri}","rootPath":"${project_dir}","workspaceFolders":[{"uri":"${root_uri}","name":"test_project"}],"capabilities":{"general":{"positionEncodings":["utf-16"]},"workspace":{"workspaceFolders":true,"configuration":true,"didChangeWatchedFiles":{"dynamicRegistration":true,"relativePatternSupport":true}},"textDocument":{"synchronization":{"dynamicRegistration":true,"willSave":true,"willSaveWaitUntil":true,"didSave":true},"completion":{"dynamicRegistration":true,"completionItem":{"snippetSupport":true,"documentationFormat":["markdown","plaintext"]}},"hover":{"dynamicRegistration":true,"contentFormat":["markdown","plaintext"]},"publishDiagnostics":{"versionSupport":true}},"window":{"workDoneProgress":true}},"initializationOptions":{}}}' + json_root_path := project_dir.replace('\\', '\\\\') + initialize := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":42,"clientInfo":{"name":"Sublime Text LSP","version":"2.13.0"},"locale":"en","rootUri":"${root_uri}","rootPath":"${json_root_path}","workspaceFolders":[{"uri":"${root_uri}","name":"test_project"}],"capabilities":{"general":{"positionEncodings":["utf-16"]},"workspace":{"workspaceFolders":true,"configuration":true,"didChangeWatchedFiles":{"dynamicRegistration":true,"relativePatternSupport":true}},"textDocument":{"synchronization":{"dynamicRegistration":true,"willSave":true,"willSaveWaitUntil":true,"didSave":true},"completion":{"dynamicRegistration":true,"completionItem":{"snippetSupport":true,"documentationFormat":["markdown","plaintext"]}},"hover":{"dynamicRegistration":true,"contentFormat":["markdown","plaintext"]},"publishDiagnostics":{"versionSupport":true}},"window":{"workDoneProgress":true}},"initializationOptions":{}}}' initialized := '{"jsonrpc":"2.0","method":"initialized","params":{}}' output := integration_run_frames(mut app, project_dir, 'sublime_handshake', [initialize, initialized]) assert app.received_initialize - assert app.workspace_roots == [project_dir] + assert app.workspace_roots == [project_dir.replace('\\', '/')] assert app.position_encoding == .utf16 assert app.supports_dynamic_watched_files_registration assert app.supports_work_done_progress diff --git a/interop.v b/interop.v index 0aad112f..c798b8f0 100644 --- a/interop.v +++ b/interop.v @@ -12,9 +12,51 @@ import time fn resolve_v_compiler_exe() string { configured := os.getenv('VLS_V_COMMAND').trim_space() if configured != '' { + unwrapped := resolve_wrapper_target(configured) + if unwrapped != '' { + return unwrapped + } return configured } - return os.find_abs_path_of_executable('v') or { 'v' } + found := os.find_abs_path_of_executable('v') or { 'v' } + unwrapped := resolve_wrapper_target(found) + if unwrapped != '' { + return unwrapped + } + return found +} + +// resolve_wrapper_target unwraps a Windows launcher shim (a `.bat`/`.cmd` +// file that forwards to the real compiler executable) so compiler-relative +// lookups such as the vlib directory resolve against the real installation +// instead of the shim's directory. It returns '' when `candidate` is not a +// wrapper or no usable target is found, in which case the caller keeps the +// original path. +fn resolve_wrapper_target(candidate string) string { + if candidate == '' || candidate == 'v' { + return '' + } + lower := candidate.to_lower() + if !(lower.ends_with('.bat') || lower.ends_with('.cmd')) { + return '' + } + content := os.read_file(candidate) or { return '' } + for line in content.split_into_lines() { + trimmed := line.trim_space() + if !trimmed.starts_with('"') { + continue + } + end := trimmed[1..].index('"') or { continue } + target := trimmed[1..end + 1] + target_lower := target.to_lower() + if target_lower.ends_with('.bat') || target_lower.ends_with('.cmd') { + continue + } + if os.is_file(target) { + return target + } + } + return '' } // compiler_is_available reports whether the V compiler was resolved to a real @@ -267,7 +309,8 @@ fn build_v_check_args_multifile(is_library_module bool) []string { } fn build_v_line_info_args_multifile(rel_file string, line_info string) []string { - return ['-w', '-check', '-nocolor', '-vls-mode', '-line-info', '${rel_file}:${line_info}', '.'] + return ['-w', '-check', '-nocolor', '-vls-mode', '-line-info', '${rel_file}:${line_info}', + '.'] } fn build_v_line_info_args_single(file_to_check string, line_info string, compile_target string) []string { @@ -284,8 +327,8 @@ fn build_v_line_info_args_single(file_to_check string, line_info string, compile // than assumed from a version number. enum LineInfoMode { unknown // not probed yet — try the options as-is - direct // the compiler answers `-vls-mode` / `-line-info` itself - compat // reaching the checker needs `-old-compiler` + direct // the compiler answers `-vls-mode` / `-line-info` itself + compat // reaching the checker needs `-old-compiler` missing // no compatibility compiler either — answer from VLS's own index } @@ -338,6 +381,56 @@ fn compiler_refused_and_stopped(output string) bool { return refusals > 0 } +// compiler_lacks_compatibility_compiler reports whether an invocation failed +// because the V launcher could not reach the V1 compatibility compiler, which is +// what owns `-vls-mode` / `-line-info`. The launcher prints a single-line +// refusal and exits without compiling, and that refusal is not an "unknown +// option" line, so `compiler_rejects_line_info` never sees it. Without this +// check VLS keeps paying a process launch per request for an answer that can +// never arrive, and answers every hover, completion, and definition with an +// empty result while saying nothing about why. Each refusal form below is one +// `ensure_v1_fallback` failure in the launcher. +fn compiler_lacks_compatibility_compiler(output string) bool { + for line in output.split_into_lines() { + trimmed := line.trim_space() + if trimmed == '' { + continue + } + // A launcher that can build the fallback itself announces that first and + // then answers the request, so this form must retire nothing. + if trimmed.contains('running `make v1` now') { + return false + } + if trimmed.contains('requires the compatibility compiler') { + return true + } + if trimmed.contains('`-old-compiler` was requested') { + return true + } + if trimmed.starts_with('`make v1` failed') { + return true + } + if trimmed.starts_with('`make v1` completed without installing') { + return true + } + } + return false +} + +// report_missing_compatibility_compiler tells the user that the configured `v` +// cannot serve the compiler-backed lookups, and how to fix it. Without this the +// editor shows a VLS that highlights code but silently answers nothing for +// completion, hover, signature help, and go to definition, which is +// indistinguishable from VLS being broken. +// +// No "already warned" flag is needed: the caller sets `line_info_mode` to +// `.missing` first, and from then on `run_v_line_info` returns from its early +// `.missing` check without reaching this point, so it is reached at most once per +// session. +fn (mut app App) report_missing_compatibility_compiler() { + app.send_show_message('vls: the V compiler on PATH cannot serve completion, hover, signature help, or go to definition, because its V1 compatibility compiler is missing. Install `make`, then run `make v1` in your V source directory, or point `v.vls.command` at a V that has it. Diagnostics and formatting are unaffected.', 2) +} + // normalize_v_line_info_output extracts the actual line-info payload from the // compatibility compiler's combined stdout/stderr. Newer launchers may prepend // an option notice before delegating to the established compiler. @@ -465,11 +558,11 @@ fn parse_v_check_diagnostic_header(line string, source_dir string) ?JsonError { if marker_idx > best_marker_idx { best_marker_idx = marker_idx best = JsonError{ - path: path + path: path message: line[marker_idx + marker.len..] line_nr: line_nr_text.int() - col: col_text.int() - level: if level.contains('error') { 'error' } else { level } + col: col_text.int() + level: if level.contains('error') { 'error' } else { level } } } break @@ -517,8 +610,8 @@ fn (mut app App) cache_v_check_result(path string, content_hash int, generation } app.diag_cache[path] = DiagCacheEntry{ content_hash: content_hash - generation: generation - errors: errors + generation: generation + errors: errors } } @@ -553,7 +646,7 @@ fn run_v_argv(args []string, work_folder string) os.Result { log(msg) return os.Result{ exit_code: 1 - output: msg + output: msg } } v_exe := resolve_v_compiler_exe() @@ -615,12 +708,12 @@ fn run_v_argv(args []string, work_folder string) os.Result { if timed_out { return os.Result{ exit_code: compiler_exit_timeout - output: '' + output: '' } } return os.Result{ exit_code: code - output: out.str() + output: out.str() } } @@ -728,23 +821,24 @@ fn (mut app App) prepare_compilation_overlay(real_path string) !CompilationOverl } } return CompilationOverlay{ - source_root: source_root + source_root: source_root source_display_root: source_display_root - temp_root: temp_root - source_work_dir: source_work_dir - temp_work_dir: temp_work_dir - temp_source_file: os.join_path(temp_root, file_rel) + temp_root: temp_root + source_work_dir: source_work_dir + temp_work_dir: temp_work_dir + temp_source_file: os.join_path(temp_root, file_rel) } } // source_path_from_overlay maps compiler paths in the temporary project back to // their original source paths. -fn source_path_from_overlay_with_windows_rules(reported_path string, overlay CompilationOverlay, windows bool) string { +fn source_path_from_overlay_with_windows_rules(reported_path string, overlay CompilationOverlay, windows bool, base_dir string) string { mut candidate := normalize_overlay_path_with_windows_rules(reported_path, windows) + resolve_base := if base_dir != '' { base_dir } else { overlay.temp_work_dir } if candidate.starts_with('./') || candidate.starts_with('.\\') { - candidate = os.join_path(overlay.temp_work_dir, candidate[2..]) + candidate = os.join_path(resolve_base, candidate[2..]) } else if !os.is_abs_path(candidate) { - candidate = os.join_path(overlay.temp_work_dir, candidate) + candidate = os.join_path(resolve_base, candidate) } candidate = normalize_overlay_path_with_windows_rules(candidate, windows) temp_root := normalize_overlay_path_with_windows_rules(overlay.temp_root, windows) @@ -755,11 +849,11 @@ fn source_path_from_overlay_with_windows_rules(reported_path string, overlay Com return candidate } -fn source_path_from_overlay(reported_path string, overlay CompilationOverlay) string { +fn source_path_from_overlay(reported_path string, overlay CompilationOverlay, base_dir string) string { $if windows { - return source_path_from_overlay_with_windows_rules(reported_path, overlay, true) + return source_path_from_overlay_with_windows_rules(reported_path, overlay, true, base_dir) } - return source_path_from_overlay_with_windows_rules(reported_path, overlay, false) + return source_path_from_overlay_with_windows_rules(reported_path, overlay, false, base_dir) } fn (mut app App) run_v_check(path string, text string) []JsonError { @@ -837,15 +931,15 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { mut filtered_errors := []JsonError{} for err in v_errors { - err_file := source_path_from_overlay(err.path, overlay) + err_file := source_path_from_overlay(err.path, overlay, compile_target) if normalized_index_path(err_file) == normalized_index_path(real_path) { updated_err := JsonError{ - path: real_path + path: real_path message: err.message line_nr: err.line_nr - col: err.col - len: err.len - level: err.level + col: err.col + len: err.len + level: err.level } filtered_errors << updated_err log('INCLUDING ERROR from err_file=${err_file}: ${err.message}') @@ -1513,7 +1607,7 @@ fn (mut app App) line_info_unavailable_result(method Method, path string, line_i } return Hover{ contents: MarkupContent{ - kind: 'markdown' + kind: 'markdown' value: doc } } @@ -1630,7 +1724,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R output = normalize_v_line_info_output(x.output, method) } } - if compiler_rejects_line_info(x.output) && compiler_refused_and_stopped(x.output) { + if (compiler_rejects_line_info(x.output) && compiler_refused_and_stopped(x.output)) || compiler_lacks_compatibility_compiler(x.output) { // The invocation refused the options and did nothing else, so nothing // here can answer and the single-file retry below would be refused for // the same reason. An empty payload alone is not evidence: on a launcher @@ -1639,6 +1733,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R // compiler-backed hover, signature, and receiver definition. log('no compiler serves -line-info; falling back to the index') app.line_info_mode = .missing + app.report_missing_compatibility_compiler() cleanup_compilation_temp(temp_project_dir, singlefile_tmppath) return app.line_info_unavailable_result(method, path, line_info) } @@ -1696,7 +1791,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R } result = Hover{ contents: MarkupContent{ - kind: 'markdown' + kind: 'markdown' value: value } } @@ -1704,7 +1799,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R // Compiler returned no info but we found a vdoc comment result = Hover{ contents: MarkupContent{ - kind: 'markdown' + kind: 'markdown' value: doc } } @@ -1725,7 +1820,7 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R col := fields[fields.len - 1].int() mut uri_path := os.to_slash(fields[..fields.len - 2].join(':')) if use_multifile && temp_project_dir != '' { - uri_path = source_path_from_overlay(uri_path, overlay) + uri_path = source_path_from_overlay(uri_path, overlay, compile_target) log('MAPPED TO uri_path=${uri_path}') } // Build a proper percent-encoded DocumentUri so paths containing @@ -1749,13 +1844,13 @@ fn (app &App) compiler_location(path string, line int, byte_col int) Location { target_uri := index_uri_for_path(path, app.open_index_uris_by_path()) client_col := app.byte_col_to_client_col(target_uri, line, byte_col) return Location{ - uri: target_uri + uri: target_uri range: LSPRange{ start: Position{ line: line char: client_col } - end: Position{ + end: Position{ line: line char: client_col } diff --git a/interop_test.v b/interop_test.v index 3f03a75a..5d817ff7 100644 --- a/interop_test.v +++ b/interop_test.v @@ -38,6 +38,80 @@ fn test_resolve_v_compiler_exe_prefers_configured_command() { assert resolve_v_compiler_exe() == configured } +fn test_resolve_wrapper_target_ignores_plain_executable() { + assert resolve_wrapper_target('C:\\v\\v.exe') == '' + assert resolve_wrapper_target('/usr/local/bin/v') == '' + assert resolve_wrapper_target('') == '' + assert resolve_wrapper_target('v') == '' +} + +fn test_resolve_wrapper_target_ignores_missing_wrapper() { + missing := os.join_path(os.temp_dir(), 'vls_no_such_wrapper_${os.getpid()}.bat') + assert resolve_wrapper_target(missing) == '' +} + +fn test_resolve_wrapper_target_unwraps_bat_forwarding_to_exe() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_probe_${os.getpid()}') + bin := os.join_path(base, '.bin') + interop_test_must_mkdir_all(bin) + defer { + os.rmdir_all(base) or {} + } + target := os.join_path(base, 'v.exe') + interop_test_must_write_file(target, '') + wrapper := os.join_path(bin, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\n"${target}" %*\n') + assert resolve_wrapper_target(wrapper) == target +} + +fn test_resolve_wrapper_target_ignores_bat_without_quoted_target() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_bare_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + wrapper := os.join_path(base, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\nv.exe %*\n') + assert resolve_wrapper_target(wrapper) == '' +} + +fn test_resolve_wrapper_target_ignores_wrapper_chain() { + base := os.join_path(os.temp_dir(), 'vls_wrapper_chain_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + inner := os.join_path(base, 'inner.bat') + interop_test_must_write_file(inner, '@echo off\necho hi\n') + outer := os.join_path(base, 'outer.bat') + interop_test_must_write_file(outer, '@echo off\n"${inner}" %*\n') + assert resolve_wrapper_target(outer) == '' +} + +fn test_resolve_v_compiler_exe_unwraps_configured_wrapper() { + old_command := os.getenv('VLS_V_COMMAND') + defer { + if old_command == '' { + os.unsetenv('VLS_V_COMMAND') + } else { + os.setenv('VLS_V_COMMAND', old_command, true) + } + } + base := os.join_path(os.temp_dir(), 'vls_wrapper_env_${os.getpid()}') + bin := os.join_path(base, '.bin') + interop_test_must_mkdir_all(bin) + defer { + os.rmdir_all(base) or {} + } + target := os.join_path(base, 'v.exe') + interop_test_must_write_file(target, '') + wrapper := os.join_path(bin, 'v.bat') + interop_test_must_write_file(wrapper, '@echo off\n"${target}" %*\n') + os.setenv('VLS_V_COMMAND', wrapper, true) + assert resolve_v_compiler_exe() == target + assert find_v_dir() == os.dir(os.real_path(target)) +} + // --- uri_to_path tests --- fn test_uri_to_path_unix_style() { @@ -164,10 +238,10 @@ fn test_normalize_overlay_path_preserves_posix_backslashes() { fn test_source_path_from_overlay_normalizes_windows_relative_join() { overlay := CompilationOverlay{ source_display_root: r'C:\repo' - temp_root: r'C:\temp\overlay' - temp_work_dir: r'C:\temp\overlay\src' + temp_root: r'C:\temp\overlay' + temp_work_dir: r'C:\temp\overlay\src' } - mapped := source_path_from_overlay_with_windows_rules('./main.v', overlay, true) + mapped := source_path_from_overlay_with_windows_rules('./main.v', overlay, true, '') assert mapped == 'C:/repo/src/main.v' } @@ -257,7 +331,7 @@ fn test_compiler_location_reuses_equivalent_open_uri() { canonical_uri := path_to_uri(path) open_uri := canonical_uri.replace_once('file:///', 'file://localhost/') mut app := &App{ - open_files: map[string]string{} + open_files: map[string]string{} position_encoding: .utf16 } app.open_files[open_uri] = '🚀 target\n' @@ -471,6 +545,103 @@ fn test_compiler_refused_and_stopped_separates_a_dead_end_from_a_recovery() { assert !compiler_refused_and_stopped('./main.v:3:7') } +fn test_compiler_lacks_compatibility_compiler_detects_every_launcher_refusal() { + // These are the single-line refusals `ensure_v1_fallback` prints in the V + // launcher, verbatim. None of them is an "unknown option" line, so before + // this was recognized VLS never retired the lookups and never said anything. + assert compiler_lacks_compatibility_compiler('`-vls-mode` requires the compatibility compiler, but no usable V 0.5.2 fallback was found and make is unavailable. Install make, then run `make v1` in `C:\\Users\\me\\v`.') + // vlang/v#29369 rewrote the tail of that refusal: the hint after "make is + // unavailable" is now platform specific, and the sentence is split. Detection + // keys on "requires the compatibility compiler", so both spellings must retire + // the lookups, and this test has to hold either side of that change. + assert compiler_lacks_compatibility_compiler('`-vls-mode` requires the compatibility compiler, but no usable V 0.5.2 fallback was found and make is unavailable. On Windows, install GNU make in MSYS2 (`make` or `mingw32-make`) and put its tools, including `sh`, on PATH. Then run `make v1` in `C:\\Users\\me\\v`.') + assert compiler_lacks_compatibility_compiler('`-vls-mode` requires the compatibility compiler, but the V source tree could not be found. Run `make v1` in the V source directory.') + assert compiler_lacks_compatibility_compiler('`-old-compiler` was requested, but no usable V 0.5.2 fallback was found and make is unavailable. Install make, then run `make v1` in `/home/me/v`.') + // The same rewrite as above, on the host where the hint is the short one. + assert compiler_lacks_compatibility_compiler('`-old-compiler` was requested, but no usable V 0.5.2 fallback was found and make is unavailable. Install make. Then run `make v1` in `/home/me/v`.') + assert compiler_lacks_compatibility_compiler('`make v1` failed with exit code 2. Run it manually in `/home/me/v` for more details.') + assert compiler_lacks_compatibility_compiler('`make v1` completed without installing a usable V 0.5.2 fallback at `/home/me/.cache/v1_fallback`.') + // A launcher that recovers on its own only announces the fallback before + // rerunning, then answers. Retiring the lookups on that would cost the + // session every compiler-backed hover, signature, and receiver definition. + assert !compiler_lacks_compatibility_compiler('unknown option `-vls-mode`') + assert !compiler_lacks_compatibility_compiler('unknown option `-vls-mode`\nV compilation failed (compiler_error); retrying with `/v1_fallback`.') + assert !compiler_lacks_compatibility_compiler('`-vls-mode` requires the compatibility compiler, but no usable V 0.5.2 fallback was found; running `make v1` now...\n{"contents":{"kind":"markdown","value":"fn f()"}}') + // A working compiler's payload, an ordinary diagnostic, and empty output. + assert !compiler_lacks_compatibility_compiler('{"contents":{"kind":"markdown","value":"fn f()"}}') + assert !compiler_lacks_compatibility_compiler('./main.v:3:7: error: unknown option') + assert !compiler_lacks_compatibility_compiler('') +} + +// A launcher that understands `-vls-mode` but cannot reach the compatibility +// compiler that implements it, and stops there. Its refusal is not an "unknown +// option" line, which is what made it invisible to the dead-end check. +const no_compat_compiler_launcher_stub = r'#!/bin/sh +echo "\`-vls-mode\` requires the compatibility compiler, but no usable V 0.5.2 fallback was found and make is unavailable. Install make, then run \`make v1\` in \`/v\`." >&2 +exit 1 +' + +fn test_run_v_line_info_retires_lookups_when_the_compatibility_compiler_is_missing() { + $if windows { + // The stand-in launcher is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_no_compat_compiler', no_compat_compiler_launcher_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + + assert app.run_v_line_info(.hover, uri, '6:hv^4') == ResponseResult('null') + assert app.line_info_mode == .missing +} + +fn test_report_missing_compatibility_compiler_names_the_repair() { + // The editor otherwise shows a VLS that highlights code but answers nothing + // for completion, hover, signature help, or go to definition, with no hint + // that the compiler is the reason. Platform-independent: no stub compiler. + mut app := App{ + capture_output: true + } + app.report_missing_compatibility_compiler() + assert app.captured_output.len == 1 + assert app.captured_output[0].contains('window/showMessage') + // Name the actual repair, not just the symptom. + assert app.captured_output[0].contains('make v1') + assert app.captured_output[0].contains('type":2') +} + +fn test_a_retired_lookup_never_reports_the_missing_compiler_again() { + // Once the lookups are retired, `run_v_line_info` answers from the index at + // its early `.missing` check, so it never spawns the compiler and never + // re-reports. That structural guarantee is why the notice needs no + // "already warned" flag. + previous := os.getenv('VLS_V_COMMAND') + mut app := App{ + capture_output: true + line_info_mode: .missing + open_files: map[string]string{} + } + uri := 'file:///tmp/vls_retired_lookup.v' + app.open_files[uri] = 'module main\n\n// greet writes a greeting.\nfn greet() {}\n\nfn main() {\n\tgreet()\n}\n' + // A compiler that cannot be spawned at all: reaching it would fail loudly. + os.setenv('VLS_V_COMMAND', os.join_path(os.temp_dir(), 'vls_no_such_compiler'), true) + defer { + restore_v_command(previous) + } + + hover := app.run_v_line_info(.hover, uri, '7:hv^1') + // Answered from the index, from the document's own vdoc comment. + assert hover is Hover + if hover is Hover { + assert hover.contents.value.contains('greet writes a greeting.') + } + // Nothing was said to the client, and no compiler was launched. + assert app.captured_output.len == 0 + assert app.line_info_mode == .missing +} + // line_info_stub_app writes `script` as an executable stand-in for `v`, points // VLS_V_COMMAND at it, and returns an App plus the URI of a lone source file. // The source sits in its own directory so the request takes the single-file @@ -641,12 +812,12 @@ fn test_parse_v_check_diagnostics_reads_v3_output() { diagnostics := parse_v_check_diagnostics(output, '') assert diagnostics.len == 2 assert diagnostics[0] == JsonError{ - path: '/tmp/main.v' + path: '/tmp/main.v' message: 'undefined variable: `missing_name`' line_nr: 4 - col: 7 - len: 12 - level: 'error' + col: 7 + len: 12 + level: 'error' } assert diagnostics[1].level == 'warning' assert diagnostics[1].line_nr == 8 @@ -661,12 +832,12 @@ fn test_parse_v_check_diagnostics_maps_v3_builder_error_to_error() { ' diagnostics := parse_v_check_diagnostics(output, '') assert diagnostics == [JsonError{ - path: '/tmp/main.v' + path: '/tmp/main.v' message: 'cannot import module "missing" (not found)' line_nr: 3 - col: 1 - len: 14 - level: 'error' + col: 1 + len: 14 + level: 'error' }] } @@ -743,8 +914,8 @@ fn test_cache_v_check_result_retries_failure_without_diagnostics() { path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ content_hash: 1 - generation: 1 - errors: [] + generation: 1 + errors: [] } app.cache_v_check_result(path, 2, 2, [], compiler_exit_timeout, 0) assert path !in app.diag_cache @@ -755,16 +926,16 @@ fn test_cache_v_check_result_retries_timeout_with_partial_diagnostics() { path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ content_hash: 1 - generation: 1 - errors: [] + generation: 1 + errors: [] } partial_errors := [ JsonError{ - path: '/tmp/main.v' + path: '/tmp/main.v' message: 'partial compiler output' line_nr: 1 - col: 1 - level: 'error' + col: 1 + level: 'error' }, ] app.cache_v_check_result(path, 2, 2, partial_errors, compiler_exit_timeout, partial_errors.len) @@ -799,11 +970,11 @@ fn test_run_v_argv_reports_missing_working_dir() { fn test_v_error_to_lsp_diagnostic_basic() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'undefined identifier `foo`' line_nr: 10 - col: 5 - len: 3 + col: 5 + len: 3 } diag := v_error_to_lsp_diagnostic(v_err) @@ -818,11 +989,11 @@ fn test_v_error_to_lsp_diagnostic_basic() { fn test_v_error_to_lsp_diagnostic_first_line() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'syntax error' line_nr: 1 - col: 1 - len: 1 + col: 1 + len: 1 } diag := v_error_to_lsp_diagnostic(v_err) @@ -833,11 +1004,11 @@ fn test_v_error_to_lsp_diagnostic_first_line() { fn test_v_error_to_lsp_diagnostic_long_error() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'unexpected token' line_nr: 100 - col: 50 - len: 20 + col: 50 + len: 20 } diag := v_error_to_lsp_diagnostic(v_err) @@ -848,11 +1019,11 @@ fn test_v_error_to_lsp_diagnostic_long_error() { fn test_v_error_to_lsp_diagnostic_zero_length() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'error at position' line_nr: 5 - col: 10 - len: 0 + col: 10 + len: 0 } diag := v_error_to_lsp_diagnostic(v_err) @@ -862,11 +1033,11 @@ fn test_v_error_to_lsp_diagnostic_zero_length() { fn test_v_error_to_lsp_diagnostic_large_line_numbers() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'error in large file' line_nr: 10000 - col: 200 - len: 50 + col: 200 + len: 50 } diag := v_error_to_lsp_diagnostic(v_err) @@ -877,11 +1048,11 @@ fn test_v_error_to_lsp_diagnostic_large_line_numbers() { fn test_v_error_to_lsp_diagnostic_column_one() { v_err := JsonError{ - path: '/test/file.v' + path: '/test/file.v' message: 'error at start of line' line_nr: 5 - col: 1 - len: 5 + col: 1 + len: 5 } diag := v_error_to_lsp_diagnostic(v_err) @@ -917,7 +1088,7 @@ fn test_lsp_range_struct() { line: 0 char: 0 } - end: Position{ + end: Position{ line: 0 char: 10 } @@ -932,7 +1103,7 @@ fn test_lsp_range_multiline() { line: 5 char: 10 } - end: Position{ + end: Position{ line: 10 char: 5 } @@ -942,17 +1113,17 @@ fn test_lsp_range_multiline() { fn test_lsp_diagnostic_struct() { diag := LSPDiagnostic{ - range: LSPRange{ + range: LSPRange{ start: Position{ line: 5 char: 0 } - end: Position{ + end: Position{ line: 5 char: 10 } } - message: 'test error' + message: 'test error' severity: 1 } assert diag.message == 'test error' @@ -965,8 +1136,8 @@ fn test_lsp_diagnostic_severities() { severities := [1, 2, 3, 4] // Error, Warning, Information, Hint for sev in severities { diag := LSPDiagnostic{ - range: LSPRange{} - message: 'test' + range: LSPRange{} + message: 'test' severity: sev } assert diag.severity == sev @@ -975,13 +1146,13 @@ fn test_lsp_diagnostic_severities() { fn test_location_struct() { loc := Location{ - uri: 'file:///test/file.v' + uri: 'file:///test/file.v' range: LSPRange{ start: Position{ line: 10 char: 5 } - end: Position{ + end: Position{ line: 10 char: 15 } @@ -999,9 +1170,9 @@ fn test_location_empty() { fn test_detail_struct() { detail := Detail{ - kind: 6 // Function - label: 'my_function' - detail: 'fn my_function() string' + kind: 6 // Function + label: 'my_function' + detail: 'fn my_function() string' documentation: 'A helper function' } assert detail.kind == 6 @@ -1014,7 +1185,7 @@ fn test_detail_kinds() { kinds := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] // Text, Method, Function, etc. for k in kinds { detail := Detail{ - kind: k + kind: k label: 'test' } assert detail.kind == k @@ -1023,11 +1194,11 @@ fn test_detail_kinds() { fn test_detail_struct_with_snippet() { detail := Detail{ - kind: 6 - label: 'println' - detail: 'fn println(s string)' - documentation: 'Prints a string' - insert_text: 'println(\${1:s})' + kind: 6 + label: 'println' + detail: 'fn println(s string)' + documentation: 'Prints a string' + insert_text: 'println(\${1:s})' insert_text_format: 2 // Snippet format } assert detail.insert_text? == 'println(\${1:s})' @@ -1036,7 +1207,7 @@ fn test_detail_struct_with_snippet() { fn test_detail_without_snippet() { detail := Detail{ - kind: 6 + kind: 6 label: 'println' } assert detail.insert_text == none @@ -1045,9 +1216,9 @@ fn test_detail_without_snippet() { fn test_signature_help_struct() { sig := SignatureHelp{ - signatures: [ + signatures: [ SignatureInformation{ - label: 'fn my_func(a int, b string) bool' + label: 'fn my_func(a int, b string) bool' parameters: [ ParameterInformation{ label: 'a int' @@ -1068,7 +1239,7 @@ fn test_signature_help_struct() { fn test_signature_help_multiple_signatures() { sig := SignatureHelp{ - signatures: [ + signatures: [ SignatureInformation{ label: 'fn overload1(a int)' }, @@ -1096,17 +1267,17 @@ fn test_signature_help_empty() { fn test_capabilities_struct() { cap := Capabilities{ capabilities: Capability{ - text_document_sync: TextDocumentSyncOptions{ + text_document_sync: TextDocumentSyncOptions{ open_close: true - change: 1 + change: 1 } - completion_provider: CompletionProvider{ + completion_provider: CompletionProvider{ trigger_characters: ['.'] } signature_help_provider: SignatureHelpOptions{ trigger_characters: ['(', ','] } - definition_provider: true + definition_provider: true } } assert cap.capabilities.definition_provider == true @@ -1127,11 +1298,11 @@ fn test_capabilities_minimal() { fn test_request_struct() { req := Request{ - id: 1 - method: 'textDocument/completion' + id: 1 + method: 'textDocument/completion' jsonrpc: '2.0' - params: json2.encode(Params{ - position: Position{ + params: json2.encode(Params{ + position: Position{ line: 5 char: 10 } @@ -1162,7 +1333,7 @@ fn test_request_params_decode_malformed_returns_error() { fn test_response_struct() { resp := Response{ - id: 1 + id: 1 result: 'null' } assert resp.id == 1 @@ -1171,7 +1342,7 @@ fn test_response_struct() { fn test_response_with_capabilities() { resp := Response{ - id: 0 + id: 0 result: Capabilities{ capabilities: Capability{ definition_provider: true @@ -1188,7 +1359,7 @@ fn test_notification_struct() { notif := Notification{ method: 'textDocument/publishDiagnostics' params: PublishDiagnosticsParams{ - uri: 'file:///test.v' + uri: 'file:///test.v' diagnostics: [] } } @@ -1200,16 +1371,16 @@ fn test_notification_with_diagnostics() { notif := Notification{ method: 'textDocument/publishDiagnostics' params: PublishDiagnosticsParams{ - uri: 'file:///test.v' + uri: 'file:///test.v' diagnostics: [ LSPDiagnostic{ - range: LSPRange{} - message: 'error 1' + range: LSPRange{} + message: 'error 1' severity: 1 }, LSPDiagnostic{ - range: LSPRange{} - message: 'error 2' + range: LSPRange{} + message: 'error 2' severity: 1 }, ] @@ -1252,7 +1423,7 @@ fn test_write_tracked_files_to_temp_single_file() { interop_test_must_write_file(test_file, 'module main') mut app := &App{ - temp_dir: temp_dir + temp_dir: temp_dir open_files: map[string]string{} } @@ -1290,7 +1461,7 @@ fn test_write_tracked_files_to_temp_multiple_files() { } mut app := &App{ - temp_dir: temp_dir + temp_dir: temp_dir open_files: map[string]string{} } @@ -1331,7 +1502,7 @@ fn test_write_tracked_files_to_temp_nested_directories() { interop_test_must_write_file(nested_file, 'module internal') mut app := &App{ - temp_dir: temp_dir + temp_dir: temp_dir open_files: map[string]string{} } @@ -1371,7 +1542,7 @@ fn test_prepare_compilation_overlay_preserves_nested_symlink_layout() { main_uri := path_to_uri(main_file) unsaved_content := 'module main\n\nfn unsaved() {}\n' mut app := &App{ - temp_dir: app_temp_dir + temp_dir: app_temp_dir open_files: { main_uri: unsaved_content } @@ -1391,7 +1562,7 @@ fn test_prepare_compilation_overlay_preserves_nested_symlink_layout() { assert overlay.temp_work_dir == os.join_path(overlay.temp_root, 'src') assert overlay.temp_source_file == os.join_path(overlay.temp_root, 'src', 'main.v') assert os.read_file(overlay.temp_source_file) or { '' } == unsaved_content - mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay) + mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay, '') assert normalize_overlay_path(mapped_path) == normalize_overlay_path(main_file) } @@ -1412,7 +1583,7 @@ fn test_prepare_compilation_overlay_preserves_posix_backslashes() { assert uri_to_path(main_uri) == main_file unsaved_content := 'module main\n\nfn unsaved() {}\n' mut app := &App{ - temp_dir: app_temp_dir + temp_dir: app_temp_dir open_files: { main_uri: unsaved_content } @@ -1429,7 +1600,7 @@ fn test_prepare_compilation_overlay_preserves_posix_backslashes() { assert overlay.source_root == project_dir assert overlay.source_display_root == project_dir assert os.read_file(overlay.temp_source_file) or { '' } == unsaved_content - mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay) + mapped_path := source_path_from_overlay(overlay.temp_source_file, overlay, '') assert mapped_path == main_file } } @@ -1450,7 +1621,7 @@ fn test_write_tracked_files_skips_files_outside_working_dir() { interop_test_must_write_file(other_file, 'module other') mut app := &App{ - temp_dir: temp_dir + temp_dir: temp_dir open_files: map[string]string{} } @@ -1911,8 +2082,8 @@ fn test_json_error_negative_values() { // to 0 rather than emitted as negative positions (P1-09). err := JsonError{ line_nr: -1 - col: -1 - len: -1 + col: -1 + len: -1 } diag := v_error_to_lsp_diagnostic(err) assert diag.severity == 1 @@ -1939,11 +2110,11 @@ fn test_params_struct_complete() { content_changes: [ContentChange{ text: 'test' }] - position: Position{ + position: Position{ line: 5 char: 10 } - text_document: TextDocumentIdentifier{ + text_document: TextDocumentIdentifier{ uri: 'file:///test.v' } } @@ -1971,7 +2142,7 @@ fn test_signature_help_options_triggers() { fn test_text_document_sync_options() { sync := TextDocumentSyncOptions{ open_close: true - change: 1 // Full sync + change: 1 // Full sync } assert sync.open_close == true assert sync.change == 1 @@ -1980,7 +2151,7 @@ fn test_text_document_sync_options() { fn test_text_document_sync_incremental() { sync := TextDocumentSyncOptions{ open_close: true - change: 2 // Incremental sync + change: 2 // Incremental sync } assert sync.change == 2 } @@ -1994,7 +2165,7 @@ fn test_parameter_information() { fn test_signature_information_with_params() { sig := SignatureInformation{ - label: 'fn test(a int, b string, c bool)' + label: 'fn test(a int, b string, c bool)' parameters: [ ParameterInformation{ label: 'a int' @@ -2013,11 +2184,11 @@ fn test_signature_information_with_params() { fn test_publish_diagnostics_params() { params := PublishDiagnosticsParams{ - uri: 'file:///test.v' + uri: 'file:///test.v' diagnostics: [ LSPDiagnostic{ - range: LSPRange{} - message: 'error' + range: LSPRange{} + message: 'error' severity: 1 }, ] diff --git a/snapshot_test.v b/snapshot_test.v new file mode 100644 index 00000000..26b24196 --- /dev/null +++ b/snapshot_test.v @@ -0,0 +1,176 @@ +module main + +import os +import json2 + +const snapshot_dir = 'testdata/snapshots' + +fn must_mkdir_all(path string) { + os.mkdir_all(path) or { + assert false, 'Failed to create directory ${path}: ${err}' + return + } +} + +fn must_write_file(path string, content string) { + os.write_file(path, content) or { + assert false, 'Failed to write file ${path}: ${err}' + return + } +} + +fn create_test_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_snap_${os.getpid()}') + os.mkdir_all(temp_dir) or { + assert false, 'Failed to create test temp dir: ${err}' + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } + } + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +fn cleanup_test_app(app &App) { + os.rmdir_all(app.temp_dir) or {} +} +const update_env = 'VLS_UPDATE_SNAPSHOTS' + +fn snapshot_path(name string) string { + return os.join_path(snapshot_dir, '${name}.json') +} + +fn should_update_snapshots() bool { + return os.getenv(update_env) == '1' +} + +fn save_snapshot(name string, content string) ! { + os.mkdir_all(snapshot_dir) or { return err } + os.write_file(snapshot_path(name), content) or { return err } +} + +fn load_snapshot(name string) !string { + return os.read_file(snapshot_path(name)) or { return err } +} + +fn assert_snapshot(name string, actual string) { + if should_update_snapshots() { + save_snapshot(name, actual) or {} + return + } + expected := load_snapshot(name) or { + save_snapshot(name, actual) or {} + return + } + assert actual == expected, 'snapshot mismatch for ${name}:\n--- expected ---\n${expected}\n--- actual ---\n${actual}' +} + +fn response_to_json(resp Response) string { + return json2.encode(resp, escape_unicode: true) +} + +fn snapshot_completion(name string, content string, line int, ch int) { + mut app := create_test_app() + defer { cleanup_test_app(app) } + + tmp_dir := os.join_path(app.temp_dir, 'snap') + must_mkdir_all(tmp_dir) + file := os.join_path(tmp_dir, 'main.v') + must_write_file(file, content) + + uri := path_to_uri(file) + app.text = content + app.open_files[uri] = content + + req := Request{ + id: 1 + method: 'textDocument/completion' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ uri: uri } + position: Position{ line: line, char: ch } + }, escape_unicode: true) + } + + resp := app.operation_at_pos(.completion, req) + json := response_to_json(resp) + assert_snapshot(name, json) +} + +fn snapshot_hover(name string, content string, line int, ch int) { + mut app := create_test_app() + defer { cleanup_test_app(app) } + + tmp_dir := os.join_path(app.temp_dir, 'snap') + must_mkdir_all(tmp_dir) + file := os.join_path(tmp_dir, 'main.v') + must_write_file(file, content) + + uri := path_to_uri(file) + app.text = content + app.open_files[uri] = content + + req := Request{ + id: 1 + method: 'textDocument/hover' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ uri: uri } + position: Position{ line: line, char: ch } + }, escape_unicode: true) + } + + resp := app.operation_at_pos(.hover, req) + json := response_to_json(resp) + assert_snapshot(name, json) +} + +fn snapshot_definition(name string, content string, line int, ch int) { + mut app := create_test_app() + defer { cleanup_test_app(app) } + + tmp_dir := os.join_path(app.temp_dir, 'snap') + must_mkdir_all(tmp_dir) + file := os.join_path(tmp_dir, 'main.v') + must_write_file(file, content) + + uri := path_to_uri(file) + app.text = content + app.open_files[uri] = content + + req := Request{ + id: 1 + method: 'textDocument/definition' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ uri: uri } + position: Position{ line: line, char: ch } + }, escape_unicode: true) + } + + resp := app.operation_at_pos(.definition, req) + json := response_to_json(resp) + assert_snapshot(name, json) +} + +fn test_snapshot_completion_basic() { + content := 'module main\n\nfn main() {\n\tos.\n}\n' + snapshot_completion('completion_basic', content, 3, 4) +} + +fn test_snapshot_hover_basic() { + content := 'module main\n\nfn greet(name string) string {\n\treturn name\n}\n\nfn main() {\n\tgreet("world")\n}\n' + snapshot_hover('hover_basic', content, 7, 3) +} + +fn test_snapshot_definition_basic() { + content := 'module main\n\nfn greet(name string) string {\n\treturn name\n}\n\nfn main() {\n\tgreet("world")\n}\n' + snapshot_definition('definition_basic', content, 7, 3) +} + +fn test_snapshot_completion_member() { + content := 'module main\n\nstruct User {\n\tname string\n\tage int\n}\n\nfn main() {\n\tu := User{}\n\tu.\n}\n' + snapshot_completion('completion_member', content, 10, 3) +}