From 289eb5ad82ef861bc6f1e4e2afaa8243f52baec3 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 15:06:39 +0330 Subject: [PATCH 01/83] fix: recognize a launcher that cannot reach the V1 compatibility compiler Hover, completion, signature help, and go to definition all go through `v -vls-mode -line-info`, which the V launcher routes to the V1 compatibility compiler. When that compiler is missing, the launcher refuses with a single line and exits: `-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`. That refusal is not an "unknown option" line, so neither `compiler_rejects_line_info` nor `compiler_refused_and_stopped` recognized it. `line_info_mode` stayed `.direct`, so VLS kept spawning the compiler once per request for an answer that can never arrive, and every one of those lookups resolved to empty with nothing said about why. Recognize the refusal, retire the lookups as `.missing` so they are answered from VLS's own index instead of paying a process launch each, and tell the user what to install. A launcher that can build the fallback itself announces "running `make v1` now" and then answers, so that form is explicitly not a dead end. The notice needs no "already warned" flag: 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 sent at most once per session. --- interop.v | 104 ++++++++++++++----- interop_test.v | 270 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 258 insertions(+), 116 deletions(-) diff --git a/interop.v b/interop.v index 0aad112f..5720d7b5 100644 --- a/interop.v +++ b/interop.v @@ -267,7 +267,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 +285,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 +339,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 +516,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 +568,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 +604,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 +666,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,12 +779,12 @@ 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) } } @@ -840,12 +891,12 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { err_file := source_path_from_overlay(err.path, overlay) 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 +1564,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 +1681,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 +1690,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 +1748,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 +1756,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 } } @@ -1749,13 +1801,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..86308622 100644 --- a/interop_test.v +++ b/interop_test.v @@ -164,8 +164,8 @@ 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) assert mapped == 'C:/repo/src/main.v' @@ -257,7 +257,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 +471,96 @@ 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`.') + 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`.') + 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 +731,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 +751,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 +833,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 +845,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 +889,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 +908,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 +923,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 +938,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 +952,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 +967,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 +1007,7 @@ fn test_lsp_range_struct() { line: 0 char: 0 } - end: Position{ + end: Position{ line: 0 char: 10 } @@ -932,7 +1022,7 @@ fn test_lsp_range_multiline() { line: 5 char: 10 } - end: Position{ + end: Position{ line: 10 char: 5 } @@ -942,17 +1032,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 +1055,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 +1065,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 +1089,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 +1104,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 +1113,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 +1126,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 +1135,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 +1158,7 @@ fn test_signature_help_struct() { fn test_signature_help_multiple_signatures() { sig := SignatureHelp{ - signatures: [ + signatures: [ SignatureInformation{ label: 'fn overload1(a int)' }, @@ -1096,17 +1186,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 +1217,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 +1252,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 +1261,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 +1278,7 @@ fn test_notification_struct() { notif := Notification{ method: 'textDocument/publishDiagnostics' params: PublishDiagnosticsParams{ - uri: 'file:///test.v' + uri: 'file:///test.v' diagnostics: [] } } @@ -1200,16 +1290,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 +1342,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 +1380,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 +1421,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 +1461,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 } @@ -1412,7 +1502,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 } @@ -1450,7 +1540,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 +2001,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 +2029,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 +2061,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 +2070,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 +2084,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 +2103,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 }, ] From e50666628b827eb21b96f4611e8ec38bcc238593 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 17:07:54 +0330 Subject: [PATCH 02/83] test: recognise the reworded "make is unavailable" refusal too vlang/v#29369 (merged as 76d88b9) rewrites the tail of the launcher's refusal: the sentence after "make is unavailable" is now a platform-specific hint, and the comma became a period. On Windows the clause reads On Windows, install GNU make in MSYS2 (`make` or `mingw32-make`) and put its tools, including `sh`, on PATH. and elsewhere it is still "Install make.". `compiler_lacks_compatibility_compiler` keys on "requires the compatibility compiler", which both spellings contain, so detection is unaffected. This test pinned the old wording verbatim, so it now asserts both: green before the compiler change and after it, and the coupling is written down instead of being rediscovered the next time the wording shifts. Validated against V 0.5.2 76d88b9, the merged compiler: the Windows sample above is its output verbatim. `interop_test.v` passes; the module suite reports the same 3 failures with and without this change - `index_test.v`, `handlers_test.v` and `integration_test.v`, none of them in this file. `integration_test.v` fails on an empty completion list, which is the symptom of the missing V1 fallback this refusal describes, and is what vlang/v#29369 and vlang/vscode-vlang#543 are about. --- interop_test.v | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/interop_test.v b/interop_test.v index 86308622..cdab37f3 100644 --- a/interop_test.v +++ b/interop_test.v @@ -476,8 +476,15 @@ fn test_compiler_lacks_compatibility_compiler_detects_every_launcher_refusal() { // 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 From 3d3ba5d1b655b2326ad0948a3c73b3e0ba9b1710 Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 7 Oct 2026 10:14:48 +0330 Subject: [PATCH 03/83] fix: correct compile target and path resolution for multifile diagnostics - Use compile_target as exec_dir and diagnostic_source_dir so the compiler checks the correct module directory - Add base_dir parameter to source_path_from_overlay so relative paths resolve against the compile target, not the project root - Escape backslashes in JSON rootPath for Windows paths - Fix u64 to int conversion in test file size calculations --- handlers_test.v | 2 +- index_test.v | 4 ++-- integration_test.v | 5 +++-- interop.v | 17 +++++++++-------- interop_test.v | 6 +++--- 5 files changed, 18 insertions(+), 16 deletions(-) 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..f44ac71f 100644 --- a/integration_test.v +++ b/integration_test.v @@ -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 5720d7b5..06730da9 100644 --- a/interop.v +++ b/interop.v @@ -790,12 +790,13 @@ fn (mut app App) prepare_compilation_overlay(real_path string) !CompilationOverl // 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) @@ -806,11 +807,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 { @@ -888,7 +889,7 @@ 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 @@ -1777,7 +1778,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 diff --git a/interop_test.v b/interop_test.v index cdab37f3..bc01ea3a 100644 --- a/interop_test.v +++ b/interop_test.v @@ -167,7 +167,7 @@ fn test_source_path_from_overlay_normalizes_windows_relative_join() { 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' } @@ -1488,7 +1488,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) } @@ -1526,7 +1526,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 } } From 8cf8b10032fb1893b7696a84409b540cdff83531 Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 7 Oct 2026 10:57:52 +0330 Subject: [PATCH 04/83] fix: resolve Windows launcher shim to the real V compiler When the 'v' on PATH is a forwarding wrapper (v.bat -> v.exe), find_v_dir returned the wrapper directory, so vlib module lookups (os, builtin, time, strings) resolved nowhere and indexed member completions came back empty. Unwrap .bat/.cmd shims to their quoted executable target in resolve_v_compiler_exe and cover it with tests. --- integration_test.v | 4 +-- interop.v | 44 ++++++++++++++++++++++++++- interop_test.v | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/integration_test.v b/integration_test.v index f44ac71f..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 } diff --git a/interop.v b/interop.v index 06730da9..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 diff --git a/interop_test.v b/interop_test.v index bc01ea3a..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() { From 51981a6b3108b70a59bc1952016904763b6e689f Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 7 Oct 2026 22:11:03 +0330 Subject: [PATCH 05/83] Fix merge fallout: drop stray braces and thread compile_target through split_check_errors --- interop.v | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/interop.v b/interop.v index b4b19987..ff45b169 100644 --- a/interop.v +++ b/interop.v @@ -1401,9 +1401,9 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // check: they are shown at once (see DiagnosticsServer.ask). publish := app.diagnostics_partial partial := if publish != unsafe { nil } { - fn [publish, diagnostic_source_dir, file_to_check, use_multifile, overlay, real_path, program_uris, path] (answer os.Result) { + fn [publish, diagnostic_source_dir, file_to_check, use_multifile, overlay, real_path, program_uris, compile_target, path] (answer os.Result) { found := split_check_errors(answer.output, diagnostic_source_dir, file_to_check, - use_multifile, overlay, real_path, program_uris) + use_multifile, overlay, real_path, program_uris, compile_target) publish(path, found) } } else { @@ -1435,7 +1435,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } found := split_check_errors(x.output, diagnostic_source_dir, file_to_check, use_multifile, - overlay, real_path, program_uris) + overlay, real_path, program_uris, compile_target) if server_exe == '' { cleanup_compilation_temp(temp_project_dir, singlefile_tmppath) } @@ -1481,7 +1481,7 @@ fn (app &App) program_open_files(real_path string, overlay CompilationOverlay) m // split_check_errors reads the diagnostics of `output`, what a check printed, // for the file at `real_path`, and, when the check covered its program, for the // other open files of the program, `program_uris` (see program_open_files). -fn split_check_errors(output string, diagnostic_source_dir string, file_to_check string, use_multifile bool, overlay CompilationOverlay, real_path string, program_uris map[string]string) CheckErrors { +fn split_check_errors(output string, diagnostic_source_dir string, file_to_check string, use_multifile bool, overlay CompilationOverlay, real_path string, program_uris map[string]string, base_dir string) CheckErrors { // Parse V3's native flat-AST checker diagnostics so ordinary diagnostics stay // on the default backend. mut v_errors := parse_v_check_diagnostics(output, diagnostic_source_dir) @@ -1498,7 +1498,7 @@ fn split_check_errors(output string, diagnostic_source_dir string, file_to_check } 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, base_dir) if normalized_index_path(err_file) == normalized_index_path(real_path) { filtered_errors << JsonError{ path: real_path @@ -2539,8 +2539,6 @@ fn (mut app App) line_info_result(method Method, path string, line_info string, result = hints } } - } - } } .definition, .declaration, .type_definition, .implementation { // file.v:line:col => Location @@ -2563,7 +2561,6 @@ fn (mut app App) line_info_result(method Method, path string, line_info string, result = app.compiler_location(uri_path, line_nr, col) } } - else {} } return result From a9ae45063a0144fca9a23aaced7b46fec0533515 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 08:28:55 +0330 Subject: [PATCH 06/83] Fix undefined compile_target: thread it through line_info_result and update source_path_from_overlay callers --- interop.v | 4 ++-- rename_conflicts.v | 3 ++- v3_line_info.v | 8 +++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/interop.v b/interop.v index ff45b169..7c248eda 100644 --- a/interop.v +++ b/interop.v @@ -2482,13 +2482,13 @@ fn (mut app App) run_v_line_info_once(method Method, path string, line_info stri log('RUN RES ${x}') return app.line_info_result(method, path, line_info, output, use_multifile, temp_project_dir, - overlay) + overlay, compile_target) } // line_info_result turns what the compiler printed into the answer the client // expects. Both the one-shot process and the persistent compiler end here, so // the two paths cannot drift apart. -fn (mut app App) line_info_result(method Method, path string, line_info string, output string, use_multifile bool, temp_project_dir string, overlay CompilationOverlay) ResponseResult { +fn (mut app App) line_info_result(method Method, path string, line_info string, output string, use_multifile bool, temp_project_dir string, overlay CompilationOverlay, compile_target string) ResponseResult { // Default to JSON null so any unhandled method branch produces a valid LSP response. mut result := ResponseResult('null') match method { diff --git a/rename_conflicts.v b/rename_conflicts.v index 6667d616..551f7ba1 100644 --- a/rename_conflicts.v +++ b/rename_conflicts.v @@ -345,7 +345,8 @@ fn parse_name_pos(answer string, overlay CompilationOverlay) ?NamePos { || !fields[fields.len - 2].is_int() { return none } - path := source_path_from_overlay(os.to_slash(fields[..fields.len - 2].join(':')), overlay) + path := source_path_from_overlay(os.to_slash(fields[..fields.len - 2].join(':')), overlay, + overlay.temp_work_dir) return NamePos{ path: normalize_overlay_path(path) line: fields[fields.len - 2].int() - 1 diff --git a/v3_line_info.v b/v3_line_info.v index a74ba5f1..b2a9ef66 100644 --- a/v3_line_info.v +++ b/v3_line_info.v @@ -79,7 +79,7 @@ fn (mut app App) v3_line_info(method Method, path string, real_path string, line } log('V3 answered ${output.len} bytes') return app.line_info_result(method, path, line_info, output, true, result.project.overlay.temp_root, - result.project.overlay) + result.project.overlay, result.project.overlay.temp_work_dir) } // V3Hover is what V3 answers for a hover: what it says of the name under the @@ -117,7 +117,8 @@ fn (mut app App) v3_hover(uri string, real_path string, line_info string) ?V3Hov mut declared_at := ?Location(none) if definition != '' { located := app.line_info_result(.definition, uri, definition_info, definition, - true, result.project.overlay.temp_root, result.project.overlay) + true, result.project.overlay.temp_root, result.project.overlay, + result.project.overlay.temp_work_dir) if located is Location { declared_at = located } @@ -510,7 +511,8 @@ fn (mut app App) v3_prefetch_anchors(locations []Location, mut cache map[string] continue } found := app.line_info_result(.definition, loc.uri, questions[i].line_info, output, - true, result.project.overlay.temp_root, result.project.overlay) + true, result.project.overlay.temp_root, result.project.overlay, + result.project.overlay.temp_work_dir) if found is Location && found.uri != '' { cache[anchor_cache_key(loc.uri, loc.range.start.line, loc.range.start.char)] = found } From aa24779944969d7e0a95419c41be371b5a87c927 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 12:06:50 +0330 Subject: [PATCH 07/83] feat: tiered diagnostics with fast unknown-import check Every edit now schedules two jobs: a fast index-only unknown-import check (150ms debounce) and the full compiler check (800ms). The slow answer replaces the fast one, so clean files see no extra traffic while a bad import is flagged in milliseconds. Adds opt-in VLS_PERF_LOG timing for requests and diagnostics jobs, plus the comparison/roadmap notes behind the change. --- COMPARISON.md | 56 +++++++++++ ROADMAP.md | 90 ++++++++++++++++++ diagnostics_scheduler.v | 104 ++++++++++++++++----- fast_diagnostics.v | 200 ++++++++++++++++++++++++++++++++++++++++ handlers.v | 31 +------ handlers_test.v | 174 +++++++++++++++++++++++++++++++++- main.v | 7 ++ 7 files changed, 606 insertions(+), 56 deletions(-) create mode 100644 COMPARISON.md create mode 100644 ROADMAP.md create mode 100644 fast_diagnostics.v diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 00000000..0dea5b5f --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,56 @@ +# Language-server comparison: gopls, rust-analyzer, zls vs vls + +Why these three: Go is V's syntactic parent, Rust is the +aspirational maturity target, Zig is the closest lifecycle +peer (young systems language, breaking compiler). + +## Architecture + +- gopls: per-package summaries, persistent file cache, + pruned invalidation (skip importers when exports + unchanged). Two-tier diagnostics (instant + deferred vet). +- rust-analyzer: Salsa queries over ItemTree/DefMap/Body; + laziness first, incrementality second; proc-macros and + flycheck out-of-process; per-request cancellation. +- zls: per-file `std.zig.Ast` + heuristic semantics (no + full comptime); strict 1:1 version lock with Zig; custom + build runner; opt-in build-on-save via a `check` step. +- vls: per-file token index + full project re-check; + shell-free compiler argv + overlay; scheduler + pool + of max 3 diagnostic servers; index-first fallbacks. + +## Feature gap summary + +- Incrementality: all three peers beat vls (export + summaries / Salsa / per-module units vs full re-check). +- Diagnostics tiers: peers split fast/slow; vls is one-tier. +- Refactors: gopls and rust-analyzer offer extract/inline/ + fill/struct assists with safety checks; vls has 2 actions + (remove unknown import, contiguous organizeImports). +- Rename safety: gopls checks shadowing + interface + satisfaction; vls refuses on incomplete scope only. +- Config: zls layers editor > `zls.json` (+ schema) > + per-build file; vls relies on `VLS_V_COMMAND`/`VFLAGS`. +- Compat: zls chose lockstep; vls probes compiler modes + (`unknown/direct/compat/missing`) for a version range. + +## Criticism (what to fix, in roadmap order) + +1. No real incrementality — full re-check per keystroke. +2. One-tier diagnostics — latency or staleness, no middle. +3. Index masks compiler gaps instead of measuring them. +4. Refactors are text edits, not semantic operations. +5. Config is env-var folklore (`VFLAGS`-in-shell). +6. Dispatched stubs (inlineValue literals-only, + same-line linkedEditing, empty onTypeFormatting). +7. No perf budget or observability story. + +## Sources + +- https://go.dev/gopls/ and /features/, /workspace/, /settings/ +- https://go.dev/blog/gopls-scalability +- https://rust-analyzer.github.io/book/ (features, assists, + diagnostics, architecture, non_cargo_based_projects) +- https://github.com/zigtools/zls and https://zigtools.org/zls/ +- vls tree: index.v, interop.v, diagnostics_scheduler.v, + diagnostics_server.v, handlers.v, main.v, v3_line_info.v diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..0b4f59c4 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,90 @@ +# VLS roadmap — from gopls / rust-analyzer / zls comparison + +Decisions (2026-10-08): foundation first, support a V version +range (not lockstep), aim for full gopls-grade refactors last. +See COMPARISON.md for the evidence behind each phase. + +## Phase 0 — baselines (current) + +Add opt-in timing logs, no behavior change by default. +Set `VLS_PERF_LOG=1` to collect numbers. + +- `request method= elapsed_ms=` per position request. +- `diagnostics uri= elapsed_ms=` per diagnostics job. +- Collect p50/p95 on 3 repos (self, small app, vlib-heavy) + before any Phase 1 change. Record below. + +Baselines (`VLS_PERF_LOG=1`, sampler sleeps 8s between +edits; first sample cold, rest warm; loaded box): + +- small (16-line file, own project): completion warm 3-112ms, + diagnostics warm 250-310ms (cold 2184ms) +- medium (main.v, vls project): completion warm 22-140ms, + diagnostics warm 1528-1625ms (cold 5102ms) +- heavy (handlers.v, vls project): completion warm 63-205ms, + diagnostics warm 1580-1694ms (cold 5059ms) + +Reading: diagnostics cost is project-size dominated — a +one-line edit re-checks the whole program (~1.6s warm for +vls itself). That is the Phase 1 target. + +## Phase 1a — done (2026-10-08): fast/slow tiers + +Every edit schedules two jobs: fast (index-only unknown-import +check, 150ms debounce) and slow (full compiler check, 800ms +debounce). The slow answer replaces the fast one; a fast error +the slow check drops is cleared by the replacement publish. +Empty fast answers publish nothing, so clean files see zero +extra traffic. New code: fast_diagnostics.v, tiered scheduler. + +Measured (`Temp/opencode/vls-sample.py`, fixed harness): + +- bad import: fast 0-28ms (`unknown module`, quickfix-ready), + slow ~1.1s (full compiler errors) right after, every edit. +- clean files: fast finds nothing, slow unchanged + (small 315-358ms, vls project ~1.5-1.6s). +- Server-mode note: with the diagnostics server on, the first + check pays ~2s server start + builtin parse; with + `VLS_DIAGNOSTICS_SERVER=off` the first check was 285ms and + warm checks identical (~270ms). Server value is unproven + for small projects; revisit in Phase 1b. + +Harness lesson: the first sampler never drained stdout, so the +~4KB pipe filled mid-run and the server blocked mid-write — +whole edits "vanished". Discard any run whose message count +disagrees with the protocol; the sampler now drains +continuously into a file. + +## Phase 1 — foundation + +- Split diagnostics: fast (parse + open-file errors, ~100ms + debounce) vs slow (workspace check after ~1s idle). +- Persistent export-summary cache across restarts; only + re-check importers whose summary changed. +- Per-request cancellation + completion budget (~100-200ms) + that shrinks scope instead of failing. +- Compat matrix test: old/mid/new compiler stubs. + +## Phase 2 — navigation / hints parity + +- Fuzzy workspace symbols (trigram index). +- Granular inlay-hint toggles, conservative defaults. +- Richer hover (const values, methods, doc links). + +## Phase 3 — full refactors + +- Safe rename (shadowing, interface satisfaction). +- Extract function/variable, inline, fillStruct/fillSwitch, + stub-missing-members. Each with before/after doc-tests. + +## Phase 4 — build / config UX + +- Layered config: settings > `vls.json` (+ schema) > + per-project overrides > env. `defines: [...]` + replaces `VFLAGS`-in-shell folklore. +- Fast `v check`-only mode vs full build (zls check-step). + +## Phase 5 — finish or drop stubs + +Implement or unadvertise: range-formatting, on-type +formatting, file-op hooks, inlineValue, linkedEditing. diff --git a/diagnostics_scheduler.v b/diagnostics_scheduler.v index 1e42e9dd..77e1343d 100644 --- a/diagnostics_scheduler.v +++ b/diagnostics_scheduler.v @@ -11,9 +11,20 @@ import time // that runs, whose answer would be stale, and a question goes to a server of // its own, which a check does not hold (see DiagnosticsServer.busy). The worker // looks for ready jobs often while one waits for its time. -const diagnostics_debounce_ms = 0 +// The fast tier answers from the index without a compiler; the slow tier +// runs the full check after the user pauses. The slow answer replaces the +// fast one, so a fast finding the check does not reproduce is corrected. +const diagnostics_fast_debounce_ms = 150 +const diagnostics_slow_debounce_ms = 800 const diagnostics_worker_poll = 5 * time.millisecond +// DiagnosticsKind is the tier a job belongs to. Slow is the zero value, so +// a job built without a kind behaves like today's single-tier check. +enum DiagnosticsKind { + slow + fast +} + struct DiagnosticsJob { uri string content string @@ -28,6 +39,7 @@ struct DiagnosticsJob { global_generation u64 generation u64 ready_at i64 + kind DiagnosticsKind } struct DiagnosticsTicket { @@ -35,6 +47,14 @@ struct DiagnosticsTicket { global_generation u64 generation u64 project_generation u64 + kind DiagnosticsKind +} + +// diagnostics_job_key identifies a pending job: one URI holds a fast and a +// slow job with independent debounce deadlines. +fn diagnostics_job_key(uri string, kind DiagnosticsKind) string { + suffix := if kind == .fast { 'fast' } else { 'slow' } + return '${uri}\x00${suffix}' } struct DiagnosticsProjectMutation { @@ -108,29 +128,32 @@ fn (mut scheduler DiagnosticsScheduler) begin_project_mutation(project_key strin for other in others { affected[other] = true } - mut pending_uris := []string{} - for pending_uri, job in scheduler.pending_jobs { + mut pending_keys := []string{} + for key, job in scheduler.pending_jobs { if job.project_key == project_key { - affected[pending_uri] = true - pending_uris << pending_uri + affected[job.uri] = true + pending_keys << key } } - for pending_uri in pending_uris { - scheduler.pending_jobs.delete(pending_uri) + for key in pending_keys { + scheduler.pending_jobs.delete(key) } if scheduler.active_project_key == project_key && scheduler.active_uri != '' { affected[scheduler.active_uri] = true } scheduler.project_generations[project_key] = scheduler.project_generations[project_key] + 1 project_generation := scheduler.project_generations[project_key] - mut tickets := []DiagnosticsTicket{cap: affected.len} + mut tickets := []DiagnosticsTicket{cap: affected.len * 2} for affected_uri, _ in affected { scheduler.generations[affected_uri] = scheduler.generations[affected_uri] + 1 - tickets << DiagnosticsTicket{ - uri: affected_uri - global_generation: scheduler.global_generation - generation: scheduler.generations[affected_uri] - project_generation: project_generation + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + tickets << DiagnosticsTicket{ + uri: affected_uri + global_generation: scheduler.global_generation + generation: scheduler.generations[affected_uri] + project_generation: project_generation + kind: kind + } } } return tickets @@ -172,7 +195,7 @@ fn (mut scheduler DiagnosticsScheduler) enqueue(job DiagnosticsJob) bool { if scheduler.stopped { return false } - scheduler.pending_jobs[job.uri] = job + scheduler.pending_jobs[diagnostics_job_key(job.uri, job.kind)] = job if scheduler.paused { return false } @@ -193,19 +216,36 @@ fn (mut scheduler DiagnosticsScheduler) take_ready_jobs(now i64) ([]DiagnosticsJ scheduler.mutex.unlock() } mut ready := []DiagnosticsJob{cap: 1} - mut ready_uri := '' + mut ready_key := '' if scheduler.active_uri == '' { - for uri, job in scheduler.pending_jobs { - if job.ready_at <= now { + // A ready fast job wins over a ready slow one: index answers publish + // while the compiler check still waits out its longer debounce. + mut slow_key := '' + mut has_slow := false + for key, job in scheduler.pending_jobs { + if job.ready_at > now { + continue + } + if job.kind == .fast { ready << job - ready_uri = uri + ready_key = key break } + if !has_slow { + has_slow = true + slow_key = key + } + } + if ready_key == '' && has_slow { + if slow_job := scheduler.pending_jobs[slow_key] { + ready << slow_job + ready_key = slow_key + } } } - if ready_uri != '' { + if ready_key != '' { job := ready[0] - scheduler.pending_jobs.delete(ready_uri) + scheduler.pending_jobs.delete(ready_key) scheduler.active_uri = job.uri scheduler.active_project_key = job.project_key scheduler.active_generation = job.generation @@ -231,7 +271,8 @@ fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { fn (mut scheduler DiagnosticsScheduler) cancel(uri string) { scheduler.mutex.lock() scheduler.generations[uri] = scheduler.generations[uri] + 1 - scheduler.pending_jobs.delete(uri) + scheduler.pending_jobs.delete(diagnostics_job_key(uri, .slow)) + scheduler.pending_jobs.delete(diagnostics_job_key(uri, .fast)) scheduler.mutex.unlock() } @@ -332,7 +373,7 @@ fn (mut app App) finish_diagnostics_project_mutation(mutation DiagnosticsProject } fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, tickets []DiagnosticsTicket, project_key string, changed_uri string, changed_content string, excluded_uri string) { - ready_at := time.now().unix_milli() + diagnostics_debounce_ms + now := time.now().unix_milli() mut should_start := false for ticket in tickets { if ticket.uri == excluded_uri { @@ -349,6 +390,11 @@ fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, if current_version := app.open_files_versions[ticket.uri] { version = current_version } + ready_at := if ticket.kind == .fast { + now + diagnostics_fast_debounce_ms + } else { + now + diagnostics_slow_debounce_ms + } job := DiagnosticsJob{ uri: ticket.uri content: job_content @@ -363,6 +409,7 @@ fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, global_generation: ticket.global_generation generation: ticket.generation ready_at: ready_at + kind: ticket.kind } if scheduler.enqueue(job) { should_start = true @@ -402,7 +449,11 @@ fn run_diagnostics_worker(mut scheduler DiagnosticsScheduler) { continue } for job in jobs { - run_diagnostics_job(mut scheduler, job) + if job.kind == .fast { + run_fast_diagnostics_job(mut scheduler, job) + } else { + run_diagnostics_job(mut scheduler, job) + } scheduler.finish(job) } } @@ -412,6 +463,8 @@ fn run_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { if !scheduler.is_job_current(job) { return } + // Phase 0 baseline: opt-in timing for the full diagnostics job. + started_ms := time.now().unix_milli() temp_dir := os.join_path(os.temp_dir(), 'vls_diag_${os.getpid()}_${job.generation}_${time.now().unix_nano()}') os.mkdir_all(temp_dir) or { return } defer { @@ -446,6 +499,11 @@ fn run_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { } } notification := worker.build_diagnostics_notification(job.uri, job.content) + if os.getenv('VLS_PERF_LOG') != '' { + diag_elapsed_ms := time.now().unix_milli() - started_ms + worker.send_log_message('diagnostics uri=${job.uri} kind=slow elapsed_ms=${diag_elapsed_ms}', + 4) + } if !scheduler.publish_if_current(mut worker, job, notification) { return } diff --git a/fast_diagnostics.v b/fast_diagnostics.v new file mode 100644 index 00000000..bec420a3 --- /dev/null +++ b/fast_diagnostics.v @@ -0,0 +1,200 @@ +// Fast, compiler-free diagnostics: unknown-import resolvability. +// The slow compiler check stays authoritative: a fast error the slow +// check does not reproduce is cleared when the slow answer publishes, +// since every publishDiagnostics replaces the file's diagnostics. +module main + +import os +import time + +// ImportRef is one module path an `import` statement takes, with its +// 0-based position for diagnostics. +struct ImportRef { + line int // 0-based line of the import + col int // 0-based byte column where the module path starts + path string +} + +// parse_import_refs extracts module paths with positions from the +// `import` statements in `content`. It mirrors parse_imports, which +// delegates to it, so the two can never disagree. +fn parse_import_refs(content string) []ImportRef { + mut refs := []ImportRef{} + mut in_import_block := false + for i, line in content.split_into_lines() { + trimmed := line.trim_space() + if in_import_block { + if trimmed.starts_with(')') { + in_import_block = false + continue + } + parts := trimmed.all_before('//').fields() + if parts.len > 0 { + pos := line.index(parts[0]) or { -1 } + if pos >= 0 { + refs << ImportRef{ + line: i + col: pos + path: parts[0] + } + } + } + continue + } + if !trimmed.starts_with('import ') { + continue + } + rest := trimmed[7..].all_before('//').trim_space() + if rest == '(' { + in_import_block = true + continue + } + // Strip optional `as alias` suffix + parts := rest.fields() + if parts.len > 0 { + indent := line.len - trimmed.len + offset := rest.index(parts[0]) or { 0 } + refs << ImportRef{ + line: i + col: indent + 7 + offset + path: parts[0] + } + } + } + return refs +} + +// fast_import_is_skipped reports the paths that are never flagged: `C` is +// C interop, and a path with grouping or quoting characters is a construct +// this scan does not understand, so the slow check decides. +fn fast_import_is_skipped(path string) bool { + if path == '' || path == 'C' { + return true + } + for c in path { + if c == `(` || c == `)` || c == `{` || c == `}` || c == `[` || c == `]` || c == `"` + || c == `'` { + return true + } + } + return false +} + +// fast_module_dir_declares reports whether `dir` holds the module +// `name`: its V files declare the module named after the folder, +// which is not `main`. This mirrors collect_importable_modules. +fn fast_module_dir_declares(dir string, name string) bool { + if name == '' || name == 'main' { + return false + } + if !os.is_dir(dir) { + return false + } + return declared_module_of_dir(dir) == name +} + +// fast_check_errors returns one error per import of `content` that +// resolves nowhere: not under the project bases, vlib, or vmodules. +// An import of the file's own folder needs no import and is skipped. +// The message matches the compiler's wording so the existing +// "Remove unknown import" quick fix fires on fast results too. +fn (app &App) fast_check_errors(uri string, content string) []JsonError { + refs := parse_import_refs(content) + if refs.len == 0 { + return [] + } + real_path := uri_to_path(uri) + file_dir := os.dir(real_path) + own_dir := normalize_overlay_path(os.real_path(file_dir)) + vmod_root := find_project_root(file_dir) + program_dir := app.program_root(real_path) + // V resolves a project import against the importing file's folder, the + // v.mod root, and the program folder, so every one is a base. + mut bases := []string{} + for base in [vmod_root, file_dir, program_dir] { + if base != '' && base !in bases { + bases << base + } + } + v_dir := find_v_dir() + vlib := if v_dir != '' { os.join_path(v_dir, 'vlib') } else { '' } + vmodules := os.vmodules_paths() + mut errors := []JsonError{} + for ref in refs { + if fast_import_is_skipped(ref.path) { + continue + } + name := ref.path.split('.').last() + rel := ref.path.replace('.', os.path_separator) + mut resolves := false + for base in bases { + candidate := os.join_path(base, rel) + if normalize_overlay_path(candidate) == own_dir { + resolves = true + break + } + if fast_module_dir_declares(candidate, name) { + resolves = true + break + } + } + if !resolves && vlib != '' { + resolves = fast_module_dir_declares(os.join_path(vlib, rel), name) + } + if !resolves { + for dir in vmodules { + if fast_module_dir_declares(os.join_path(dir, rel), name) { + resolves = true + break + } + } + } + if resolves { + continue + } + // JsonError positions are 1-based (see v_error_to_lsp_diagnostic). + errors << JsonError{ + path: real_path + message: "unknown module '${ref.path}'" + line_nr: ref.line + 1 + col: ref.col + 1 + len: ref.path.len + level: 'error' + } + } + return errors +} + +// run_fast_diagnostics_job answers a fast job: index-only errors, no +// compiler, no temp copy. An empty fast answer publishes nothing: the slow +// check publishes the file's full state, which also clears a fast error it +// does not repeat. +fn run_fast_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { + if !scheduler.is_job_current(job) { + return + } + started_ms := time.now().unix_milli() + mut versions := map[string]i64{} + if version := job.version { + versions[job.uri] = version + } + mut worker := App{ + open_files: job.open_files + open_files_versions: versions + position_encoding: job.position_encoding + write_mutex: job.write_mutex + tcp_conn: job.tcp_conn + diagnostics_enabled: true + } + errors := worker.fast_check_errors(job.uri, job.content) + if errors.len == 0 { + return + } + notification := worker.diagnostics_notification_for(job.uri, job.content, errors) + scheduler.publish_if_current(mut worker, job, notification) + if os.getenv('VLS_PERF_LOG') != '' { + fast_elapsed_ms := time.now().unix_milli() - started_ms + worker.send_log_message('diagnostics uri=${job.uri} kind=fast elapsed_ms=${fast_elapsed_ms}', + 4) + } +} diff --git a/handlers.v b/handlers.v index 690692e4..63236d3b 100644 --- a/handlers.v +++ b/handlers.v @@ -6441,36 +6441,7 @@ fn get_module_name(content string) string { // parse_imports extracts the module paths from `import` statements in `content`. // Returns a list of module paths, e.g. ['os', 'math', 'v.util']. fn parse_imports(content string) []string { - mut imports := []string{} - mut in_import_block := false - for line in content.split_into_lines() { - trimmed := line.trim_space() - if in_import_block { - if trimmed.starts_with(')') { - in_import_block = false - continue - } - parts := trimmed.all_before('//').fields() - if parts.len > 0 { - imports << parts[0] - } - continue - } - if !trimmed.starts_with('import ') { - continue - } - rest := trimmed[7..].all_before('//').trim_space() - if rest == '(' { - in_import_block = true - continue - } - // Strip optional `as alias` suffix - parts := rest.fields() - if parts.len > 0 { - imports << parts[0] - } - } - return imports + return parse_import_refs(content).map(it.path) } // get_import_completions returns completion items for an `import` line. diff --git a/handlers_test.v b/handlers_test.v index 7172f282..22aa98ca 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -797,7 +797,9 @@ fn test_diagnostics_scheduler_requeues_active_sibling() { uri_b := 'file:///project/b.v' project_key := 'file:///project' tickets_a := scheduler.begin_project_schedule(uri_a, project_key) - assert tickets_a.len == 1 + assert tickets_a.len == 2 + assert tickets_a.any(it.kind == .slow) + assert tickets_a.any(it.kind == .fast) active_job := DiagnosticsJob{ uri: uri_a project_key: project_key @@ -826,8 +828,13 @@ fn diagnostics_test_pending_job(mut scheduler DiagnosticsScheduler, uri string) defer { scheduler.mutex.unlock() } - job := scheduler.pending_jobs[uri] or { return none } - return job + // One URI holds a fast and a slow job; return the first pending one. + for _, job in scheduler.pending_jobs { + if job.uri == uri { + return job + } + } + return none } fn test_diagnostics_scheduler_checks_staleness_while_publishing() { @@ -13939,3 +13946,164 @@ fn test_rename_of_a_local_passed_to_a_method_named_like_an_array_one() { }, 'main.v:10:2') assert edits == ['main.v:10:2', 'main.v:12:17', 'main.v:17:10'], edits.str() } + +// fast_test_project writes `files` under a tagged temp folder for fast +// diagnostics tests. +fn fast_test_project(mut app &App, tag string, files map[string]string) string { + dir := os.join_path(app.temp_dir, 'fast_diag_${tag}') + must_mkdir_all(dir) + for name, content in files { + path := os.join_path(dir, name) + must_mkdir_all(os.dir(path)) + must_write_file(path, content) + } + return dir +} + +fn test_parse_import_refs_records_positions() { + content := 'module main\n\nimport os\nimport math as m\n\nfn main() {}\n' + refs := parse_import_refs(content) + assert refs.map(it.path) == ['os', 'math'] + assert refs[0].line == 2 + assert refs[1].line == 3 + // The path starts after `import ` on its line. + assert refs[0].col == 7 + assert refs[1].col == 7 +} + +fn test_parse_import_refs_reads_import_blocks() { + content := 'module main\n\nimport (\n\tos\n\tnet.http\n)\n\nfn main() {}\n' + refs := parse_import_refs(content) + assert refs.map(it.path) == ['os', 'net.http'] + assert refs[0].line == 3 + assert refs[1].line == 4 +} + +fn test_parse_imports_matches_ref_paths() { + content := 'module main\n\nimport os\nimport (\n\tjson\n)\n\nfn main() {}\n' + assert parse_imports(content) == parse_import_refs(content).map(it.path) +} + +fn test_fast_check_errors_flags_only_unresolvable_imports() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'unknown', { + 'main.v': 'module main\n\nimport os\nimport nosuchmod_xyz\nimport C\n\nfn main() {}\n' + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + errors := app.fast_check_errors(uri, content) + assert errors.len == 1, errors.str() + assert errors[0].message.contains('unknown module') + assert errors[0].message.contains('nosuchmod_xyz') + assert errors[0].line_nr == 4 + assert errors[0].level == 'error' +} + +fn test_fast_check_errors_accepts_sibling_modules() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'sibling', { + 'main.v': 'module main\n\nimport helper\n\nfn main() {\n\tprintln(helper.answer())\n}\n' + 'helper/helper.v': "module helper\n\npub fn answer() string {\n\treturn 'x'\n}\n" + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + // The submodule resolves against the importing file's folder. + assert app.fast_check_errors(uri, content) == [] +} + +fn test_fast_check_errors_skips_c_interop() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := fast_test_project(mut app, 'cinterop', { + 'main.v': 'module main\n\nimport C\n\nfn main() {}\n' + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + content := os.read_file(path) or { panic(err) } + app.open_files[uri] = content + assert app.fast_check_errors(uri, content) == [] +} + +fn test_scheduler_tickets_come_in_slow_and_fast() { + mut scheduler := new_diagnostics_scheduler() + tickets := scheduler.begin_project_schedule('file:///a.v', 'proj') + assert tickets.len == 2 + assert tickets.any(it.kind == .slow) + assert tickets.any(it.kind == .fast) + assert tickets[0].generation == tickets[1].generation +} + +fn test_scheduler_takes_fast_before_slow() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///tiered.v' + mut first := true + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + global_gen, gen := scheduler.next_generation(uri) + started := scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: 'x' + global_generation: global_gen + generation: gen + ready_at: 100 + kind: kind + write_mutex: app.write_mutex + }) + if first { + assert started + first = false + } + } + jobs, should_stop := scheduler.take_ready_jobs(100) + assert !should_stop + assert jobs.len == 1 + assert jobs[0].kind == .fast + scheduler.finish(jobs[0]) + rest, _ := scheduler.take_ready_jobs(100) + assert rest.len == 1 + assert rest[0].kind == .slow +} + +fn test_scheduler_cancel_drops_both_tiers() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///both.v' + mut started := false + for kind in [DiagnosticsKind.slow, DiagnosticsKind.fast] { + global_gen, gen := scheduler.next_generation(uri) + if scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: 'x' + global_generation: global_gen + generation: gen + ready_at: 100 + kind: kind + write_mutex: app.write_mutex + }) { + started = true + } + } + assert started + scheduler.cancel(uri) + jobs, should_stop := scheduler.take_ready_jobs(100) + assert jobs.len == 0 + assert should_stop +} diff --git a/main.v b/main.v index d59dced1..ae7c6704 100644 --- a/main.v +++ b/main.v @@ -849,7 +849,14 @@ fn (mut app App) handle_requests[T](mut reader T) { match method { .completion, .signature_help, .definition, .hover, .declaration, .type_definition, .implementation { + // Phase 0 baseline: opt-in per-request timing. + started_ms := time.now().unix_milli() resp := app.operation_at_pos(method, lsp_request) + if os.getenv('VLS_PERF_LOG') != '' { + elapsed_ms := time.now().unix_milli() - started_ms + app.send_log_message('request method=${method.str()} elapsed_ms=${elapsed_ms}', + 4) + } app.write_response_or_cancelled(lsp_request.id, resp) } .references { From f8eaf5692ee5e4c980e5706355411ea29a2ad98a Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 12:45:25 +0330 Subject: [PATCH 08/83] feat: persistent diagnostics cache keyed on program fingerprint run_v_check fingerprints the program (compiler id plus sorted per-file content hashes, buffers winning over disk) before overlay or compiler work. Identical states reuse answers from memory or a per-program JSON file in the OS cache dir, across restarts. Reopening this repo unchanged answers in about 15ms from disk instead of 5s cold. Also hardens compiler spawn: a missing compiler returns an error result instead of crashing the server. --- ROADMAP.md | 21 ++++- diag_cache.v | 149 +++++++++++++++++++++++++++++++++++ interop.v | 77 ++++++++++++++---- interop_test.v | 207 ++++++++++++++++++++++++++++++++++++++++++++++--- main.v | 10 ++- 5 files changed, 430 insertions(+), 34 deletions(-) create mode 100644 diag_cache.v diff --git a/ROADMAP.md b/ROADMAP.md index 0b4f59c4..aeecaaad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,26 @@ whole edits "vanished". Discard any run whose message count disagrees with the protocol; the sampler now drains continuously into a file. -## Phase 1 — foundation +## Phase 1b — done (2026-10-08): persistent fingerprint cache + +`run_v_check` fingerprints the program (compiler id + sorted +per-file content hashes, buffers winning over disk) before touching +the overlay or the compiler. Identical states reuse the answer from +memory, or from a per-program JSON file under the OS cache dir, +across restarts. New code: diag_cache.v. Two adjacent fixes fell out: +a missing compiler panicked the spawn path (now an ordinary error +result), and the disk save first keyed on a different root than the +load (unified on the program dir). + +Measured: reopening this repo unchanged serves diagnostics in ~15ms +from disk instead of ~5s cold (~340x). Clean-file traffic unchanged. + +Known environment failure (pre-existing, verified on the pristine +base): `test_conditional_methods_request_receiver_completion_fallback` +needs the V1 compatibility compiler, which this host cannot build +(`make` missing). + +## Phase 1 — foundation (remaining) - Split diagnostics: fast (parse + open-file errors, ~100ms debounce) vs slow (workspace check after ~1s idle). diff --git a/diag_cache.v b/diag_cache.v new file mode 100644 index 00000000..ed6d6fe6 --- /dev/null +++ b/diag_cache.v @@ -0,0 +1,149 @@ +// Persistent diagnostics cache: a check is reused whenever the program it +// ran on and the compiler it ran with are unchanged, within the session +// and across restarts. The fingerprint covers file contents (editor +// buffers win over disk), so an identical state never pays for a check +// twice; anything the fingerprint does not cover falls back to a check. +module main + +import json2 +import os +import time + +// diag_disk_cache_version versions the on-disk format. +const diag_disk_cache_version = 1 +// diag_disk_cache_max_bytes bounds one program's cache file; larger caches +// are neither loaded nor written. +const diag_disk_cache_max_bytes = 2 * 1024 * 1024 +// diag_disk_cache_max_entries bounds the entries kept per program. +const diag_disk_cache_max_entries = 1024 + +// compiler_fingerprint identifies the compiler a check ran with: its path, +// size and modification time. A compiler upgrade changes the binary, so +// cached diagnostics never survive one. +fn compiler_fingerprint() string { + exe := resolve_v_compiler_exe() + info := os.stat(exe) or { return exe } + return '${exe}:${info.size}:${info.mtime}' +} + +// program_content_fingerprint is what the `.v` files under `root` hold: +// their paths and content hashes, with the editor's open buffers winning +// over disk. It shares the walk bounds and exclusions of the workspace +// index; an incomplete walk salts the fingerprint so it never matches. +fn (app &App) program_content_fingerprint(root string) string { + if root == '' || !os.is_dir(root) { + return 'noroot:${time.now().unix_nano()}' + } + mut files := []string{} + mut complete := collect_v_files_bounded(root, project_disk_max_files, mut files) + files.sort() + mut parts := []string{cap: files.len} + mut bytes_read := u64(0) + for path in files { + content := app.source_text(path) + if u64(content.len) > index_max_file_bytes + || u64(content.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + bytes_read += u64(content.len) + parts << '${path}:${content.hash()}' + } + if !complete { + parts << 'incomplete:${time.now().unix_nano()}' + } + return parts.join('\n') +} + +// diag_cache_base_dir holds one cache file per program. Tests point it +// elsewhere with VLS_DIAG_CACHE_DIR. +fn diag_cache_base_dir() string { + override := os.getenv('VLS_DIAG_CACHE_DIR') + if override != '' { + return override + } + return os.join_path(os.cache_dir(), 'vls', 'check-diag') +} + +fn diag_cache_file(program_root string) string { + return os.join_path(diag_cache_base_dir(), 'prog_${program_root.hash().hex()}.json') +} + +// DiagDiskCache is the on-disk format: a version, the compiler that wrote +// it, and the cached results by file path. Entries are DiagCacheEntry, +// shared with the session cache, so disk results merge without conversion. +struct DiagDiskCache { + version int + compiler string + entries map[string]DiagCacheEntry +} + +// load_diag_disk_cache reads the program's cache file; anything unreadable, +// oversized, versioned otherwise, or written by another compiler is dropped. +fn load_diag_disk_cache(program_root string) map[string]DiagCacheEntry { + if program_root == '' { + return {} + } + raw := os.read_file(diag_cache_file(program_root)) or { return {} } + if raw.len > diag_disk_cache_max_bytes { + return {} + } + stored := json2.decode[DiagDiskCache](raw) or { return {} } + if stored.version != diag_disk_cache_version || stored.compiler != compiler_fingerprint() { + return {} + } + return stored.entries +} + +// save_diag_disk_entry records one file result, keeping what is already +// cached for the program. Writes are best effort: a cache must never fail +// a check. +fn save_diag_disk_entry(program_root string, path string, entry DiagCacheEntry) { + if program_root == '' { + return + } + file := diag_cache_file(program_root) + mut entries := map[string]DiagCacheEntry{} + if raw := os.read_file(file) { + if raw.len <= diag_disk_cache_max_bytes { + if stored := json2.decode[DiagDiskCache](raw) { + if stored.version == diag_disk_cache_version + && stored.compiler == compiler_fingerprint() { + entries = stored.entries.clone() + } + } + } + } + entries[path] = entry + if entries.len > diag_disk_cache_max_entries { + return + } + encoded := json2.encode(DiagDiskCache{ + version: diag_disk_cache_version + compiler: compiler_fingerprint() + entries: entries + }) + if encoded.len > diag_disk_cache_max_bytes { + return + } + os.mkdir_all(os.dir(file)) or { return } + os.write_file(file, encoded) or {} +} + +// ensure_diag_disk_cache merges the program's disk results into the +// session cache once; later lookups hit memory. The fingerprint each entry +// carries still decides every hit. +fn (mut app App) ensure_diag_disk_cache(program_root string) { + if program_root == '' || program_root in app.diag_disk_roots { + return + } + app.diag_disk_roots[program_root] = true + for path, disk in load_diag_disk_cache(program_root) { + if path !in app.diag_cache { + app.diag_cache[path] = DiagCacheEntry{ + fingerprint: disk.fingerprint + errors: disk.errors + } + } + } +} diff --git a/interop.v b/interop.v index 7c248eda..e6d91d4a 100644 --- a/interop.v +++ b/interop.v @@ -771,16 +771,17 @@ fn v_diagnostic_underline_len(line string) int { // cache_v_check_result never retains timed-out output because it may be partial. // Other failed invocations are cacheable only when they produced parsed // diagnostics; otherwise a transient compiler crash must be retried. -fn (mut app App) cache_v_check_result(path string, content_hash int, generation int, errors []JsonError, exit_code int, parsed_diagnostic_count int) { +fn (mut app App) cache_v_check_result(path string, program_root string, fingerprint string, errors []JsonError, exit_code int, parsed_diagnostic_count int) { if exit_code == compiler_exit_timeout || (exit_code != 0 && parsed_diagnostic_count == 0) { app.diag_cache.delete(path) return } - app.diag_cache[path] = DiagCacheEntry{ - content_hash: content_hash - generation: generation - errors: errors + entry := DiagCacheEntry{ + fingerprint: fingerprint + errors: errors } + app.diag_cache[path] = entry + save_diag_disk_entry(program_root, path, entry) } // Sentinel exit code returned when a compiler invocation is killed for @@ -830,7 +831,30 @@ fn run_v_argv_cancelled(args []string, work_folder string, cancelled fn () bool) output: msg } } - v_exe := resolve_v_compiler_exe() + mut v_exe := resolve_v_compiler_exe() + if v_exe == 'v' { + // A bare name spawns only when the OS resolves it; resolve it here + // so a missing compiler degrades instead of failing loudly. + v_exe = os.find_abs_path_of_executable('v') or { + msg := 'V compiler not found on PATH' + log(msg) + return os.Result{ + exit_code: 1 + output: msg + } + } + } + if !os.exists(v_exe) { + // Spawning a missing executable fails loudly in the OS call; refuse + // with an ordinary error result instead. A missing compiler must + // never crash the server. + msg := 'V compiler not found: ${v_exe}' + log(msg) + return os.Result{ + exit_code: 1 + output: msg + } + } timeout_ms := resolve_compiler_timeout_ms() mut p := os.new_process(v_exe) p.set_args(args) @@ -1280,15 +1304,34 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { mut singlefile_tmppath := '' mut overlay := CompilationOverlay{} - // Check the diagnostics cache before invoking the compiler. - content_hash := text.hash() - gen := app.project_generation(path) + // The fingerprint covers the program's contents and the compiler: an + // identical state reuses the answer without invoking the compiler, + // within the session and across restarts (see diag_cache.v). + program_dir := app.program_root(real_path) + overlay_root := program_overlay_root(real_path, program_dir) + fingerprint := compiler_fingerprint() + '\n' + app.program_content_fingerprint(overlay_root) + mut cache_hit := '' if cached := app.diag_cache[path] { - if cached.content_hash == content_hash && cached.generation == gen { - log('Returning cached diagnostics for ${path}') - return cached.errors + if cached.fingerprint == fingerprint { + cache_hit = 'mem' + } + } + if cache_hit == '' { + app.ensure_diag_disk_cache(program_dir) + if cached := app.diag_cache[path] { + if cached.fingerprint == fingerprint { + cache_hit = 'disk' + } } } + if os.getenv('VLS_PERF_LOG') != '' { + app.send_log_message('diagnostics cache root=${overlay_root} fp=${fingerprint.hash().hex()} hit=${cache_hit}', + 4) + } + if cache_hit != '' { + log('Returning ${cache_hit}-cached diagnostics for ${path}') + return app.diag_cache[path].errors + } log('running v.exe check for ${real_path}') log('Open files count: ${app.open_files.len}') @@ -1309,9 +1352,9 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } else { '' } - // The file is checked as part of its program: from the program's directory, - // with the local modules it imports, as `v .` there builds it. - program_dir := app.program_root(real_path) + // The file is checked as part of its program: from the program's directory + // (computed above for the cache fingerprint), with the local modules it + // imports, as `v .` there builds it. // The copy of the program that the questions about it use too, whose lock // this check holds until it is answered: the same check answers both. mut shared_copy := &ProgramCopy(unsafe { nil }) @@ -1446,12 +1489,12 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { app.program_errors = found.program.clone() app.program_dir_checked = overlay.source_work_dir log('FILTERED ERRORS: ${found.file.len} of ${found.parsed}') - app.cache_v_check_result(path, content_hash, gen, found.file, x.exit_code, found.parsed) + app.cache_v_check_result(path, program_dir, fingerprint, found.file, x.exit_code, found.parsed) return found.file } log('V3 CHECK ERRORS: ${found.parsed}') - app.cache_v_check_result(path, content_hash, gen, found.file, x.exit_code, found.parsed) + app.cache_v_check_result(path, program_dir, fingerprint, found.file, x.exit_code, found.parsed) return found.file } diff --git a/interop_test.v b/interop_test.v index e10c7a7b..036bed2c 100644 --- a/interop_test.v +++ b/interop_test.v @@ -1043,11 +1043,10 @@ fn test_cache_v_check_result_retries_failure_without_diagnostics() { mut app := App{} path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ - content_hash: 1 - generation: 1 - errors: [] + fingerprint: 'fp' + errors: [] } - app.cache_v_check_result(path, 2, 2, [], compiler_exit_timeout, 0) + app.cache_v_check_result(path, '', 'fp2', [], compiler_exit_timeout, 0) assert path !in app.diag_cache } @@ -1055,9 +1054,8 @@ fn test_cache_v_check_result_retries_timeout_with_partial_diagnostics() { mut app := App{} path := 'file:///tmp/main.v' app.diag_cache[path] = DiagCacheEntry{ - content_hash: 1 - generation: 1 - errors: [] + fingerprint: 'fp' + errors: [] } partial_errors := [ JsonError{ @@ -1068,21 +1066,206 @@ fn test_cache_v_check_result_retries_timeout_with_partial_diagnostics() { level: 'error' }, ] - app.cache_v_check_result(path, 2, 2, partial_errors, compiler_exit_timeout, partial_errors.len) + app.cache_v_check_result(path, '', 'fp2', partial_errors, compiler_exit_timeout, partial_errors.len) assert path !in app.diag_cache } fn test_cache_v_check_result_keeps_valid_clean_and_diagnostic_results() { mut app := App{} path := 'file:///tmp/main.v' - app.cache_v_check_result(path, 1, 1, [], 0, 0) + app.cache_v_check_result(path, '', 'fp1', [], 0, 0) assert path in app.diag_cache assert app.diag_cache[path].errors.len == 0 - app.cache_v_check_result(path, 2, 2, [], 1, 1) + app.cache_v_check_result(path, '', 'fp2', [], 1, 1) assert path in app.diag_cache - assert app.diag_cache[path].content_hash == 2 - assert app.diag_cache[path].generation == 2 + assert app.diag_cache[path].fingerprint == 'fp2' +} + +// with_temp_diag_cache_dir points VLS_DIAG_CACHE_DIR at a fresh temp folder. +fn with_temp_diag_cache_dir(tag string) string { + dir := os.join_path(os.temp_dir(), 'vls_diagcache_${tag}_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(dir) + previous := os.getenv('VLS_DIAG_CACHE_DIR') + os.setenv('VLS_DIAG_CACHE_DIR', dir, true) + return previous +} + +fn restore_diag_cache_dir(previous string) { + if previous == '' { + os.unsetenv('VLS_DIAG_CACHE_DIR') + } else { + os.setenv('VLS_DIAG_CACHE_DIR', previous, true) + } +} + +fn test_program_content_fingerprint_tracks_buffers_and_siblings() { + root := os.join_path(os.temp_dir(), 'vls_fp_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + main_file := os.join_path(root, 'main.v') + other_file := os.join_path(root, 'other.v') + interop_test_must_write_file(main_file, 'module main\n\nfn main() {}\n') + interop_test_must_write_file(other_file, 'module main\n\nfn helper() {}\n') + main_uri := path_to_uri(main_file) + app := App{ + open_files: { + main_uri: 'module main\n\nfn main() {}\n' + } + } + // Deterministic for an unchanged state, buffers included. + before := app.program_content_fingerprint(root) + assert before == app.program_content_fingerprint(root) + // An unsaved buffer change alters it without touching disk. + mut changed := App{ + open_files: { + main_uri: 'module main\n\nfn main() {\n\tprintln(1)\n}\n' + } + } + assert changed.program_content_fingerprint(root) != before + // So does a sibling changing on disk. + interop_test_must_write_file(other_file, 'module main\n\nfn helper() int {\n\treturn 2\n}\n') + assert app.program_content_fingerprint(root) != before + os.rmdir_all(root) or {} +} + +fn test_diag_disk_cache_round_trip_and_rejections() { + previous := with_temp_diag_cache_dir('roundtrip') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_dc_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + entry := DiagCacheEntry{ + fingerprint: 'fp1' + errors: [ + JsonError{ + path: os.join_path(root, 'main.v') + message: 'seeded error' + line_nr: 2 + col: 3 + len: 4 + level: 'error' + }, + ] + } + save_diag_disk_entry(root, 'file:///main.v', entry) + loaded := load_diag_disk_cache(root) + assert loaded['file:///main.v'].fingerprint == 'fp1' + assert loaded['file:///main.v'].errors.len == 1 + assert loaded['file:///main.v'].errors[0].message == 'seeded error' + assert loaded['file:///main.v'].errors[0].line_nr == 2 + // Unreadable content is dropped, not fatal. + os.write_file(diag_cache_file(root), 'not json') or { panic(err) } + assert load_diag_disk_cache(root).len == 0 + // Results written by another compiler are dropped. + save_diag_disk_entry(root, 'file:///main.v', entry) + previous_command := os.getenv('VLS_V_COMMAND') + os.setenv('VLS_V_COMMAND', os.join_path(root, 'no-such-compiler'), true) + assert load_diag_disk_cache(root).len == 0 + restore_v_command(previous_command) + os.rmdir_all(root) or {} +} + +fn test_diag_disk_cache_serves_a_later_session() { + previous := with_temp_diag_cache_dir('twosession') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_2sess_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + // First session: compute the fingerprint exactly as run_v_check does and + // store a result on disk. + mut first := &App{ + open_files: { + uri: content + } + } + real_path := uri_to_path(uri) + program_dir := first.program_root(real_path) + fingerprint := compiler_fingerprint() + '\n' + + first.program_content_fingerprint(program_overlay_root(real_path, program_dir)) + entry := DiagCacheEntry{ + fingerprint: fingerprint + errors: [ + JsonError{ + path: real_path + message: 'persisted diagnostic' + line_nr: 1 + col: 1 + level: 'error' + }, + ] + } + save_diag_disk_entry(program_dir, uri, entry) + // Second session: a fresh App must merge and match the same fingerprint. + mut second := &App{ + open_files: { + uri: content + } + } + second.ensure_diag_disk_cache(second.program_root(real_path)) + assert uri in second.diag_cache, 'disk results did not merge' + refingerprint := compiler_fingerprint() + '\n' + + second.program_content_fingerprint(program_overlay_root(real_path, + second.program_root(real_path))) + assert second.diag_cache[uri].fingerprint == refingerprint, 'fingerprint moved between sessions' + assert second.diag_cache[uri].errors[0].message == 'persisted diagnostic' + os.rmdir_all(root) or {} +} + +fn test_run_v_check_returns_cached_result_without_compiler() { + previous_cache := with_temp_diag_cache_dir('hit') + previous_command := os.getenv('VLS_V_COMMAND') + defer { + restore_diag_cache_dir(previous_cache) + restore_v_command(previous_command) + } + root := os.join_path(os.temp_dir(), 'vls_hit_${os.getpid()}_${time.now().unix_nano()}') + work := os.join_path(root, 'work') + interop_test_must_mkdir_all(work) + os.setenv('VLS_V_COMMAND', os.join_path(root, 'no-such-compiler'), true) + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + open_files: { + uri: content + } + } + // Seed exactly the state run_v_check will compute: with a compiler + // that does not exist, only a fingerprint hit can answer. + real_path := uri_to_path(uri) + program_dir := app.program_root(real_path) + fingerprint := compiler_fingerprint() + '\n' + + app.program_content_fingerprint(program_overlay_root(real_path, program_dir)) + cached_errors := [ + JsonError{ + path: real_path + message: 'seeded diagnostic' + line_nr: 1 + col: 1 + level: 'error' + }, + ] + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: fingerprint + errors: cached_errors + } + got := app.run_v_check(uri, content) + assert got.len == 1 + assert got[0].message == 'seeded diagnostic' + // A changed buffer misses and, with no compiler, answers nothing. + changed := content + '\n// changed\n' + app.open_files[uri] = changed + missing := app.run_v_check(uri, changed) + assert missing.len == 0, missing.str() + os.rmdir_all(root) or {} } fn test_run_v_argv_reports_missing_working_dir() { diff --git a/main.v b/main.v index ae7c6704..5261a70e 100644 --- a/main.v +++ b/main.v @@ -30,6 +30,7 @@ mut: inlay_hints_enabled bool = true // toggled via workspace/didChangeConfiguration diagnostics_enabled bool = true // toggled via workspace/didChangeConfiguration diag_cache map[string]DiagCacheEntry // Per-URI cached diagnostics + diag_disk_roots map[string]bool // Program roots whose disk cache merged this session open_files_generation int // Incremented on every workspace file mutation project_generations map[string]int // Per-project-dir revision, for scoped cache invalidation cancelled_requests map[int]bool // Request ids cancelled via $/cancelRequest @@ -85,11 +86,12 @@ struct JsonVarAC { details []Detail } -// DiagCacheEntry stores a cached diagnostic result for one file. +// DiagCacheEntry stores a cached diagnostic result for one file. The +// fingerprint covers the program's contents and the compiler (see +// diag_cache.v): only an identical state reuses the errors. struct DiagCacheEntry { - content_hash int - generation int - errors []JsonError + fingerprint string + errors []JsonError } // Keep runtime-derived settings behind functions. Function-call module constants can crash V3's From b11028a14062e53a84d5742ebc22bca35ea7fa37 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 21:32:00 +0330 Subject: [PATCH 09/83] test: give the wrapper fixture the vlib dir a real install has find_v_dir_from_exe walks up to a directory holding vlib. The fake compiler tree had none, so there was no V home to find and the test failed everywhere. Creating base/vlib mirrors a real installation. --- interop_test.v | 3 +++ 1 file changed, 3 insertions(+) diff --git a/interop_test.v b/interop_test.v index e10c7a7b..6fdd66ee 100644 --- a/interop_test.v +++ b/interop_test.v @@ -105,6 +105,9 @@ fn test_resolve_v_compiler_exe_unwraps_configured_wrapper() { } target := os.join_path(base, 'v.exe') interop_test_must_write_file(target, '') + // A real V installation holds vlib next to the compiler; without it the + // walk-up has no V home to find. + interop_test_must_mkdir_all(os.join_path(base, 'vlib')) 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) From af3df7fe39b983e7d27dde59ffe0ea9f6bad2e36 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 22:22:16 +0330 Subject: [PATCH 10/83] perf: answer line-info from the shared diagnostics-server pool run_v_line_info_once tries the warm shared server and its program copy first, falling back to the one-shot compiler path on any failure. Compat and missing modes keep their existing path untouched, and the pool never changes mode probing. Hover, signature help, and compiler-backed completions stop paying overlay plus spawn per request when a server is warm. --- interop.v | 81 +++++++++++++++++++++++++++++ v3_line_info_test.v | 123 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/interop.v b/interop.v index e6d91d4a..982ee4b5 100644 --- a/interop.v +++ b/interop.v @@ -2389,6 +2389,81 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R return app.run_v_line_info_once(method, path, line_info, compilation_work_dir(normalize_overlay_path(real_path))) } +// PooledLineInfo is the answer of the shared diagnostics-server pool to one +// `-line-info` question, with the copy of the program it comes from. +struct PooledLineInfo { + output string + overlay CompilationOverlay +} + +// run_v_line_info_pooled answers from the shared diagnostics-server pool, +// which checks the same copy of the program the diagnostics slow path checks +// (see ProgramCopy): a warm server answers in a fraction of the time a +// compiler process of its own takes to start. None means the pool has no +// answer, and the caller falls back to that one-shot process. The pool never +// changes how the session drives the compiler: compat and missing modes keep +// their one-shot path, and the mode probes there still run. +fn (mut app App) run_v_line_info_pooled(method Method, path string, line_info string) ?PooledLineInfo { + if app.line_info_mode == .compat || app.line_info_mode == .missing { + return none + } + exe := resolve_diagnostics_server_exe() or { return none } + real_path := uri_to_path(path) + program_dir := app.program_root(real_path) + mut pool := app.v3_query_pool() + if !pool.begin_operation() { + return none + } + defer { + pool.end_operation() + } + mut program := pool.program_copy(program_dir) + program.mutex.lock() + defer { + program.mutex.unlock() + } + app.prepare_program_copy(mut pool, mut program, real_path, program_dir) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + app.v3_sync_open_files(mut program.project) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + content := app.open_files[path] or { os.read_file(real_path) or { return none } } + copy_path := program.project.write(normalize_overlay_path(real_path), content) or { + log('no pooled copy of ${program_dir}: ${err}') + return none + } + project := program.project + // A test file is a program of its own, which V builds with the files of + // its module: the program of the directory leaves it out. + target := if copy_path.ends_with('_test.v') { copy_path } else { '.' } + // The command line of the checks of the diagnostics (see + // build_v_check_args_multifile), whose servers answer the questions too. + is_library := target == '.' && !app.is_program_dir(project.overlay.source_work_dir) + mut args := v3_compiler_selection_args() + if is_library { + args << '-shared' + } + args << ['-check', '-nocolor', target] + mut servers := app.v3_query_pool() + result := servers.query(exe, args, project.overlay.temp_work_dir, '${copy_path}:${line_info}') or { + return none + } + if result.exit_code != 0 { + return none + } + output := normalize_v_line_info_output(result.output, method) + if output == '' { + return none + } + return PooledLineInfo{ + output: output + overlay: project.overlay + } +} + // run_v_line_info_once answers from a compiler process of its own, which checks // the program from `work_dir`. fn (mut app App) run_v_line_info_once(method Method, path string, line_info string, work_dir string) ResponseResult { @@ -2396,6 +2471,12 @@ fn (mut app App) run_v_line_info_once(method Method, path string, line_info stri if app.line_info_mode == .missing { return app.line_info_unavailable_result(method, path, line_info) } + // A warm shared server answers from the copy of the program it already + // checks, without the overlay and the compiler process of its own below. + if pooled := app.run_v_line_info_pooled(method, path, line_info) { + return app.line_info_result(method, path, line_info, pooled.output, true, + pooled.overlay.temp_root, pooled.overlay, pooled.overlay.temp_work_dir) + } mut working_dir := os.dir(real_path) mut file_to_check := real_path mut compile_target := real_path diff --git a/v3_line_info_test.v b/v3_line_info_test.v index 31fe34fc..a25affae 100644 --- a/v3_line_info_test.v +++ b/v3_line_info_test.v @@ -632,6 +632,129 @@ fn test_a_v_without_the_query_engine_leaves_the_answer_to_v1() { assert app.v3_one_shot_unsupported } +// A diagnostics server that answers no question: each one fails as on a +// program V3 cannot parse. +const fake_v3_failing_server = r"#!/bin/sh +echo v-diagnostics-server: ready +here=$(dirname $0) +while read -r request rest; do + case $request in quit) exit 0 ;; esac + token=${rest%% *} + echo ${rest#* } >> $here/questions.txt + echo v-diagnostics-server: child 1 $token + echo 'main.v:1:1: error: unexpected token' + printf '\nv-diagnostics-server: end 1 %s\n' $token +done +" + +// fake_one_shot_v writes the fake compiler that answers `-line-info` in a +// process of its own into a directory of its own under `dir`, with the answer +// the test left for it, and returns its path. +fn fake_one_shot_v(dir string, name string) !string { + alone := os.join_path(dir, name) + os.mkdir_all(alone)! + exe := os.join_path(alone, 'v') + os.write_file(exe, fake_v3_one_shot)! + os.chmod(exe, 0o755)! + os.write_file(os.join_path(alone, 'answer.txt'), fake_hover_answer)! + return exe +} + +fn test_v1_line_info_uses_the_warm_shared_server() { + mut app, fake := fake_v3_app('v1_pooled', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + // V3 off: the request goes to the V1 path, which asks the shared pool first. + app.v3_line_info_enabled = false + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // Asked through the shared server, in the copy the diagnostics use. + assert fake.questions().len == 1 + assert app.v3_copies().len == 1 + copy_root := app.v3_copies()[0].overlay.temp_root + assert fake.questions()[0] == '${os.join_path(copy_root, 'main.v')}:4:hv^2' + // The pool never changes how the session drives the compiler. + assert app.line_info_mode == .unknown + // A second request reuses the server and the copy: nothing new starts. + app.run_v_line_info(.hover, uri, '4:hv^2') + assert fake.questions().len == 2 + assert app.v3_query_pool().servers.len == 1 +} + +fn test_v1_definition_comes_back_mapped_from_the_shared_copy() { + mut app, fake := fake_v3_app('v1_definition', fake_v3_self_declaration_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.definition, uri, '4:gd^2') + assert result is Location + found := result as Location + // The server names the copy; the answer names the file it mirrors. + assert found.uri == uri + assert found.range.start.line == 3 +} + +fn test_v1_line_info_falls_back_to_its_own_process() { + mut app, fake := fake_v3_app('v1_fallback', fake_v3_failing_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + // The V in use answers `-line-info` in a process of its own. + os.setenv('VLS_V_COMMAND', fake_one_shot_v(fake.dir, 'alone')!, true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // The pool was tried first, then the one-shot process answered. + assert fake.questions().len == 1 + alone_questions := os.read_file(os.join_path(fake.dir, 'alone', 'questions.txt')) or { '' } + assert alone_questions.split_into_lines().len == 1 +} + +fn test_v1_line_info_in_compat_mode_keeps_its_own_process() { + mut app, fake := fake_v3_app('v1_compat', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + app.line_info_mode = .compat + os.setenv('VLS_V_COMMAND', fake_one_shot_v(fake.dir, 'alone')!, true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // Compat keeps its one-shot path: the shared server is never asked, and + // the mode is untouched. + assert !os.exists(os.join_path(fake.server, 'questions.txt')) + assert app.line_info_mode == .compat +} + +fn test_v1_line_info_without_a_server_keeps_its_own_process() { + mut app, fake := fake_v3_app('v1_no_server', fake_v3_one_shot, 'VLS_V_COMMAND')! + defer { + stop_fake_v3_app(mut app, fake) + } + app.v3_line_info_enabled = false + os.setenv('VLS_DIAGNOSTICS_SERVER', 'off', true) + path := os.join_path(fake.project, 'main.v') + uri := path_to_uri(path) + result := app.run_v_line_info(.hover, uri, '4:hv^2') + assert result is Hover + assert (result as Hover).contents.value.contains('fake') + // No server to ask: no copy is built for questions. + assert app.v3_copies().len == 0 +} + fn test_only_a_created_or_deleted_file_rebuilds_the_copy() { mut app, fake := fake_v3_app('watcher', fake_v3_query_server, 'VLS_DIAGNOSTICS_SERVER')! defer { From a75792817933479bf1df0ad52bd5a0f52ab7bea8 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 22:22:25 +0330 Subject: [PATCH 11/83] feat: fast tier reports certain parse errors for the edited buffer Extends the compiler-free fast check with bracket/string/comment/interpolation balance scanning that mirrors the index tokenizer. Only certain findings publish (stray closers, unclosed constructs at EOF with no continuation); mid-keystroke states stay silent and the slow check corrects the rest. --- fast_diagnostics.v | 295 ++++++++++++++++++++++++++++++++++++++++++++- handlers_test.v | 233 +++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 5 deletions(-) diff --git a/fast_diagnostics.v b/fast_diagnostics.v index bec420a3..641a6a53 100644 --- a/fast_diagnostics.v +++ b/fast_diagnostics.v @@ -165,10 +165,294 @@ fn (app &App) fast_check_errors(uri string, content string) []JsonError { return errors } -// run_fast_diagnostics_job answers a fast job: index-only errors, no -// compiler, no temp copy. An empty fast answer publishes nothing: the slow -// check publishes the file's full state, which also clears a fast error it -// does not repeat. +// FastParseBracket is one unclosed opening delimiter with its 0-based position. +struct FastParseBracket { + ch u8 + line int // 0-based line of the opener + col int // 0-based byte column of the opener +} + +// FastParseInterp is one `${` interpolation whose string is suspended while its +// code is scanned, with the 0-based position of the `$`. +struct FastParseInterp { + quote u8 + line int + col int +mut: + brace_depth int +} + +// FastParseState is the scanner state for fast_parse_errors. It mirrors the +// discipline of OccurrenceScanState in index.v: nested block comments, line +// comments, single, double and backtick strings, raw and C strings, escapes, +// and `${` interpolation. Bracket depth is tracked only in code, so brackets +// inside comments, strings and rune literals never count. +struct FastParseState { +mut: + block_comment_depth int + block_comment_line int + block_comment_col int + quote u8 + quote_line int + quote_col int + raw_string bool + interpolations []FastParseInterp + stack []FastParseBracket +} + +// fast_scan_literal scans a string or rune literal from `start`, skipping its +// text the way scan_literal_identifier_occurrences does. A `${` suspends the +// literal and scans the interpolation as code. It returns the byte after the +// closing quote, the matching `}` resume point, or the end of the line for a +// literal that stays open. +fn fast_scan_literal(line_text string, line_idx int, start int, path string, mut state FastParseState, mut errors []JsonError) int { + mut col := start + if state.quote == 0 { + state.quote = line_text[start] + state.quote_line = line_idx + state.quote_col = start + col++ + } + quote := state.quote + for col < line_text.len { + if line_text[col] == `\\` && !state.raw_string { + col += 2 + continue + } + if line_text[col] == quote { + state.quote = 0 + state.raw_string = false + return col + 1 + } + if !state.raw_string && line_text[col] == `$` && col + 1 < line_text.len + && line_text[col + 1] == `{` { + state.quote = 0 + state.interpolations << FastParseInterp{ + quote: quote + line: line_idx + col: col + } + return fast_scan_code(line_text, line_idx, col + 2, path, mut state, mut errors) + } + col++ + } + return col +} + +// fast_scan_code scans code from `start` to the end of the line, tracking +// bracket depth the way scan_code_identifier_occurrences tracks token context. +// A closer with an empty stack is certain no matter what the user types next, +// so it reports at once. A closer that mismatches the open top is uncertain +// mid-keystroke, so the slow check decides and the stack is left alone. +fn fast_scan_code(line_text string, line_idx int, start int, path string, mut state FastParseState, mut errors []JsonError) int { + mut col := start + for col < line_text.len { + if state.quote != 0 { + col = fast_scan_literal(line_text, line_idx, col, path, mut state, mut errors) + continue + } + c := line_text[col] + if state.block_comment_depth > 0 { + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `*` { + state.block_comment_depth++ + col += 2 + continue + } + if col + 1 < line_text.len && c == `*` && line_text[col + 1] == `/` { + state.block_comment_depth-- + col += 2 + continue + } + col++ + continue + } + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `/` { + return line_text.len + } + if col + 1 < line_text.len && c == `/` && line_text[col + 1] == `*` { + state.block_comment_depth = 1 + state.block_comment_line = line_idx + state.block_comment_col = col + col += 2 + continue + } + if c in [`r`, `c`] && col + 1 < line_text.len + && (line_text[col + 1] == `"` || line_text[col + 1] == `'`) { + state.raw_string = c == `r` + col = fast_scan_literal(line_text, line_idx, col + 1, path, mut state, mut errors) + continue + } + if c == `"` || c == `'` || c == 96 { + col = fast_scan_literal(line_text, line_idx, col, path, mut state, mut errors) + continue + } + if c == `(` || c == `[` || c == `{` { + if c == `{` && state.interpolations.len > 0 { + last := state.interpolations.len - 1 + state.interpolations[last].brace_depth++ + } + state.stack << FastParseBracket{ + ch: c + line: line_idx + col: col + } + col++ + continue + } + if c == `)` || c == `]` || c == `}` { + if c == `}` && state.interpolations.len > 0 { + last := state.interpolations.len - 1 + if state.interpolations[last].brace_depth == 0 { + interp := state.interpolations.pop() + state.quote = interp.quote + col++ + continue + } + state.interpolations[last].brace_depth-- + } + expected := if c == `)` { + `(` + } else if c == `]` { + `[` + } else { + `{` + } + if state.stack.len == 0 { + // JsonError positions are 1-based (see v_error_to_lsp_diagnostic). + errors << JsonError{ + path: path + message: "unexpected token '${c.ascii_str()}'" + line_nr: line_idx + 1 + col: col + 1 + len: 1 + level: 'error' + } + } else if state.stack.last().ch == expected { + state.stack.pop() + } + col++ + continue + } + col++ + } + return col +} + +// fast_last_non_empty returns the index of the last line holding non-space +// text, or -1 when there is none. +fn fast_last_non_empty(lines []string) int { + for i := lines.len - 1; i >= 0; i-- { + if lines[i].trim_space() != '' { + return i + } + } + return -1 +} + +// fast_last_line_is_continuation reports whether the last non-empty line may +// still be growing: it ends in an opener or an operator tail, or it holds a +// construct that is still unclosed at EOF. Flagging the file now would fire +// on mid-keystroke code, so the slow check decides instead. +fn fast_last_line_is_continuation(lines []string, last int, state &FastParseState) bool { + for b in state.stack { + if b.line == last { + return true + } + } + for interp in state.interpolations { + if interp.line == last { + return true + } + } + line := lines[last].trim_space() + if line == '' { + return true + } + c := line[line.len - 1] + return c in [`(`, `[`, `{`, `,`, `=`, `:`, `.`, `\\`, `+`, `-`, `*`, `/`, `%`, `&`, `|`, `^`, + `!`, `<`, `>`, `?`, `$`, `~`] +} + +// fast_parse_errors scans the edited file's buffer for certain delimiter +// mistakes: a closing delimiter with no opener, or an unclosed delimiter, +// string or block comment at EOF. It runs no compiler and reads no other +// file. An EOF remainder only reports when the last non-empty line is not an +// obvious continuation, and a backtick literal never reports: backtick strings +// may legally span lines, so an unclosed one at EOF is still being typed as +// far as this check can tell. +fn (app &App) fast_parse_errors(uri string, content string) []JsonError { + real_path := uri_to_path(uri) + mut state := FastParseState{} + mut errors := []JsonError{} + lines := content.split_into_lines() + for line_idx, line_text in lines { + fast_scan_code(line_text, line_idx, 0, real_path, mut state, mut errors) + } + last := fast_last_non_empty(lines) + if last < 0 { + return errors + } + if state.quote != 0 { + if state.quote != 96 && state.quote_line < last { + errors << JsonError{ + path: real_path + message: 'unclosed string literal' + line_nr: state.quote_line + 1 + col: state.quote_col + 1 + len: 1 + level: 'error' + } + } + return errors + } + if state.block_comment_depth > 0 { + if state.block_comment_line < last { + errors << JsonError{ + path: real_path + message: 'unclosed block comment' + line_nr: state.block_comment_line + 1 + col: state.block_comment_col + 1 + len: 2 + level: 'error' + } + } + return errors + } + if state.interpolations.len > 0 { + interp := state.interpolations.last() + if !fast_last_line_is_continuation(lines, last, &state) { + errors << JsonError{ + path: real_path + message: 'unclosed string interpolation' + line_nr: interp.line + 1 + col: interp.col + 1 + len: 2 + level: 'error' + } + } + return errors + } + if state.stack.len > 0 { + if fast_last_line_is_continuation(lines, last, &state) { + return errors + } + first := state.stack.first() + errors << JsonError{ + path: real_path + message: "unclosed delimiter '${first.ch.ascii_str()}'" + line_nr: first.line + 1 + col: first.col + 1 + len: 1 + level: 'error' + } + } + return errors +} + +// run_fast_diagnostics_job answers a fast job: import and parse errors from +// the edited buffer only, no compiler, no temp copy. An empty fast answer +// publishes nothing: the slow check publishes the file's full state, which +// also clears a fast error it does not repeat. fn run_fast_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { if !scheduler.is_job_current(job) { return @@ -186,7 +470,8 @@ fn run_fast_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJ tcp_conn: job.tcp_conn diagnostics_enabled: true } - errors := worker.fast_check_errors(job.uri, job.content) + mut errors := worker.fast_check_errors(job.uri, job.content) + errors << worker.fast_parse_errors(job.uri, job.content) if errors.len == 0 { return } diff --git a/handlers_test.v b/handlers_test.v index 22aa98ca..a3778f20 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -14036,6 +14036,239 @@ fn test_fast_check_errors_skips_c_interop() { assert app.fast_check_errors(uri, content) == [] } +fn test_fast_parse_errors_flags_stray_closing_delimiter() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n}\n}\n' + dir := fast_test_project(mut app, 'parse_stray', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unexpected token '}'" + assert errors[0].line_nr == 5 + assert errors[0].col == 1 + assert errors[0].level == 'error' + // A stray closer publishes through the same notification path as slow errors. + notif := app.diagnostics_notification_for(uri, text, errors) + assert notif.method == 'textDocument/publishDiagnostics' + assert notif.params.uri == uri + assert notif.params.diagnostics.len == 1 + assert (notif.params.diagnostics[0].source or { '' }) == 'vlang' + assert notif.params.diagnostics[0].severity == 1 + assert notif.params.diagnostics[0].message == "unexpected token '}'" +} + +fn test_fast_parse_errors_accepts_balanced_code() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\tprintln('hi')\n}\n" + dir := fast_test_project(mut app, 'parse_balanced', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_ignores_brackets_in_strings_comments_and_runes() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\ts := \'()[]{}\'\n\tt := "([{}])"\n\tu := `}{)(`\n\tr := r\'(raw)\'\n\t// comment with ) ] }\n\t/* block with ( [ { */\n\t w := \'interp \${s} done\'\n\tch := `)`\n\tprintln(s, t, u, r, w, ch)\n}\n' + dir := fast_test_project(mut app, 'parse_skipped', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_flags_unclosed_delimiter_at_eof() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\tprintln(x)\n' + dir := fast_test_project(mut app, 'parse_unclosed', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unclosed delimiter '{'" + assert errors[0].line_nr == 3 + assert errors[0].col == 11 + assert errors[0].level == 'error' +} + +fn test_fast_parse_errors_suppresses_obvious_continuations() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + still_typing := [ + '', + 'module main\n\nfn main() {', + 'module main\n\nfn main() {\n\tx := [1, 2', + 'module main\n\nfn main() {\n\tx := 1 +', + "module main\n\nfn main() {\n\ts := 'abc", + 'module main\n\nfn main() {\n\t/* note', + ] + for content in still_typing { + uri := path_to_uri(os.join_path(app.temp_dir, 'parse_cont.v')) + assert app.fast_parse_errors(uri, content) == [], content + } +} + +fn test_fast_parse_errors_flags_unclosed_string() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\ts := 'hello\n\tprintln(s)\n}\n" + dir := fast_test_project(mut app, 'parse_string', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string literal' + assert errors[0].line_nr == 4 + assert errors[0].col == 7 +} + +fn test_fast_parse_errors_flags_unclosed_block_comment() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nfn main() {\n\t/* open\n\tprintln(x)\n}\n' + dir := fast_test_project(mut app, 'parse_comment', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed block comment' + assert errors[0].line_nr == 4 + assert errors[0].col == 2 +} + +fn test_fast_parse_errors_ignores_multiline_backtick_string() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // A backtick string may legally span lines, so this looks exactly like a + // multiline string that is still being typed. The slow check decides. + content := 'module main\n\nfn main() {\n\ts := `abc\n\tdef\n}\n' + dir := fast_test_project(mut app, 'parse_backtick', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + assert app.fast_parse_errors(uri, text) == [] +} + +fn test_fast_parse_errors_flags_unclosed_interpolation() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := "module main\n\nfn main() {\n\ts := '\${abc\n\tdef\n" + dir := fast_test_project(mut app, 'parse_interp', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string interpolation' + assert errors[0].line_nr == 4 + assert errors[0].col == 8 +} + +fn test_fast_parse_errors_flags_string_left_open_by_interpolation() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // The quote after the interpolation start opens a new literal inside the + // interpolation code, so the file ends inside a string opened earlier. + content := "module main\n\nfn main() {\n\ts := '\${name'\n\tprintln(s)\n}\n" + dir := fast_test_project(mut app, 'parse_interp_quote', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == 'unclosed string literal' + assert errors[0].line_nr == 4 +} + +fn test_fast_parse_errors_reports_mismatch_as_unclosed() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // A crossed closer is uncertain mid-keystroke, so the scan leaves the + // stack alone and the EOF remainder reports the first unclosed opener. + content := 'module main\n\nfn main() {\n\tx := ([)]\n\tprintln(x)\n}\n' + dir := fast_test_project(mut app, 'parse_mismatch', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + errors := app.fast_parse_errors(uri, text) + assert errors.len == 1, errors.str() + assert errors[0].message == "unclosed delimiter '{'" + assert errors[0].line_nr == 3 +} + +fn test_fast_parse_errors_combines_with_import_errors() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + content := 'module main\n\nimport nosuchmod_xyz\n\nfn main() {\n}\n}\n' + dir := fast_test_project(mut app, 'parse_combined', { + 'main.v': content + }) + path := os.join_path(dir, 'main.v') + uri := path_to_uri(path) + text := os.read_file(path) or { panic(err) } + app.open_files[uri] = text + import_errors := app.fast_check_errors(uri, text) + parse_errors := app.fast_parse_errors(uri, text) + assert import_errors.len == 1, import_errors.str() + assert parse_errors.len == 1, parse_errors.str() + mut combined := import_errors.clone() + combined << parse_errors + assert combined.len == 2 +} + fn test_scheduler_tickets_come_in_slow_and_fast() { mut scheduler := new_diagnostics_scheduler() tickets := scheduler.begin_project_schedule('file:///a.v', 'proj') From c4c8e27c52b6493332358769953752768abb7d6d Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 22:22:34 +0330 Subject: [PATCH 12/83] test: compiler-compatibility matrix for line-info modes Stub compilers for modern-direct, old-flag, and dead-end generations assert the probed LineInfoMode, diagnostics results, and mode stability across repeated probes. --- interop_test.v | 170 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/interop_test.v b/interop_test.v index 89975d35..980781e4 100644 --- a/interop_test.v +++ b/interop_test.v @@ -2645,3 +2645,173 @@ fn test_publish_diagnostics_params() { assert params.uri == 'file:///test.v' assert params.diagnostics.len == 1 } + +// ============================================================================ +// Compiler-compatibility matrix: one stub compiler per LineInfoMode. +// Each stub answers `-check` with a diagnostic (so diagnostics are +// independent of the `-line-info` mode) and answers `-line-info` the way +// its generation does. Every test asserts the probed mode, that a +// diagnostics spot-check still returns results, and that the mode never +// regresses within the session once probed. +// ============================================================================ + +// A current compiler answers `-line-info` itself: no refusal, no selector. +const compat_matrix_modern_direct_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + case " $* " in + *hv^4*) echo "{\"contents\":{\"kind\":\"markdown\",\"value\":\"fn helper()\"}}" ;; + esac + exit 0 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +// An older launcher refuses `-line-info` until `-old-compiler` selects it. +const compat_matrix_old_flag_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + for arg in "$@"; do + if [ "$arg" = "-old-compiler" ]; then + echo "\`-old-compiler\` was requested; retrying with \`/v1_fallback\`." >&2 + echo "{\"contents\":{\"kind\":\"markdown\",\"value\":\"fn helper()\"}}" + exit 0 + fi + done + echo "unknown option \`-vls-mode\`" >&2 + exit 1 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +// A launcher with no checker refuses the options and the selector outright. +const compat_matrix_dead_end_stub = r'#!/bin/sh +case " $* " in + *"-line-info"*) + for arg in "$@"; do + if [ "$arg" = "-old-compiler" ]; then + echo "unknown option \`-old-compiler\`" >&2 + exit 1 + fi + done + echo "unknown option \`-vls-mode\`" >&2 + exit 1 + ;; +esac +case " $* " in + *" -check "*) + for last in "$@"; do :; done + echo "${last}:1:1: error: stub check error" >&2 + exit 1 + ;; +esac +exit 0 +' + +fn test_compat_matrix_modern_direct_probes_direct() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_modern', compat_matrix_modern_direct_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + assert app.line_info_mode == .unknown + + hover := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .direct + assert hover is Hover + if hover is Hover { + assert hover.contents.value.contains('fn helper()') + } + + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the modern stub' + assert check[0].message.contains('stub check error') + + // A probed mode never regresses within the session. + again := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .direct + assert again is Hover +} + +fn test_compat_matrix_old_flag_needs_explicit_selector() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_old_flag', compat_matrix_old_flag_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + assert app.line_info_mode == .unknown + + hover := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .compat + assert hover is Hover + if hover is Hover { + assert hover.contents.value.contains('fn helper()') + } + + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the old-flag stub' + assert check[0].message.contains('stub check error') + + // Compat stays compat: the retry keeps selecting the checker directly. + again := app.run_v_line_info(.hover, uri, '6:hv^4') + assert app.line_info_mode == .compat + assert again is Hover +} + +fn test_compat_matrix_dead_end_without_checker() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_compat_dead_end', compat_matrix_dead_end_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + app.capture_output = true + assert app.line_info_mode == .unknown + + assert app.run_v_line_info(.hover, uri, '6:hv^4') == ResponseResult('null') + assert app.line_info_mode == .missing + + // Retiring the lookups must not retire diagnostics: `v check` needs + // no compatibility compiler. + content := os.read_file(uri_to_path(uri)) or { '' } + check := app.run_v_check(uri, content) + assert check.len > 0, 'expected diagnostics from the dead-end stub' + assert check[0].message.contains('stub check error') + + // Retired stays retired: no new process, no second notice. + assert app.run_v_line_info(.hover, uri, '6:hv^4') == ResponseResult('null') + assert app.line_info_mode == .missing + assert app.captured_output.len == 1 +} From eb622199653f72dc8820b0ab37a82475365ddb1a Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 22:22:57 +0330 Subject: [PATCH 13/83] docs: record pooled line-info, fast parse errors, compat matrix --- ROADMAP.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index aeecaaad..5f56fe8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -74,6 +74,18 @@ base): `test_conditional_methods_request_receiver_completion_fallback` needs the V1 compatibility compiler, which this host cannot build (`make` missing). +## Phase 1c — done: pooled line-info, fast parse errors, compat matrix + +- `run_v_line_info_once` answers from the shared diagnostics-server + pool first (same copy the slow check uses), one-shot fallback + preserved; compat/missing modes untouched. +- Fast tier also reports certain parse errors (stray closers, + unclosed delimiters/strings/comments at EOF); mid-keystroke + states stay silent. +- Compiler-compatibility matrix tests: modern-direct, old-flag, + and dead-end stub compilers assert probed mode, diagnostics, + and mode stability. + ## Phase 1 — foundation (remaining) - Split diagnostics: fast (parse + open-file errors, ~100ms From 5fdbd0a2a9bde0e8e5d65df38b696e3db1102ce2 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 23:21:42 +0330 Subject: [PATCH 14/83] test: broad direct coverage for handlers, interop, index, lsp, main About 115 new focused tests for previously uncovered pure and harness-level helpers: occurrences, declarations, completions, positions, import parsing, fingerprint and disk-cache paths, JSON framing, inlay stamps. Coverage heuristic up from 38 percent to about 71 percent of functions mentioned in tests. --- handlers_test.v | 1089 +++++++++++++++++++++++++++++++++++++++++ index_test.v | 104 ++++ interop_test.v | 1244 +++++++++++++++++++++++++++++++++++++++++++++++ lsp_test.v | 22 + main_test.v | 194 ++++++++ 5 files changed, 2653 insertions(+) diff --git a/handlers_test.v b/handlers_test.v index a3778f20..0aa7373d 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -14340,3 +14340,1092 @@ fn test_scheduler_cancel_drops_both_tiers() { assert jobs.len == 0 assert should_stop } + +// Coverage for handlers.v pure helpers: delimiters, parameters and names. + +fn test_cov_bracket_depth_counts_delimiters_outside_strings() { + assert bracket_depth('fn main() {') == 1, 'open brace' + assert bracket_depth('fn main() {}') == 0, 'balanced braces' + assert bracket_depth('a { b {') == 2, 'nested opens' + assert bracket_depth('}') == -1, 'lone closer' + assert bracket_depth('a[b(c)]') == 0, 'mixed delimiters balance' + assert bracket_depth('s := "{ not a block"') == 0, 'brace in string is skipped' +} + +fn test_cov_matching_delimiter_finds_pairs() { + assert matching_delimiter('(a(b)c)', 0, `(`, `)`) == 6, 'nested parens' + assert matching_delimiter('[x]', 0, `[`, `]`) == 2, 'brackets' + assert matching_delimiter('(unclosed', 0, `(`, `)`) == -1, 'unclosed' + assert matching_delimiter('abc', 1, `(`, `)`) == -1, 'not an opener' + assert matching_delimiter('', 0, `(`, `)`) == -1, 'empty text' +} + +fn test_cov_split_top_level_commas() { + assert split_top_level_commas('a, b, c') == ['a', ' b', ' c'], 'flat list' + assert split_top_level_commas('f(a, b), c') == ['f(a, b)', ' c'], 'nested call stays whole' + assert split_top_level_commas('') == [''], 'empty text is one part' +} + +fn test_cov_function_parameter_names() { + assert function_parameter_names('fn foo(a int, b string) int') == ['a', 'b'], 'plain params' + assert function_parameter_names('fn (mut app App) foo(x int)') == ['app', 'x'], 'receiver first' + assert function_parameter_names('not a function') == [], 'no signature' +} + +fn test_cov_utf8_helpers() { + assert utf8_seq_len(`a`) == 1, 'ascii' + assert utf8_seq_len(0xc3) == 2, 'two byte lead' + assert utf8_seq_len(0xe2) == 3, 'three byte lead' + assert utf8_seq_len(0xf0) == 4, 'four byte lead' + assert utf8_seq_len(0x80) == 1, 'continuation byte advances by one' + s := 'h\xc3\xa9llo' + assert utf8_char_to_byte_index(s, 2) == 3, 'second char starts at byte 3' + assert utf8_byte_to_char_index(s, 3) == 2, 'byte 3 is the second char' + assert utf8_char_to_byte_index(s, 0) == 0, 'zero char is zero byte' + assert utf8_byte_to_char_index(s, 0) == 0, 'zero byte is zero char' +} + +fn test_cov_ident_predicates_and_encoding_names() { + assert is_ident_start(`a`), 'letter starts an identifier' + assert is_ident_start(`_`), 'underscore starts an identifier' + assert !is_ident_start(`1`), 'digit cannot start an identifier' + assert is_ident_char(`1`), 'digit continues an identifier' + assert !is_ident_char(`-`), 'dash is not an identifier char' + assert position_encoding_string(.utf16) == 'utf-16', 'utf16 wire name' + assert position_encoding_string(.utf8) == 'utf-8', 'utf8 wire name' + assert position_encoding_string(.utf32) == 'utf-32', 'utf32 wire name' +} + +fn test_cov_line_offsets_and_position_mapping() { + assert line_start_offsets('a\nb\nc') == [0, 2, 4], 'lf offsets' + assert line_start_offsets('a\r\nb') == [0, 3], 'crlf offsets' + assert line_start_offsets('a\rb') == [0, 2], 'cr offsets' + starts := line_start_offsets('ab\ncd') + assert line_text_without_terminator('ab\ncd', starts, 0) == 'ab', 'first line text' + assert line_text_without_terminator('ab\ncd', starts, 1) == 'cd', 'last line text' + assert position_to_byte_offset('ab\ncd', starts, 1, 1, .utf8) == 4, 'second line second byte' + assert position_to_byte_offset('ab\ncd', starts, 9, 0, .utf8) == 5, 'past last line clamps' +} + +fn test_cov_find_word_bounds_and_substr() { + start, end := find_word_bounds_at_col('fn main()', 4, .utf8) + assert start == 3 && end == 7, 'word bounds of main' + after_start, after_end := find_word_bounds_at_col('fn main()', 7, .utf8) + assert after_start == 3 && after_end == 7, 'column just past the word still resolves' + bad_start, bad_end := find_word_bounds_at_col('f (', 2, .utf8) + assert bad_start == -1 && bad_end == -1, 'no identifier at column' + assert substr_by_char_bounds('fn main()', 3, 7, .utf8) == 'main', 'substr of bounds' +} + +fn test_cov_parse_import_binding() { + if b := parse_import_binding('os') { + assert b.alias == 'os', 'plain import aliases itself' + assert b.module_path == 'os', 'plain import keeps path' + } else { + assert false, 'os should parse' + } + if b := parse_import_binding('net.http as nh') { + assert b.alias == 'nh', 'as alias wins' + assert b.module_path == 'net.http', 'as keeps full path' + } else { + assert false, 'aliased import should parse' + } + if _ := parse_import_binding('') { + assert false, 'empty text should not parse' + } + bindings := parse_import_bindings('module main\n\nimport os\nimport net.http as nh\n') + assert bindings.map(it.alias) == ['os', 'nh'], 'single line bindings' +} + +fn test_cov_get_word_before_dot() { + assert get_word_before_dot('os.args', 2, .utf8) == 'os', 'receiver before dot' + assert get_word_before_dot('os.args', 0, .utf8) == '', 'no dot at column' + assert get_word_before_dot('', 0, .utf8) == '', 'empty line' + assert get_word_before_dot('.foo', 0, .utf8) == '', 'nothing before dot' +} + +fn test_cov_normalize_receiver_type() { + assert normalize_receiver_type('&User') == 'User', 'reference stripped' + assert normalize_receiver_type('?Foo') == 'Foo', 'option stripped' + assert normalize_receiver_type('&User[T]') == 'User', 'generics cut' + assert normalize_receiver_type(' string ') == 'string', 'space trimmed' +} + +fn test_cov_expression_names_its_type() { + assert expression_names_its_type('[]int{}'), 'array literal names its type' + assert expression_names_its_type('User{name: 1}'), 'struct literal names its type' + assert !expression_names_its_type('foo()'), 'call does not' + assert !expression_names_its_type('x'), 'plain variable does not' +} + +fn test_cov_callable_or_constructor() { + name, is_ctor := callable_or_constructor('foo()') + assert name == 'foo' && !is_ctor, 'plain call' + tname, tis_ctor := callable_or_constructor('User{}') + assert tname == 'User' && tis_ctor, 'struct literal is a constructor' + ename, ector := callable_or_constructor(' ') + assert ename == '' && !ector, 'blank has no callable' + assert source_fragment_starts_with_literal('"hi"'), 'double quoted literal' + assert source_fragment_starts_with_literal('r"raw"'), 'raw string literal' + assert !source_fragment_starts_with_literal('foo'), 'identifier is not a literal' + assert !source_fragment_starts_with_literal(''), 'empty is not a literal' +} + +fn test_cov_binding_and_local_names() { + assert binding_identifiers('foo bar') == ['foo', 'bar'], 'words are bindings' + assert binding_identifiers('mut x') == ['x'], 'mut is skipped' + assert local_declaration_names('x := 1; y := 2') == ['x', 'y'], 'walrus declarations' + assert local_declaration_names('for i in xs') == ['i'], 'for binding' + assert starts_or_block_header('or'), 'bare or header' + assert starts_or_block_header('x or'), 'trailing or header' + assert !starts_or_block_header('for'), 'for is not an or header' + pos := Position{ + line: 3 + char: 1 + } + assert containing_function_start(['module main', '', 'fn main() {', '\tx := 1', '}'], pos, .utf8) == 2, 'enclosing fn' + assert identifier_index('foo bar foo', 'foo') == 0, 'first occurrence' + assert identifier_index('xfoo foo', 'foo') == 5, 'identifier boundary respected' + assert identifier_index('abc', 'z') == -1, 'absent name' + params := parameter_list_of('fn foo(a int, b string) int') or { '' } + assert params == 'a int, b string', 'parameter list text' + if _ := parameter_list_of('no parens') { + assert false, 'parameter list of plain text should be none' + } + assert last_fn_keyword_before('x := fn (a int) int {', 20) == 5, 'closure fn found' + assert last_fn_keyword_before('x := 1', 6) == -1, 'no closure fn' +} + +// Coverage for folding_range.v. + +fn test_cov_line_brace_delta() { + assert line_brace_delta('fn main() {') == 1, 'open brace' + assert line_brace_delta('}') == -1, 'close brace' + assert line_brace_delta('}{') == 0, 'balanced on one line' + assert line_brace_delta('s := "{ not code"') == 0, 'brace in string skipped' + assert line_brace_delta('a := 1 // }') == 0, 'brace in comment skipped' + assert line_brace_delta('/* { */') == 0, 'brace in block comment skipped' +} + +fn test_cov_compute_folding_ranges_groups_blocks() { + content := 'module main\n\nimport os\nimport time\n\n/*\nblock\ncomment\n*/\nfn main() {\n\tprintln(1)\n}\n' + ranges := compute_folding_ranges(content) + assert ranges.any(it.kind == 'imports' && it.start_line == 2 && it.end_line == 3), 'import group' + assert ranges.any(it.kind == 'comment' && it.start_line == 5 && it.end_line == 8), 'block comment' + assert ranges.any(it.kind == 'region' && it.start_line == 9 && it.end_line == 11), 'fn body' + single := compute_folding_ranges('module main\n\nimport os\n\nfn main() {}\n') + assert !single.any(it.kind == 'imports'), 'one import is not a group' +} + +fn test_cov_find_fn_body_end_tracks_braces() { + assert find_fn_body_end(['fn main() {', '\tx := 1', '}'], 0) == 2, 'closing brace line' + assert find_fn_body_end(['fn main() {', '\tx := 1'], 0) == 1, 'falls back to last line' +} + +fn test_cov_folding_range_empty_doc_returns_no_ranges() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_folding_empty.v' + app.open_files[uri] = '' + response := app.handle_folding_range(Request{ + id: 910 + method: 'textDocument/foldingRange' + params: json2.encode(FoldingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + }, + escape_unicode: true + ) + }) + assert response.id == 910, 'id echoes' + assert response.result is []FoldingRange, 'empty doc answers an empty list' + assert (response.result as []FoldingRange).len == 0, 'no ranges for empty doc' +} + +// Coverage for document_highlight.v. + +fn test_cov_highlight_has_word_boundaries() { + assert highlight_has_word('foo bar foo', 'foo'), 'plain word found' + assert highlight_has_word('(foo)', 'foo'), 'punctuation boundaries count' + assert !highlight_has_word('foobar', 'foo'), 'prefix of longer name rejected' + assert !highlight_has_word('foo2', 'foo'), 'digit suffix rejected' + assert !highlight_has_word('', 'foo'), 'empty text has no word' + assert !highlight_has_word('foo', ''), 'empty word never matches' +} + +fn test_cov_is_for_binding_highlight() { + assert is_for_binding_highlight('for x in xs {', 4, 5), 'for value binding' + assert is_for_binding_highlight('for k, v in m {', 7, 8), 'for key-value binding' + assert !is_for_binding_highlight('x := 1', 0, 1), 'plain assignment is not a for binding' + assert !is_for_binding_highlight('format(x) in y', 0, 6), 'for inside call rejected' +} + +fn test_cov_is_fn_parameter_highlight() { + assert is_fn_parameter_highlight('fn foo(x int)', 7, 8), 'parameter followed by type' + assert is_fn_parameter_highlight('fn (u User) save()', 4, 5), 'receiver followed by type' + assert !is_fn_parameter_highlight('x := 1', 0, 1), 'assignment is not a parameter' + assert !is_fn_parameter_highlight('foo(x)', 4, 5), 'call argument is not a parameter' +} + +// Coverage for semantic_tokens.v. + +fn test_cov_semantic_token_modifiers_legend() { + assert semantic_token_modifiers() == ['declaration', 'readonly'], 'modifier legend' +} + +fn test_cov_variable_binding_key_and_mark() { + assert variable_binding_key(1, 'x') == '1:x', 'scope qualified key' + mut readonly := map[string]bool{} + mut mutable := map[string]bool{} + mark_variable_binding('x', false, 1, mut readonly, mut mutable) + assert readonly['1:x'], 'immutable binding is readonly' + mark_variable_binding('y', true, 1, mut readonly, mut mutable) + assert mutable['1:y'], 'mut binding is mutable' + mark_variable_binding('_', false, 1, mut readonly, mut mutable) + mark_variable_binding('', false, 1, mut readonly, mut mutable) + assert readonly.len == 1, 'blank names are skipped' + collect_fn_parameter_bindings('fn foo(a int, mut b string)', 2, mut readonly, mut mutable) + assert readonly['2:a'], 'parameter is readonly' + assert mutable['2:b'], 'mut parameter is mutable' +} + +fn test_cov_collect_readonly_variables() { + readonly, scopes := collect_readonly_variables(['const x = 1', 'fn main() {', '\ty := 2', '}']) + assert readonly['0:x'], 'const is readonly' + assert readonly['1:y'], 'immutable local is readonly' + assert scopes.len == 4, 'one scope per line' + assert scopes[0] == 0, 'file level scope' + assert scopes[1] == 1, 'function scope' +} + +fn test_cov_classify_identifier_and_dot_helpers() { + assert identifier_before_dot('user.name', 4) == 'user', 'receiver before dot' + assert identifier_before_dot('abc', 1) == '', 'no dot at column' + assert is_method_declaration_identifier('fn (u User) save()', 12), 'method name after receiver' + assert !is_method_declaration_identifier('fn (u User) save()', 0), 'fn keyword is not the method' + assert !is_method_declaration_identifier('fn main()', 3), 'plain fn has no receiver' + aliases := map[string]bool{} + assert classify_v_identifier_at('fn main() {}', 0, 2, 'fn', aliases) == sem_tok_keyword, 'keyword' + assert classify_v_identifier_at('x := User{}', 5, 9, 'User', aliases) == sem_tok_type, 'uppercase type' + // Off-by-one evidence: 'zzz_fn_qq'.len == 9 so end is 9 (line[9] == `(`, + // line[8] == `q`), and 'zzz_save_qq'.len == 11 so end is 5 + 11 == 16. + assert classify_v_identifier_at('zzz_fn_qq(x)', 0, 9, 'zzz_fn_qq', aliases) == sem_tok_function, 'call' + assert classify_v_identifier_at('user.zzz_save_qq()', 5, 16, 'zzz_save_qq', aliases) == sem_tok_method, 'method call' + assert classify_v_identifier_at('user.zzz_prop_qq', 5, 16, 'zzz_prop_qq', aliases) == sem_tok_property, 'property' + assert classify_v_identifier_at('import os', 7, 9, 'os', aliases) == sem_tok_namespace, 'import name' + aliased := { + 'nh': true + } + assert classify_v_identifier_at('nh.get()', 0, 2, 'nh', aliased) == sem_tok_namespace, 'import alias' +} + +fn test_cov_interpolation_end_and_string_tokens() { + line := 'x := "\${a}!"' + assert interpolation_end(line, 7, line.len) == 9, 'closing brace of interpolation' + assert interpolation_end('x \${a', 3, 5) == -1, 'unclosed interpolation' + mut toks := []SemToken{} + mut str_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + assert tokenize_v_string('"hi"', 0, 4, 0, mut str_state, mut toks) == 4, 'column after closing quote' + assert toks.len == 1 && toks[0].type_idx == sem_tok_string, 'whole literal is one string token' + mut interp_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut interp_toks := []SemToken{} + tokenize_v_string('"\${x}!"', 0, 7, 0, mut interp_state, mut interp_toks) + assert interp_toks.len == 3, 'string, code and string parts' + assert interp_toks[0].type_idx == sem_tok_string, 'opening quote is string' + assert interp_toks[1].type_idx == sem_tok_variable, 'interpolated name is code' + assert interp_toks[2].type_idx == sem_tok_string, 'tail is string' + before := interp_toks.len + append_string_token(mut interp_toks, 0, 5, 5) + assert interp_toks.len == before, 'empty span appends nothing' +} + +fn test_cov_tokenize_line_and_encode() { + mut state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut toks := []SemToken{} + tokenize_v_line('// hi', 3, mut state, mut toks) + assert toks.len == 1, 'line comment is one token' + assert toks[0].type_idx == sem_tok_comment, 'comment type' + assert toks[0].line == 3 && toks[0].start == 0 && toks[0].length == 5, 'comment span' + mut code_toks := []SemToken{} + tokenize_v_code('a := 1 // done', 0, 14, 0, mut state, mut code_toks) + assert code_toks.any(it.type_idx == sem_tok_number), 'number tokenised' + assert code_toks.any(it.type_idx == sem_tok_comment), 'trailing comment tokenised' + mut block_state := TokenizeState{ + import_aliases: map[string]bool{} + readonly_variables: map[string]bool{} + } + mut block_toks := []SemToken{} + tokenize_v_line('/* open', 0, mut block_state, mut block_toks) + assert block_state.in_block_comment, 'unclosed block comment carries over' + tokenize_v_line('still comment */ x := 1', 1, mut block_state, mut block_toks) + assert !block_state.in_block_comment, 'closing marker ends the comment' + raw := [SemToken{ line: 0, start: 0, length: 3, type_idx: sem_tok_keyword }, + SemToken{ line: 0, start: 5, length: 2, type_idx: sem_tok_function }] + assert encode_semantic_tokens(raw) == [0, 0, 3, 0, 0, 0, 5, 2, 5, 0], 'delta encoding' + utf8_same := convert_tokens_to_encoding(raw, ['abcdefgh'], .utf8) + assert utf8_same[1].start == 5 && utf8_same[1].length == 2, 'utf8 keeps byte offsets' + utf16_same := convert_tokens_to_encoding(raw, ['abcdefgh'], .utf16) + assert utf16_same[1].start == 5 && utf16_same[1].length == 2, 'ascii is identical in utf16' + oor := convert_tokens_to_encoding(raw, ['ab'], .utf16) + assert oor.len == 2, 'out of range lines pass through' +} + +// Coverage for fast_diagnostics.v. + +fn test_cov_fast_import_is_skipped() { + assert fast_import_is_skipped(''), 'empty path skipped' + assert fast_import_is_skipped('C'), 'C interop skipped' + assert fast_import_is_skipped('foo(bar'), 'grouping skipped' + assert fast_import_is_skipped('a"b'), 'quoting skipped' + assert !fast_import_is_skipped('os'), 'plain module checked' + assert !fast_import_is_skipped('net.http'), 'dotted module checked' +} + +fn test_cov_fast_last_non_empty_and_continuation() { + assert fast_last_non_empty(['a', '', ' ']) == 0, 'last text line' + assert fast_last_non_empty(['', ' ']) == -1, 'no text lines' + assert fast_last_non_empty([]string{}) == -1, 'no lines' + open_state := FastParseState{ + stack: [FastParseBracket{ ch: `{`, line: 0, col: 10 }] + } + assert fast_last_line_is_continuation(['fn main() {'], 0, &open_state), 'unclosed opener on last line' + empty_state := FastParseState{} + assert !fast_last_line_is_continuation(['x := 1'], 0, &empty_state), 'complete statement' + assert fast_last_line_is_continuation(['x :='], 0, &empty_state), 'operator tail continues' +} + +fn test_cov_fast_scan_code_flags_stray_closer() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut state := FastParseState{} + mut errors := []JsonError{} + end := fast_scan_code(')', 0, 0, 'cov.v', mut state, mut errors) + assert end == 1, 'scanner consumed the closer' + assert errors.len == 1, 'stray closer reported' + assert errors[0].message == "unexpected token ')'", 'stray message' + mut lit_state := FastParseState{} + mut lit_errors := []JsonError{} + after := fast_scan_literal('"abc" + x', 0, 0, 'cov.v', mut lit_state, mut lit_errors) + assert after == 5, 'column after closing quote' + assert lit_state.quote == 0, 'literal closed' + assert lit_errors.len == 0, 'balanced literal reports nothing' +} + +fn test_cov_fast_module_dir_declares() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mod_dir := os.join_path(app.temp_dir, 'covmod', 'mymod') + must_mkdir_all(mod_dir) + must_write_file(os.join_path(mod_dir, 'mymod.v'), 'module mymod\n\npub fn hi() {}\n') + assert fast_module_dir_declares(mod_dir, 'mymod'), 'folder declaring its own name' + assert !fast_module_dir_declares(mod_dir, 'other'), 'wrong name rejected' + assert !fast_module_dir_declares(mod_dir, 'main'), 'main never counts' + assert !fast_module_dir_declares(os.join_path(app.temp_dir, 'covmod', 'missing'), 'mymod'), 'missing dir' +} + +fn test_cov_run_fast_diagnostics_job_handles_clean_buffer() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + mut scheduler := new_diagnostics_scheduler() + uri := 'file:///cov_fast_job.v' + content := 'module main\n\nfn main() {}\n' + stale := DiagnosticsJob{ + uri: uri + content: content + global_generation: 1 + generation: 999 + ready_at: 100 + kind: .fast + write_mutex: app.write_mutex + } + assert !scheduler.is_job_current(stale), 'unknown generation is not current' + global_gen, gen := scheduler.next_generation(uri) + job := DiagnosticsJob{ + uri: uri + content: content + open_files: { + uri: content + } + global_generation: global_gen + generation: gen + ready_at: 100 + kind: .fast + write_mutex: app.write_mutex + } + assert scheduler.enqueue(job), 'clean fast job enqueued' + assert scheduler.is_job_current(job), 'enqueued job is current' + run_fast_diagnostics_job(mut scheduler, job) + assert scheduler.is_job_current(job), 'clean buffer publishes nothing and stays current' +} + +// Coverage for call_hierarchy.v. + +fn test_cov_find_fn_calls_in_line_collects_callees() { + mut calls := map[string][]LSPRange{} + find_fn_calls_in_line('helper(x) + other()', 2, .utf8, mut calls) + assert 'helper' in calls, 'first call found' + assert 'other' in calls, 'second call found' + assert calls['helper'][0].start.char == 0, 'call starts at line start' + assert calls['helper'][0].end.char == 6, 'call ends after the name' + mut skipped := map[string][]LSPRange{} + find_fn_calls_in_line('if (x) {}', 0, .utf8, mut skipped) + assert skipped.len == 0, 'keywords are not calls' + mut quiet := map[string][]LSPRange{} + find_fn_calls_in_line('"foo(" + x // bar(', 1, .utf8, mut quiet) + assert quiet.len == 0, 'strings and comments hold no calls' +} + +fn test_cov_find_fn_in_content_resolves_item() { + uri := 'file:///cov_find_fn.v' + content := 'module main\n\nfn helper() {}\n\nfn main() {}\n' + item := find_fn_in_content('helper', content, uri, .utf8) + assert item.name == 'helper', 'helper resolved' + assert item.uri == uri, 'uri carried over' + missing := find_fn_in_content('nosuchfn', content, uri, .utf8) + assert missing.name == '', 'unknown name resolves to nothing' +} + +fn test_cov_scan_for_callers_groups_by_caller() { + mut results := []CallHierarchyIncomingCall{} + scan_for_callers('helper', 'file:///cov_scan.v', 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n', .utf8, mut results) + assert results.any(it.from.name == 'main' && it.from_ranges.len == 1), 'main calls helper once' + mut no_callers := []CallHierarchyIncomingCall{} + scan_for_callers('nosuchfn', 'file:///cov_scan.v', 'module main\n\nfn main() {}\n', .utf8, mut no_callers) + assert no_callers.len == 0, 'no callers without calls' +} + +fn test_cov_find_fn_declaration_prefers_project() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'cov_decl') + must_mkdir_all(root) + path := os.join_path(root, 'main.v') + content := 'module main\n\nfn helper() int {\n\treturn 1\n}\n\nfn main() {\n\tprintln(helper())\n}\n' + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.workspace_roots = [root] + item := app.find_fn_declaration('helper', app.workspace_search_dirs(root), false) + assert item.name == 'helper', 'project function found through the index' + assert item.uri == uri, 'index points at the project file' +} + +fn test_cov_call_hierarchy_outgoing_skips_recursion() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'cov_outgoing') + must_mkdir_all(root) + path := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {\n\tmain()\n}\n' + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.workspace_roots = [root] + resp := app.handle_call_hierarchy_outgoing(Request{ + id: 911 + method: 'callHierarchy/outgoingCalls' + params: json2.encode(CallHierarchyOutgoingCallsParams{ + item: CallHierarchyItem{ + name: 'main' + kind: sym_kind_function + uri: uri + range: LSPRange{ + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 4 + char: 1 + } + } + selection_range: LSPRange{ + start: Position{ + line: 2 + char: 3 + } + end: Position{ + line: 2 + char: 7 + } + } + } + }, + escape_unicode: true + ) + }) + assert resp.id == 911, 'id echoes' + assert resp.result is []CallHierarchyOutgoingCall, 'outgoing answers a list' + assert (resp.result as []CallHierarchyOutgoingCall).len == 0, 'direct recursion is skipped' +} + +// Coverage for pure handlers.v helpers: declarations and signatures. + +fn test_cov_source_declaration_opens_body() { + assert source_declaration_opens_body('fn main(){}'.bytes()), 'fn opens a body' + assert source_declaration_opens_body('pub unsafe fn foo() {}'.bytes()), 'modifiers are skipped' + assert source_declaration_opens_body('struct Foo {}'.bytes()), 'struct opens a body' + assert source_declaration_opens_body('@[if windows]\nfn foo() {}'.bytes()), 'attributes are skipped' + assert !source_declaration_opens_body('x := 1'.bytes()), 'assignment opens nothing' + assert !source_declaration_opens_body(''.bytes()), 'empty mask opens nothing' +} + +fn test_cov_declares_function_at() { + assert declares_function_at(['fn main() {}'], 0, 3), 'plain function' + assert declares_function_at(['pub fn foo() {}'], 0, 7), 'pub function' + assert declares_function_at(['fn foo[T](x T) int {}'], 0, 3), 'generic parameters are skipped' + assert !declares_function_at(['x := 1'], 0, 0), 'assignment is not a function' + assert !declares_function_at(['fn main() {}'], 0, 10), 'whitespace starts nothing' + assert !declares_function_at(['foo()'], 0, 0), 'bare call is not a declaration' +} + +fn test_cov_declaration_expression_span() { + text, end_line := declaration_expression_span(['a := [1,', '2]'], 0, '[1,') + assert text == '[1, 2]', 'continued expression is joined' + assert end_line == 1, 'expression ends on the second line' + assert declaration_expression(['x := 1'], 0, '1') == '1', 'single line wrapper' + single, single_line := declaration_expression_span(['x := 1'], 0, '1') + assert single == '1' && single_line == 0, 'balanced expression stays put' +} + +fn test_cov_cursor_is_inside_parameters() { + assert cursor_is_inside_parameters('fn foo(a int)', 7, 'fn foo(a int)', 0), 'inside inline parameters' + assert !cursor_is_inside_parameters('fn foo(a int)', 0, 'fn foo(a int)', 0), 'name is outside parameters' + assert cursor_is_inside_parameters('fn foo(a int', 5, '', -1), 'open signature continues' + assert !cursor_is_inside_parameters('fn foo() {', 10, '', -1), 'body is past the parameters' + assert !cursor_is_inside_parameters('x', 0, 'zzz', 0), 'missing parens answer false' +} + +fn test_cov_type_text_names() { + assert type_text_names('int x', 'int'), 'whole word matches' + assert type_text_names('x int.', 'int'), 'punctuation bounds the word' + assert !type_text_names('print(x)', 'int'), 'absent name never matches' + assert !type_text_names('aint b', 'int'), 'identifier prefix is not a match' +} + +fn test_cov_merge_completion_items() { + a := Detail{ label: 'a' } + b := Detail{ label: 'b' } + c := Detail{ label: 'c' } + merged := merge_completion_items([a, b], [b, c]) + assert merged.map(it.label) == ['a', 'b', 'c'], 'compiler items fill the gaps' + assert merge_completion_items([]Detail{}, []Detail{}).len == 0, 'empty merges to empty' + assert without_operator_completions([Detail{ label: '+' }, Detail{ label: 'foo' }]).map(it.label) == [ + 'foo', + ], 'operators are dropped' +} + +fn test_cov_binding_scope_headers() { + assert starts_binding_scope_header('for x in xs {'), 'for opens a scope' + assert starts_binding_scope_header('\$for x in y {'), 'comptime for opens a scope' + assert starts_binding_scope_header('if x == 1 {'), 'if opens a scope' + assert !starts_binding_scope_header('x := 1'), 'assignment opens no scope' + assert binding_scope_header_ends_with_literal_type('if e := Foo'), 'trailing type ends the header' + assert !binding_scope_header_ends_with_literal_type('if x is int'), 'smart cast is not a literal' + assert !binding_scope_header_ends_with_literal_type('x := 1'), 'non type is not a literal' +} + +fn test_cov_fn_keyword_and_match_helpers() { + assert last_fn_keyword_index('a := fn (x int) int {') == 5, 'last fn keyword' + assert last_fn_keyword_index('x := 1') == -1, 'no fn keyword' + assert last_fn_keyword_index('afn') == -1, 'identifier tail is not a keyword' + header := anonymous_function_header('fn (x int) string') + assert header.found && header.complete, 'literal header parses' + assert header.parameter_names == ['x'], 'parameter name kept' + assert header.parameter_types['x'] == 'int', 'parameter type kept' + bare := anonymous_function_header('fn') + assert bare.found && !bare.complete, 'bare fn is incomplete' + assert !anonymous_function_header('zzz').found, 'no fn means no header' + assert last_match_keyword_index('x := match y {') == 5, 'match keyword found' + assert last_match_keyword_index('rematch') == -1, 'identifier is not match' + assert brace_starts_match_body('match x {', 8), 'brace after subject starts the body' + assert !brace_starts_match_body('foo() {', 6), 'call brace starts no match body' + assert previous_unmatched_open_brace('a { b { c', 9) == 6, 'inner brace is unmatched' + assert previous_unmatched_open_brace('a { b } c', 9) == -1, 'balanced braces match' + assert struct_literal_brace_is_match_arm('match x {\n1: {', 13), 'arm brace nests in match' + assert !struct_literal_brace_is_match_arm('x := User{', 9), 'struct brace is no arm' +} + +fn test_cov_expression_line_continued() { + assert expression_line_is_continued('x := 1 +'), 'operator tail continues' + assert !expression_line_is_continued('x := 1'), 'complete statement stops' + assert !expression_line_is_continued(''), 'empty line stops' +} + +fn test_cov_implicit_bindings() { + // open_paren is the index of the `(` itself: in 'xs.map(' it sits at 6. + assert implicit_call_bindings('xs.map(', 6) == ['it'], 'map binds it' + assert implicit_call_bindings('foo(', 4).len == 0, 'plain call binds nothing' + assert implicit_bindings_at_cursor('xs.map(a') == ['it'], 'open map call binds it' + assert implicit_bindings_at_cursor('foo(a').len == 0, 'plain call binds nothing' +} + +fn test_cov_local_binding_column() { + assert local_binding_column(' x int', 10, 'x') == 12, 'column is segment relative' + assert local_binding_column('yyy', 0, 'x') == -1, 'missing name is -1' +} + +fn test_cov_type_after_identifier() { + assert type_after_identifier('x int', 'x') == 'int', 'type follows the name' + assert type_after_identifier('x := 1', 'x') == '', 'assignment has no type' + assert complete_function_signature(['fn foo(a int,', 'b string) string {'], 0, + 'fn foo(a int,') == 'fn foo(a int, b string) string {', 'signature joins lines' + assert complete_function_signature([]string{}, 5, 'x') == 'x', 'out of range keeps initial' + assert method_receiver_type('(mut u User)') == 'User', 'receiver type extracted' + assert method_receiver_type('foo') == '', 'plain name has no receiver' + assert method_receiver_type('(u)') == '', 'bare variable is no type' +} + +fn test_cov_builtin_composite_owner() { + assert builtin_composite_owner('[]int') == 'array', 'array owner' + assert builtin_composite_owner('map[string]int') == 'map', 'map owner' + assert builtin_composite_owner('?[]int') == 'array', 'option prefix is stripped' + assert builtin_composite_owner('int') == '', 'scalar has no owner' + assert qualify_embedded_receiver_type('mod.Parent', 'Child') == 'mod.Child', 'child takes parent module' + assert qualify_embedded_receiver_type('P', 'mod.Child') == 'mod.Child', 'qualified type is kept' + assert struct_field_source_type('name string', 'name') == 'string', 'field type read' + assert struct_field_source_type('other int', 'name') == '', 'wrong name reads nothing' + assert embedded_struct_type('Address') == 'Address', 'bare embedded type' + assert embedded_struct_type('x int') == '', 'field line is no embedding' +} + +fn test_cov_thread_channel_and_literal_types() { + assert thread_type_for_return('') == 'thread', 'empty returns bare thread' + assert thread_type_for_return('void') == 'thread', 'void returns bare thread' + assert thread_type_for_return('int') == 'thread int', 'payload is kept' + assert fn_literal_return_type('fn (x int) string {') == 'string', 'literal return read' + assert fn_literal_return_type('fn () {') == '', 'no return is empty' + assert fn_literal_return_type('zzz') == '', 'no parens is empty' + assert (thread_array_literal_type('[]thread int{len: 2}') or { '' }) == '[]thread int', 'thread array kept' + assert (thread_array_literal_type('[]int{}') or { 'none' }) == 'none', 'plain array rejected' + wait_item := thread_wait_completion('thread int') or { Detail{} } + assert wait_item.label == 'wait', 'wait offered' + assert wait_item.detail == 'fn (t thread int) wait() int', 'wait signature typed' + assert (thread_wait_completion('int') or { Detail{ label: 'none' } }).label == 'none', 'non thread rejected' + assert (channel_literal_type('chan int{cap: 1}') or { '' }) == 'chan int', 'chan type kept' + assert (channel_literal_type('int') or { 'none' }) == 'none', 'non chan rejected' + assert (receiver_literal_type('true') or { '' }) == 'bool', 'bool literal' + assert (receiver_literal_type('5') or { '' }) == 'int', 'int literal' + assert (receiver_literal_type('1.5') or { '' }) == 'f64', 'float literal' + assert (receiver_literal_type("'hi'") or { '' }) == 'string', 'string literal' + assert (receiver_literal_type('`a`') or { '' }) == 'rune', 'rune literal' + assert (receiver_literal_type("'a' + b") or { 'none' }) == 'none', 'expression rejected' +} + +fn test_cov_selector_match_and_array_helpers() { + assert trailing_selector('if p.color') == 'p.color', 'chain at the end' + assert trailing_selector('x') == 'x', 'bare name' + assert trailing_selector('') == '', 'empty has no selector' + assert (enclosing_match_subject(['match x {', '\t1: 1'], 1) or { '' }) == 'x', 'match subject found' + assert (enclosing_match_subject(['fn main() {', '}'], 1) or { 'none' }) == 'none', 'no match above' + assert (array_literal_type('[1, 2]') or { '' }) == '[]int', 'inferred element type' + assert (array_literal_type('[]string{}') or { '' }) == '[]string', 'written element type' + assert (array_literal_type('x') or { 'none' }) == 'none', 'non array rejected' + assert struct_literal_type_before(['x := User{'], 1, '') == 'User', 'literal type before brace' + assert struct_literal_type_before([]string{}, 0, '') == '', 'no brace means no type' +} + +fn test_cov_parameter_parts() { + mod1, name1, typ1 := parameter_parts('mut buf []u8') + assert mod1 == 'mut ' && name1 == 'buf' && typ1 == '[]u8', 'mut parameter splits' + mod2, name2, typ2 := parameter_parts('c chan Color') + assert mod2 == '' && name2 == 'c' && typ2 == 'chan Color', 'chan parameter splits' + _, whole_name, whole_type := parameter_parts('thread int') + assert whole_name == '' && whole_type == 'thread int', 'whole type has no name' +} + +fn test_cov_module_completion_helpers() { + assert module_type_completion_name('Foo[T]') == 'Foo', 'generics stripped' + assert module_type_completion_name('bar') == 'bar', 'plain name kept' + assert module_completion_declaration('pub fn foo()', false), 'public function completes' + assert !module_completion_declaration('fn (u User) save()', false), 'method is no module item' + assert module_completion_declaration('struct Foo', false), 'struct completes' + assert !module_completion_declaration('x := 1', false), 'assignment is no declaration' + assert !module_completion_declaration('fn foo()', true), 'private hidden when public only' + assert const_block_assignment_name('MAX = 100') == 'MAX', 'const name read' + assert const_block_assignment_name('x := 1') == '', 'short declaration has no const name' + assert const_block_assignment_name('f(a = 1)') == '', 'nested assignment ignored' + assert update_expression_delimiter_depth('a(b', 0) == 1, 'opener deepens' + assert update_expression_delimiter_depth('a)', 1) == 0, 'closer shallows' + assert update_expression_delimiter_depth('x', 0) == 0, 'plain line keeps depth' + assert resolve_import_module_dir('definitely_not_a_module_xyz', '') == '', 'unknown module resolves nowhere' +} + +fn test_cov_source_definition_support() { + assert source_definition_kind_is_supported(sym_kind_function), 'function supported' + assert source_definition_kind_is_supported(sym_kind_struct), 'struct supported' + assert !source_definition_kind_is_supported(999), 'unknown kind unsupported' + sym := make_symbol('foo', sym_kind_function, 0, 'fn foo() {}') + assert sym.selection_range.start.char == 3, 'selection starts at the name' + assert sym.selection_range.end.char == 6, 'selection ends after the name' + occ := [TokenOccurrence{ line: 0, start_char: 3, end_char: 6 }] + assert source_declaration_occurrence_is_code(sym, occ), 'matching occurrence is code' + assert !source_declaration_occurrence_is_code(sym, []TokenOccurrence{}), 'no occurrence is no code' + assert source_attribute_content_is_conditional('if debug'), 'if attribute is conditional' + assert !source_attribute_content_is_conditional('x'), 'plain attribute is not' +} + +fn test_cov_source_occurrence_suffix_helpers() { + assert source_occurrence_has_colon_suffix('x: int', 1), 'colon suffix' + assert !source_occurrence_has_colon_suffix('x', 1), 'no colon suffix' + assert source_occurrence_has_dot_suffix('x.', 1), 'dot suffix' + assert !source_occurrence_has_dot_suffix('x', 1), 'no dot suffix' + assert source_occurrence_is_goto_target('goto label', 5), 'goto names its label' + assert !source_occurrence_is_goto_target('foo', 0), 'plain word is no goto' + assert source_line_is_module_or_import_declaration(['module main'], 0), 'module line' + assert source_line_is_module_or_import_declaration(['import os'], 0), 'import line' + assert source_line_is_module_or_import_declaration(['import (', 'os', ')'], 1), 'import block line' + assert !source_line_is_module_or_import_declaration(['x := 1'], 0), 'code is neither' + assert source_occurrence_has_compile_time_prefix('\$if x', 1), 'dollar prefix' + assert source_occurrence_has_compile_time_prefix('@[if x]', 1), 'attribute prefix' + assert !source_occurrence_has_compile_time_prefix('x', 0), 'plain start has no prefix' + assert source_line_is_hash_directive(['#flag x'], 0), 'hash directive' + assert !source_line_is_hash_directive(['x'], 0), 'code is no directive' + assert source_occurrence_is_asm_block(['asm {', 'mov'], 1, 3), 'inside asm block' + assert !source_occurrence_is_asm_block(['x := 1'], 0, 1), 'plain code is no asm' + assert source_occurrence_is_sql_expression(['sql db {', 'select'], 1, 2), 'inside sql block' + assert !source_occurrence_is_sql_expression(['x := 1'], 0, 1), 'plain code is no sql' + assert source_occurrence_is_interface_method_signature(['interface Foo {', '\tfoo()'], 1, + 1, 4), 'method inside interface' + assert !source_occurrence_is_interface_method_signature(['fn foo()'], 0, 3, 6), 'plain fn is no method' + assert source_occurrence_is_enum_member_declaration('module m\n\nenum Color {\n\tred\n}\n', + 'red', 3), 'enum member found' + assert !source_occurrence_is_enum_member_declaration('module m\n\nfn foo() {}\n', 'red', + 1), 'missing member rejected' + assert source_occurrence_is_generic_parameter(['fn foo[T](x T) {}'], 0, 7, 8), 'generic parameter' + assert !source_occurrence_is_generic_parameter(['x := 1'], 0, 0, 1), 'plain code has none' +} + +fn test_cov_name_needs_other_defines() { + assert name_needs_other_defines('foo_d_linux.c.v'), 'unknown define needed' + assert !name_needs_other_defines('foo_d_v3_backend.c.v'), 'own define needs nothing' + assert !name_needs_other_defines('plain.v'), 'plain file needs nothing' + assert name_needs_other_defines('x_notd_v3_backend.c.v'), 'negated own define needed' +} + +fn test_cov_doc_comment_helpers() { + assert doc_comment_markdown(['hello']) == 'hello', 'single line' + assert doc_comment_markdown(['a', 'b']) == 'a \nb', 'lines break hard' + assert doc_comment_markdown(['Example: foo()']) == 'Example: \n```v\nfoo()\n```', 'example fenced' + assert (inline_doc_example('Example: foo()') or { '' }) == 'foo()', 'example code read' + assert (inline_doc_example('hello') or { 'none' }) == 'none', 'plain line is no example' + assert (inline_doc_example('Example: ') or { 'none' }) == 'none', 'empty example rejected' +} + +fn test_cov_parse_fn_signatures_into() { + mut index := map[string]string{} + content := 'pub fn greet(name string) string {\n}\nfn (u User) save() int {\n}\nfn multi() (int, int) {\n}\nfn novoid() {\n}\n' + parse_fn_signatures_into(content, 'mymod', mut index) + assert index['greet'] == 'string', 'return type indexed' + assert index['mymod.greet'] == 'string', 'module qualified entry' + assert !('save' in index), 'methods skipped' + assert !('multi' in index), 'multi return skipped' + assert !('novoid' in index), 'void skipped' +} + +fn test_cov_make_symbol_variants() { + method_sym := make_symbol('save', sym_kind_method, 0, 'fn (u User) save() {}') + // 'save' starts at column 12 in 'fn (u User) save() {}'. + assert method_sym.selection_range.start.char == 12, 'method name after receiver' + assert method_sym.selection_range.end.char == 16, 'method selection spans the name' + generic_sym := make_symbol('Foo[T]', sym_kind_struct, 1, 'struct Foo[T] {}') + assert generic_sym.name == 'Foo[T]', 'full name kept' + assert generic_sym.selection_range.start.char == 7, 'generic selection strips params' +} + +fn test_cov_anchor_and_line_endings() { + mk_loc := fn (uri string, line int, charac int) Location { + return Location{ + uri: uri + range: LSPRange{ + start: Position{ line: line, char: charac } + end: Position{ line: line, char: charac + 1 } + } + } + } + assert same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///a.v', 1, 6)), 'one unit drift matches' + assert !same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///a.v', 1, 7)), 'two units differ' + assert !same_anchor_location(mk_loc('file:///a.v', 1, 5), mk_loc('file:///b.v', 1, 5)), 'other file differs' + assert line_ending_after_line('a\r\nb\r\n', 0) == '\r\n', 'crlf kept' + assert line_ending_after_line('a\nb\n', 0) == '\n', 'lf kept' + assert line_ending_after_line('a', 0) == '\n', 'no break defaults to lf' +} + +fn test_cov_normalize_and_client_caps() { + if path := normalize_workspace_root('C:\\proj') { + assert path == 'C:\\proj', 'root kept' + } else { + assert false, 'root must resolve' + } + assert (normalize_workspace_root(' ') or { 'fallback' }) == 'fallback', 'blank is none' + assert (normalize_workspace_root('/') or { 'fallback' }) == 'fallback', 'slash is none' + assert !client_supports_dynamic_watched_files_registration(InitializeParams{}), 'no caps means no watching' + assert client_supports_dynamic_watched_files_registration(InitializeParams{ + capabilities: ClientCapabilities{ + workspace: WorkspaceClientCapabilities{ + did_change_watched_files: DidChangeWatchedFilesClientCapabilities{ + dynamic_registration: true + } + } + } + }), 'watching advertised' + assert !client_supports_work_done_progress(InitializeParams{}), 'no caps means no progress' + assert client_supports_work_done_progress(InitializeParams{ + capabilities: ClientCapabilities{ + window: WindowClientCapabilities{ + work_done_progress: true + } + } + }), 'progress advertised' + assert resolve_initialize_workspace_roots(InitializeParams{ + root_path: 'C:\\proj' + }) == ['C:\\proj'], 'root path resolves' + assert resolve_initialize_workspace_roots(InitializeParams{}).len == 0, 'no roots without params' +} + +fn test_cov_workspace_settings() { + mut resolved := ResolvedWorkspaceSettings{} + merge_workspace_settings(mut resolved, true, false) + assert resolved.has_inlay_hints, 'inlay hints recorded' + assert (resolved.inlay_hints or { false }) == true, 'inlay hints set' + assert resolved.has_diagnostics, 'diagnostics recorded' + assert (resolved.diagnostics or { true }) == false, 'diagnostics set' + merge_workspace_settings(mut resolved, false, true) + assert (resolved.inlay_hints or { false }) == true + && (resolved.diagnostics or { true }) == false, 'first write wins' + empty := resolve_workspace_settings('{}') + assert !empty.has_inlay_hints && !empty.has_diagnostics, 'empty json sets nothing' +} + +fn test_cov_code_lens_helpers() { + assert code_lens_fn_name('fn main() {}') == 'main', 'main found' + assert code_lens_fn_name('pub fn run() {}') == 'run', 'pub stripped' + assert code_lens_fn_name('fn (u User) save() {}') == '', 'methods skipped' + assert code_lens_fn_name('x := 1') == '', 'code is no function' + lens_range := code_lens_range(2, 'fn main() {}', .utf8) + assert lens_range.start.line == 2 && lens_range.start.char == 0, 'lens starts the line' + assert lens_range.end.char == 12, 'lens spans the line' +} + +fn test_cov_static_method_doc() { + assert static_method_doc_symbol_at('User.create', 5, 'create') == 'User.create', 'receiver qualifies symbol' + assert static_method_doc_symbol_at('x', 0, 'x') == 'x', 'bare word is kept' +} + +fn test_cov_on_initialize_and_workspace() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + if msg := app.on_initialize(Request{ + id: 1 + method: 'initialize' + params: '{}' + }) { + assert msg == '', 'unexpected initialize error: ${msg}' + } + if msg := app.on_initialize(Request{ + id: 2 + method: 'initialize' + params: 'not json at all {' + }) { + assert msg.contains('Invalid initialize params'), 'bad params reported' + } else { + assert false, 'bad params must error' + } + root := os.join_path(app.temp_dir, 'cov_ws') + must_mkdir_all(root) + app.workspace_roots = [root] + assert app.workspace_root_containing(os.join_path(root, 'a.v')) == root, 'file maps to its root' + assert app.workspace_root_containing(os.join_path(app.temp_dir, 'elsewhere', 'a.v')) == '', 'outside maps nowhere' +} + +fn test_cov_indexed_definition_visibility() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_pub.v' + content := 'pub fn foo() {}\n' + app.open_files[uri] = content + sym := make_symbol('foo', sym_kind_function, 0, 'pub fn foo() {}') + assert source_declaration_is_public(uri, sym, app), 'pub line is public' + priv_uri := 'file:///cov_priv.v' + app.open_files[priv_uri] = 'fn bar() {}\n' + priv_sym := make_symbol('bar', sym_kind_function, 0, 'fn bar() {}') + assert !source_declaration_is_public(priv_uri, priv_sym, app), 'plain line is private' +} + +fn test_cov_search_doc_in_vlib_dir() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + dir := os.join_path(app.temp_dir, 'covdoc') + must_mkdir_all(dir) + must_write_file(os.join_path(dir, 'mymod.v'), '// does things\nfn myhelper() int {\n\treturn 1\n}\n') + found := search_doc_in_vlib_dir(dir, 'myhelper') + assert found.contains('does things'), 'doc comment found' + assert search_doc_in_vlib_dir(dir, 'nosuchfn') == '', 'missing symbol finds nothing' +} + +fn test_cov_receiver_rhs_helpers() { + assert receiver_rhs_has_open_delimiter('foo('), 'open paren' + assert receiver_rhs_has_open_delimiter('a[0'), 'open bracket' + assert receiver_rhs_has_open_delimiter('a{'), 'open brace' + assert !receiver_rhs_has_open_delimiter('foo'), 'balanced rhs' + assert !receiver_rhs_has_open_delimiter(''), 'empty rhs' + mut scan := ImportScanState{} + assert receiver_rhs_needs_continuation('foo(', false, &scan), 'open delimiter continues' + assert receiver_rhs_needs_continuation('foo', false, &scan), 'missing expression continues' + assert !receiver_rhs_needs_continuation('', true, &scan), 'empty expression stops' + assert receiver_rhs_needs_continuation('x +', true, &scan), 'operator tail continues' + assert !receiver_rhs_needs_continuation('x', true, &scan), 'complete expression stops' +} + +fn test_cov_struct_literal_cursor() { + assert struct_literal_cursor_is_at_field('User{\n', 4, ['User{']), 'field position' + assert !struct_literal_cursor_is_at_field('User{\nname: ', 4, ['User{', 'name: ']), 'value position' +} + +fn test_cov_encode_and_col_helpers() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_cols.v' + content := 'fn main() {}\n' + app.open_files[uri] = content + assert app.client_col_to_byte_col(uri, 0, 3) == 3, 'ascii column round trips down' + assert app.byte_col_to_client_col(uri, 0, 3) == 3, 'ascii column round trips up' + assert app.client_col_to_byte_col('file:///missing.v', 0, 3) == 3, 'missing file keeps column' + assert app.byte_col_to_client_col(uri, 9, 3) == 3, 'missing line keeps column' + diag := LSPDiagnostic{ + range: LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 6 } + } + message: 'm' + severity: 1 + } + encoded := app.encode_diagnostic_range(diag, ['fn main() {}']) + assert encoded.range.start.char == 3 && encoded.range.end.char == 6, 'ascii range kept' + assert encoded.message == 'm', 'payload carried over' + outside := app.encode_diagnostic_range(diag, []string{}) + assert outside.range.start.char == 3, 'missing lines keep the range' +} + +fn test_cov_code_lens_command_target() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + _, _, missing := app.code_lens_command_target([]string{}) + assert missing == 'missing file argument', 'empty args rejected' + _, _, remote := app.code_lens_command_target(['https://example.com/x.v']) + assert remote == 'only local files can be run', 'remote files rejected' + _, _, gone := app.code_lens_command_target(['file:///definitely_missing_dir_xyz/main.v']) + assert gone.contains('file does not exist'), 'missing file rejected' + dir := os.join_path(app.temp_dir, 'covrun') + must_mkdir_all(dir) + path := os.join_path(dir, 'main.v') + must_write_file(path, 'module main\n\nfn main() {}\n') + _, real_path, real_err := app.code_lens_command_target([path]) + assert real_err == '', 'real file accepted' + assert real_path.ends_with('main.v'), 'path kept' + uri := path_to_uri(path) + app.open_files[uri] = 'module main\n\nfn main() {}\n' + got_uri, _, open_err := app.code_lens_command_target([uri]) + assert open_err == '' && got_uri == uri, 'open file resolves' +} + +fn test_cov_with_builtin_calls() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + out := app.with_builtin_calls([Detail{ label: 'foo' }, Detail{ label: 'println' }]) + assert out.map(it.label) == ['foo', 'println'], 'labels kept' + cached := app.builtin_call_items() + assert cached['println'].label == 'println', 'println declared in vlib builtin' +} + +fn test_cov_organize_imports_action() { + uri := 'file:///cov_imports.v' + content := 'module m\n\nimport os\nimport json\n\nfn main() {}\n' + lines := content.split_into_lines() + if action := build_safe_organize_imports_action(uri, content, lines, .utf8) { + assert action.title == 'Organize Imports', 'action titled' + edit := action.edit or { WorkspaceEdit{} } + edits := edit.changes[uri] or { []TextEdit{} } + assert edits.len == 1, 'one edit offered' + assert edits[0].new_text == 'import json\nimport os', 'imports sorted' + } else { + assert false, 'unsorted imports offer an action' + } + sorted_content := 'module m\n\nimport json\nimport os\n\nfn main() {}\n' + if _ := build_safe_organize_imports_action(uri, sorted_content, sorted_content.split_into_lines(), + .utf8) { + assert false, 'sorted imports offer nothing' + } + if _ := build_safe_organize_imports_action(uri, 'module m\n', ['module m'], .utf8) { + assert false, 'no imports offer nothing' + } + scattered := 'module m\n\nimport os\n\nfn main() {}\n\nimport json\n' + if _ := build_safe_organize_imports_action(uri, scattered, scattered.split_into_lines(), + .utf8) { + assert false, 'scattered imports offer nothing' + } +} + +fn test_cov_compile_time_condition() { + occ := [TokenOccurrence{ line: 0, start_char: 1, end_char: 3 }] + assert source_occurrence_is_compile_time_condition(['\$if x'], 0, 5, occ, .utf8), 'condition reaches' + assert !source_occurrence_is_compile_time_condition(['\$if x {', 'y'], 1, 1, occ, .utf8), 'block brace stops it' + assert !source_occurrence_is_compile_time_condition(['x := 1'], 0, 1, []TokenOccurrence{}, + .utf8), 'no directive means no condition' +} + +fn test_cov_potential_local_binding() { + generic := [TokenOccurrence{ line: 0, start_char: 7, end_char: 8 }] + assert source_occurrences_have_potential_local_binding(['fn foo[T](x T) {}'], generic, + .utf8), 'generic parameter binds' + call_arg := [TokenOccurrence{ line: 0, start_char: 8, end_char: 9 }] + assert !source_occurrences_have_potential_local_binding(['println(x)'], call_arg, .utf8), 'call argument binds nothing' + assert !source_occurrences_have_potential_local_binding(['x'], []TokenOccurrence{}, .utf8), 'no occurrences bind nothing' +} + +fn test_cov_add_workspace_symbol() { + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + rng := LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 6 } + } + add_workspace_symbol(mut results, mut seen, 'foo', sym_kind_function, 'file:///a.v', rng) + add_workspace_symbol(mut results, mut seen, 'foo', sym_kind_function, 'file:///a.v', rng) + assert results.len == 1, 'duplicate suppressed' + assert results[0].name == 'foo' && results[0].location.uri == 'file:///a.v', 'symbol kept' +} diff --git a/index_test.v b/index_test.v index aaab9ab9..f2ef5c24 100644 --- a/index_test.v +++ b/index_test.v @@ -1074,3 +1074,107 @@ fn test_large_vlang_v_workspace_from_env() { assert false, 'expected to find a production fn main under vlang/v cmd' } } + +fn test_index_encode_range_chars_converts_byte_cols_to_encoding() { + r := LSPRange{ + start: Position{ + line: 0 + char: 2 + } + end: Position{ + line: 1 + char: 3 + } + } + lines := ['abcdef', 'xyz'] + got := encode_range_chars(r, lines, .utf8) + assert got.start.line == 0, 'start line is preserved' + assert got.start.char == 2, 'utf-8 start col passes through' + assert got.end.line == 1, 'end line is preserved' + assert got.end.char == 3, 'utf-8 end col passes through' + oob := LSPRange{ + start: Position{ + line: 9 + char: 4 + } + end: Position{ + line: 9 + char: 6 + } + } + oob_got := encode_range_chars(oob, lines, .utf8) + assert oob_got.start.line == 9, 'out-of-range start line is preserved' + assert oob_got.start.char == 0, 'out-of-range line measures against empty text' + assert oob_got.end.char == 0, 'out-of-range end col measures against empty text' +} + +fn test_index_encode_range_chars_counts_multibyte_units_in_utf16() { + r := LSPRange{ + start: Position{ + line: 0 + char: 2 + } + end: Position{ + line: 0 + char: 3 + } + } + // U+00E9 is two bytes in UTF-8 but one UTF-16 unit. + got := encode_range_chars(r, ['éx'], .utf16) + assert got.start.char == 1, 'two leading bytes are one utf-16 unit' + assert got.end.char == 2, 'three leading bytes are two utf-16 units' +} + +fn test_index_path_is_in_index_scope_matches_recursive_scope() { + assert !path_is_in_index_scope('/a/b/c.v', IndexScope{}), 'empty scope matches nothing' + rec := IndexScope{ + dir: '/a/b' + recursive: true + } + assert path_is_in_index_scope('/a/b/c.v', rec), 'direct child is in recursive scope' + assert path_is_in_index_scope('/a/b/sub/c.v', rec), 'nested child is in recursive scope' + assert !path_is_in_index_scope('/a/barley/c.v', rec), 'name with shared prefix is not inside' + assert !path_is_in_index_scope('/a/c.v', rec), 'sibling is not in recursive scope' +} + +fn test_index_path_is_in_index_scope_shallow_matches_same_dir_only() { + dir := os.join_path(os.temp_dir(), 'vls_scope_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + sub := os.join_path(dir, 'sub') + os.mkdir_all(sub) or { assert false, 'mkdir sub failed: ${err}' } + shallow := IndexScope{ + dir: dir + } + assert path_is_in_index_scope(os.join_path(dir, 'a.v'), shallow), 'file in scope dir matches' + assert !path_is_in_index_scope(os.join_path(sub, 'b.v'), shallow), 'file in subdir does not match shallow scope' +} + +fn test_index_uri_is_in_index_scope_delegates_to_path_scope() { + dir := os.join_path(os.temp_dir(), 'vls_uri_scope_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + uri := path_to_uri(os.join_path(dir, 'a.v')) + rec := IndexScope{ + dir: dir + recursive: true + } + assert uri_is_in_index_scope(uri, rec), 'uri under recursive dir is in scope' + assert !uri_is_in_index_scope(uri, IndexScope{}), 'empty scope matches no uri' +} + +fn test_index_uri_within_any_matches_paths_under_dirs() { + dir := os.join_path(os.temp_dir(), 'vls_within_${os.getpid()}_${time.now().unix_nano()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + defer { + os.rmdir_all(dir) or {} + } + uri := path_to_uri(os.join_path(dir, 'a.v')) + assert uri_within_any(uri, [dir]), 'uri under dir matches' + assert !uri_within_any(uri, [os.join_path(dir, 'sub')]), 'uri outside subdir does not match' + assert !uri_within_any(uri, []string{}), 'empty dir list matches nothing' +} diff --git a/interop_test.v b/interop_test.v index 980781e4..5215ece5 100644 --- a/interop_test.v +++ b/interop_test.v @@ -2815,3 +2815,1247 @@ fn test_compat_matrix_dead_end_without_checker() { assert app.line_info_mode == .missing assert app.captured_output.len == 1 } + +// ============================================================================ +// Tiered-diagnostics coverage for interop.v, diag_cache.v and run_command.v. +// Every test below names the production function it covers directly. +// ============================================================================ + +fn test_cov_hex_nibble_decodes_and_rejects() { + assert (hex_nibble(`0`) or { 255 }) == 0, 'hex 0 decodes to 0' + assert (hex_nibble(`9`) or { 255 }) == 9, 'hex 9 decodes to 9' + assert (hex_nibble(`a`) or { 255 }) == 10, 'hex a decodes to 10' + assert (hex_nibble(`f`) or { 255 }) == 15, 'hex f decodes to 15' + assert (hex_nibble(`A`) or { 255 }) == 10, 'hex A decodes to 10' + assert (hex_nibble(`F`) or { 255 }) == 15, 'hex F decodes to 15' + if _ := hex_nibble(`g`) { + assert false, 'g is not a hex digit' + } else { + assert true, 'g is rejected' + } + if _ := hex_nibble(`/`) { + assert false, '/ is not a hex digit' + } else { + assert true, '/ is rejected' + } +} + +fn test_cov_percent_decode_keeps_invalid_escapes() { + assert percent_decode('abc') == 'abc', 'no escape passes through' + assert percent_decode('a%20b') == 'a b', '%20 decodes to a space' + assert percent_decode('a%2Gb%') == 'a%2Gb%', 'invalid and trailing escapes are kept' + assert percent_decode('100%25') == '100%', '%25 decodes to percent' + assert percent_decode('%') == '%', 'lone percent is kept' +} + +fn test_cov_path_byte_needs_escape_and_percent_encode_path() { + assert !path_byte_needs_escape(`a`), 'lowercase is unreserved' + assert !path_byte_needs_escape(`Z`), 'uppercase is unreserved' + assert !path_byte_needs_escape(`0`), 'digit is unreserved' + assert !path_byte_needs_escape(`-`), 'dash is unreserved' + assert !path_byte_needs_escape(`/`), 'slash is kept as separator' + assert !path_byte_needs_escape(`:`), 'colon is kept for drive letters' + assert path_byte_needs_escape(` `), 'space must be escaped' + assert path_byte_needs_escape(`#`), 'hash must be escaped' + assert path_byte_needs_escape(`%`), 'percent must be escaped' + assert path_byte_needs_escape(`\\`), 'backslash must be escaped' + assert percent_encode_path('a b#c%d:e/f-._~') == 'a%20b%23c%25d:e/f-._~', 'encodes only reserved bytes' +} + +fn test_cov_path_is_within_and_path_relative_to_wrappers() { + assert path_is_within('/a/b/c', '/a/b'), 'child is within parent' + assert path_is_within('/a/b', '/a/b'), 'directory is within itself' + assert !path_is_within('/a/barley', '/a/bar'), 'sibling prefix is not within' + assert !path_is_within('/a/b', ''), 'empty dir never contains' + assert path_relative_to('/a/b/c', '/a/b') or { '' } == 'c', 'relative strips the prefix' + assert path_relative_to('/a/b', '/a/b') or { 'x' } == '', 'equal paths give empty relative' + if _ := path_relative_to('/other/file.v', '/a/b') { + assert false, 'outside path must be none' + } else { + assert true, 'outside path is none' + } +} + +fn test_cov_make_unique_temp_path_tags_extension() { + tagged := make_unique_temp_path('check', '/tmp/foo.v') + assert tagged.contains('check'), 'tag names the purpose: ${tagged}' + assert tagged.ends_with('.v'), 'extension is kept: ${tagged}' + assert tagged.contains('foo'), 'base name is kept: ${tagged}' + plain := make_unique_temp_path('work', '/tmp/foo') + assert plain.ends_with('.v'), 'extensionless input defaults to .v: ${plain}' + other := make_unique_temp_path('check', '/tmp/foo.v') + assert other.contains('check'), 'a repeated call keeps the tag: ${other}' + assert other.ends_with('.v'), 'a repeated call keeps the extension: ${other}' +} + +fn test_cov_build_v_line_info_args_multifile_shape() { + args := build_v_line_info_args_multifile('main.v', '10:gd^5') + assert args == ['-w', '-check', '-nocolor', '-vls-mode', '-line-info', 'main.v:10:gd^5', '.'], 'multifile line-info argv is exact: ${args}' +} + +fn test_cov_compiler_is_available_reports_exe_presence() { + previous := os.getenv('VLS_V_COMMAND') + defer { + restore_v_command(previous) + } + base := os.join_path(os.temp_dir(), 'vls_cov_available_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + real := os.join_path(base, 'v') + interop_test_must_write_file(real, 'stub') + os.setenv('VLS_V_COMMAND', real, true) + assert compiler_is_available(), 'an existing configured compiler is available' + os.setenv('VLS_V_COMMAND', os.join_path(base, 'no-such-compiler'), true) + assert !compiler_is_available(), 'a missing configured compiler is unavailable' +} + +fn test_cov_program_diagnostic_level_prefixes() { + if level, message := program_diagnostic_level('builder error: redefinition of function `main`') { + assert level == 'error', 'builder error maps to error' + assert message == 'redefinition of function `main`', 'builder message is kept' + } else { + assert false, 'builder error must parse' + } + if level, _ := program_diagnostic_level('checker error: bad') { + assert level == 'error', 'checker error maps to error' + } else { + assert false, 'checker error must parse' + } + if level, _ := program_diagnostic_level('parser error: bad') { + assert level == 'error', 'parser error maps to error' + } else { + assert false, 'parser error must parse' + } + if level, _ := program_diagnostic_level('cgen error: bad') { + assert level == 'error', 'cgen error maps to error' + } else { + assert false, 'cgen error must parse' + } + if level, message := program_diagnostic_level('error: plain failure') { + assert level == 'error', 'plain error keeps its level' + assert message == 'plain failure', 'plain error keeps its message' + } else { + assert false, 'plain error must parse' + } + if level, _ := program_diagnostic_level('warning: unused') { + assert level == 'warning', 'warning keeps its level' + } else { + assert false, 'warning must parse' + } + if level, _ := program_diagnostic_level('notice: hint') { + assert level == 'notice', 'notice keeps its level' + } else { + assert false, 'notice must parse' + } + if _, _ := program_diagnostic_level('/tmp/main.v:1:1: error: placed') { + assert false, 'a placed diagnostic is not a program diagnostic' + } else { + assert true, 'a placed diagnostic is skipped' + } +} + +fn test_cov_quoted_words_extracts_both_quote_styles() { + assert quoted_words('uses `alpha` plus "beta"') == ['alpha', 'beta'], 'backticks and double quotes are read' + assert quoted_words('nothing quoted here') == [], 'unquoted text gives no words' + assert quoted_words('empty `` quotes') == [], 'empty quotes give no words' +} + +fn test_cov_program_diagnostic_place_keyword_order_and_fallback() { + lines := ['module main', 'import lib', 'fn main() {'] + line_nr, col, len := program_diagnostic_place(lines, ['lib'], ['import ', 'module ']) + assert line_nr == 2, 'import keyword finds line 2, got ${line_nr}' + assert col == 1, 'column starts at 1, got ${col}' + assert len == 'import lib'.len, 'length covers the whole line, got ${len}' + // Keywords are tried in order: fn first finds the function even though an + // import of another word exists. + ordered := ['import lib', 'fn main() {'] + line_fn, _, _ := program_diagnostic_place(ordered, ['main', 'lib'], ['fn ', 'import ']) + assert line_fn == 2, 'function keyword wins when listed first, got ${line_fn}' + fallback_nr, fallback_col, fallback_len := program_diagnostic_place(lines, ['zzz'], [ + 'import ', + 'fn ', + ]) + assert fallback_nr == 1, 'unknown words fall back to line 1, got ${fallback_nr}' + assert fallback_col == 1, 'fallback column is 1, got ${fallback_col}' + assert fallback_len == 'module main'.len, 'fallback covers the first line, got ${fallback_len}' + empty_nr, empty_col, empty_len := program_diagnostic_place([]string{}, ['zzz'], ['fn ']) + assert empty_nr == 1 && empty_col == 1 && empty_len == 0, 'empty files anchor at 1:1 with length 0' +} + +fn test_cov_diagnostic_source_path_is_valid_suffix_and_disk() { + assert diagnostic_source_path_is_valid('main.v', ''), '.v is valid without a source dir' + assert diagnostic_source_path_is_valid('run.vsh', ''), '.vsh is valid without a source dir' + assert !diagnostic_source_path_is_valid('notes.txt', ''), '.txt is never a source' + assert !diagnostic_source_path_is_valid('novtsuffix', ''), 'suffixless paths are rejected' + base := os.join_path(os.temp_dir(), 'vls_cov_valid_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + real := os.join_path(base, 'main.v') + interop_test_must_write_file(real, 'module main\n') + assert diagnostic_source_path_is_valid(real, base), 'an existing absolute file is valid' + assert !diagnostic_source_path_is_valid(os.join_path(base, 'missing.v'), base), 'a missing file is invalid' + assert diagnostic_source_path_is_valid('main.v', base), 'a relative file present on disk is valid' + assert !diagnostic_source_path_is_valid('missing.v', base), 'a relative file missing on disk is invalid' +} + +fn test_cov_parse_v_check_diagnostic_header_accepts_and_rejects() { + if diag := parse_v_check_diagnostic_header('/tmp/main.v:3:7: error: boom', '') { + assert diag.path == '/tmp/main.v', 'path is kept' + assert diag.line_nr == 3, 'line is parsed' + assert diag.col == 7, 'column is parsed' + assert diag.message == 'boom', 'message is parsed' + assert diag.level == 'error', 'level is parsed' + } else { + assert false, 'a plain header must parse' + } + if diag := parse_v_check_diagnostic_header('/tmp/main.v:3:7: builder error: broken', '') { + assert diag.level == 'error', 'builder error maps to error, got ${diag.level}' + } else { + assert false, 'a builder header must parse' + } + if _ := parse_v_check_diagnostic_header('/tmp/main.v:3:x: error: boom', '') { + assert false, 'a non-numeric column must be rejected' + } else { + assert true, 'a non-numeric column is rejected' + } + if _ := parse_v_check_diagnostic_header('/tmp/notes.txt:3:7: error: boom', '') { + assert false, 'a non-V path must be rejected' + } else { + assert true, 'a non-V path is rejected' + } + if _ := parse_v_check_diagnostic_header('just some log line', '') { + assert false, 'a log line must be rejected' + } else { + assert true, 'a log line is rejected' + } +} + +fn test_cov_decimal_text_is_valid_cases() { + assert decimal_text_is_valid('0'), 'zero is valid' + assert decimal_text_is_valid('123'), 'digits are valid' + assert !decimal_text_is_valid(''), 'empty text is invalid' + assert !decimal_text_is_valid('12a'), 'letters are invalid' + assert !decimal_text_is_valid('-3'), 'a sign is invalid' +} + +fn test_cov_v_diagnostic_underline_len_cases() { + assert v_diagnostic_underline_len(' | ~~~') == 3, 'tildes measure the span' + assert v_diagnostic_underline_len(' | ^^^') == 3, 'carets measure the span' + assert v_diagnostic_underline_len('no separator here') == 0, 'a line without a pipe gives 0' + assert v_diagnostic_underline_len(' | ') == 0, 'a blank marker gives 0' + assert v_diagnostic_underline_len(' | ~~xx') == 0, 'mixed markers give 0' +} + +fn test_cov_resolve_compiler_timeout_ms_env_override() { + previous := os.getenv('VLS_TIMEOUT_MS') + defer { + if previous == '' { + os.unsetenv('VLS_TIMEOUT_MS') + } else { + os.setenv('VLS_TIMEOUT_MS', previous, true) + } + } + os.unsetenv('VLS_TIMEOUT_MS') + assert resolve_compiler_timeout_ms() == 30000, 'the default timeout is 30s' + os.setenv('VLS_TIMEOUT_MS', '1500', true) + assert resolve_compiler_timeout_ms() == 1500, 'a positive override wins' + os.setenv('VLS_TIMEOUT_MS', '0', true) + assert resolve_compiler_timeout_ms() == 30000, 'zero falls back to the default' + os.setenv('VLS_TIMEOUT_MS', '-5', true) + assert resolve_compiler_timeout_ms() == 30000, 'a negative value falls back to the default' + os.setenv('VLS_TIMEOUT_MS', 'abc', true) + assert resolve_compiler_timeout_ms() == 30000, 'a non-numeric value falls back to the default' +} + +fn test_cov_compilation_overlay_root_and_work_dir() { + base := os.join_path(os.temp_dir(), 'vls_cov_root_${os.getpid()}') + plain := os.join_path(base, 'plain') + proj := os.join_path(base, 'proj') + sub := os.join_path(proj, 'repo', 'deeper') + interop_test_must_mkdir_all(sub) + interop_test_must_mkdir_all(plain) + interop_test_must_write_file(os.join_path(proj, 'v.mod'), "Module {\n\tname: 'covroot'\n}\n") + plain_file := os.join_path(plain, 'main.v') + interop_test_must_write_file(plain_file, 'module main\n') + assert compilation_overlay_root(plain_file) == normalize_overlay_path(plain), 'loose files overlay from their own dir' + proj_file := os.join_path(sub, 'more.v') + interop_test_must_write_file(proj_file, 'module main\n') + assert compilation_overlay_root(proj_file) == normalize_overlay_path(proj), 'v.mod files overlay from the project root' + assert compilation_work_dir(plain_file) == normalize_overlay_path(plain), 'loose files check from their own dir' + assert compilation_work_dir(proj_file) == normalize_overlay_path(sub), 'unlisted subdirs check from their own dir' + os.rmdir_all(base) or {} + listed_base := os.join_path(os.temp_dir(), 'vls_cov_subdirs_${os.getpid()}') + listed_proj := os.join_path(listed_base, 'proj') + listed_sub := os.join_path(listed_proj, 'repo') + interop_test_must_mkdir_all(listed_sub) + interop_test_must_write_file(os.join_path(listed_proj, 'v.mod'), "Module {\n\tname: 'covsub'\n\tsubdirs: ['repo']\n}\n") + listed_file := os.join_path(listed_sub, 'repo.v') + interop_test_must_write_file(listed_file, 'module main\n') + assert compilation_work_dir(listed_file) == normalize_overlay_path(listed_proj), 'listed subdirs check from the program root' + os.rmdir_all(listed_base) or {} +} + +fn test_cov_should_use_compilation_overlay_branches() { + base := os.join_path(os.temp_dir(), 'vls_cov_should_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + lone := os.join_path(base, 'lone.v') + interop_test_must_write_file(lone, 'module main\n') + assert !should_use_compilation_overlay(lone, 1), 'a lone file with one buffer needs no overlay' + assert should_use_compilation_overlay(lone, 2), 'two open buffers need an overlay' + sibling := os.join_path(base, 'sibling.v') + interop_test_must_write_file(sibling, 'module main\n') + assert should_use_compilation_overlay(lone, 1), 'a sibling module file needs an overlay' + interop_test_must_write_file(os.join_path(base, 'v.mod'), "Module {\n\tname: 'covshould'\n}\n") + assert should_use_compilation_overlay(lone, 1), 'a v.mod project needs an overlay' +} + +fn test_cov_resolve_local_module_dir_variants() { + base := os.join_path(os.temp_dir(), 'vls_cov_localmod_${os.getpid()}') + prog := os.join_path(base, 'prog') + interop_test_must_mkdir_all(os.join_path(prog, 'a', 'b')) + defer { + os.rmdir_all(base) or {} + } + if got := resolve_local_module_dir('a.b', prog, '') { + assert got == normalize_overlay_path(os.join_path(prog, 'a', 'b')), 'dotted imports resolve below the program' + } else { + assert false, 'a.b must resolve below the program' + } + if _ := resolve_local_module_dir('missing', prog, '') { + assert false, 'an unknown module must be none' + } else { + assert true, 'an unknown module is none' + } + // A v.mod root named proj serves proj.a from its own a/ directory. + root := os.join_path(base, 'proj') + interop_test_must_mkdir_all(os.join_path(root, 'a')) + if got := resolve_local_module_dir('proj.a', prog, root) { + assert got == normalize_overlay_path(os.join_path(root, 'a')), 'the project prefix resolves below the v.mod root' + } else { + assert false, 'proj.a must resolve below the v.mod root' + } +} + +fn test_cov_v_files_in_and_source_text_and_has_sibling() { + base := os.join_path(os.temp_dir(), 'vls_cov_vfiles_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(base, 'main.v') + other_file := os.join_path(base, 'other.v') + interop_test_must_write_file(main_file, 'module main\n') + interop_test_must_write_file(other_file, 'module main\n') + interop_test_must_write_file(os.join_path(base, 'skip_test.v'), 'module main\n') + interop_test_must_write_file(os.join_path(base, 'keep_test_test.v'), 'module main\n') + main_uri := path_to_uri(main_file) + mut app := &App{ + open_files: { + main_uri: 'buffer wins' + path_to_uri(os.join_path(base, 'new.v')): 'unsaved module' + } + } + files := app.v_files_in(normalize_overlay_path(base)) + assert normalize_overlay_path(main_file) in files, 'disk files are listed' + assert normalize_overlay_path(other_file) in files, 'sibling files are listed' + assert files.any(it.ends_with('new.v')), 'unsaved buffers are listed' + assert !files.any(it.ends_with('keep_test_test.v')), 'test files are left out' + assert app.source_text(normalize_overlay_path(main_file)) == 'buffer wins', 'open buffers win over disk' + assert app.source_text(normalize_overlay_path(other_file)) == 'module main\n', 'closed files read from disk' + assert app.source_text(normalize_overlay_path(os.join_path(base, 'gone.v'))) == '', 'missing files read as empty' + assert has_sibling_v_files(base, main_file), 'a sibling .v file is detected' + lone_dir := os.join_path(base, 'lone') + interop_test_must_mkdir_all(lone_dir) + lone_file := os.join_path(lone_dir, 'only.v') + interop_test_must_write_file(lone_file, 'module main\n') + assert !has_sibling_v_files(lone_dir, lone_file), 'a lone file has no siblings' + assert !has_sibling_v_files(os.join_path(base, 'no-such-dir'), lone_file), 'a missing dir has no siblings' +} + +fn test_cov_program_reaches_is_program_dir_imports_local() { + base := os.join_path(os.temp_dir(), 'vls_cov_reaches_${os.getpid()}') + interop_test_must_mkdir_all(os.join_path(base, 'lib')) + interop_test_must_mkdir_all(os.join_path(base, 'lone')) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(base, 'main.v'), 'module main\n\nimport lib\n\nfn main() {}\n') + interop_test_must_write_file(os.join_path(base, 'lib', 'lib.v'), 'module lib\n') + interop_test_must_write_file(os.join_path(base, 'lone', 'lone.v'), 'module lone\n') + interop_test_must_write_file(os.join_path(base, 'plain.v'), 'module main\n\nfn main() {}\n') + lib_dir := normalize_overlay_path(os.join_path(base, 'lib')) + lone_dir := normalize_overlay_path(os.join_path(base, 'lone')) + root := normalize_overlay_path(base) + mut app := &App{ + open_files: map[string]string{} + } + assert app.program_reaches(root, '', lib_dir), 'the program reaches its imported module' + assert !app.program_reaches(root, '', lone_dir), 'the program does not reach an unimported module' + assert !app.program_reaches(lib_dir, '', lone_dir), 'a library dir is not a program' + assert app.is_program_dir(root), 'a dir with module main is a program dir' + assert !app.is_program_dir(lib_dir), 'a library dir is not a program dir' + assert app.imports_local_module(os.join_path(base, 'main.v'), root), 'main.v imports a local module' + assert !app.imports_local_module(os.join_path(base, 'plain.v'), root), 'a file without local imports needs no program check' +} + +fn test_cov_prepare_compilation_overlay_in_and_with_and_line_info() { + base := os.join_path(os.temp_dir(), 'vls_cov_prep_${os.getpid()}') + proj := os.join_path(base, 'proj') + interop_test_must_mkdir_all(proj) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(proj, 'main.v') + interop_test_must_write_file(main_file, 'module main\n') + main_uri := path_to_uri(main_file) + unsaved := 'module main\n\nfn unsaved() {}\n' + mut app := &App{ + temp_dir: work + open_files: { + main_uri: unsaved + } + } + overlay := app.prepare_compilation_overlay_in(main_file, proj) or { + assert false, 'prepare in must succeed: ${err}' + return + } + defer { + os.rmdir_all(overlay.temp_root) or {} + } + assert os.read_file(overlay.temp_source_file) or { '' } == unsaved, 'the buffer is materialized' + assert overlay.source_work_dir == normalize_overlay_path(proj), 'the work dir is kept' + with_overlay := app.prepare_compilation_overlay_with(main_file, proj, map[string]string{}) or { + assert false, 'prepare with must succeed: ${err}' + return + } + defer { + os.rmdir_all(with_overlay.temp_root) or {} + } + assert with_overlay.source_work_dir == normalize_overlay_path(proj), 'prepare with keeps the work dir' + line_overlay := app.prepare_line_info_overlay(main_file, proj) or { + assert false, 'line info overlay must succeed: ${err}' + return + } + defer { + os.rmdir_all(line_overlay.temp_root) or {} + } + assert os.read_file(line_overlay.temp_source_file) or { '' } == unsaved, 'line info overlay keeps the buffer' + // A work dir outside the source tree cannot hold the file. + if _ := app.prepare_compilation_overlay_with(main_file, os.join_path(base, 'elsewhere'), map[string]string{}) { + assert false, 'an outside work dir must fail' + } else { + assert true, 'an outside work dir fails' + } +} + +fn test_cov_program_open_files_maps_program_members() { + base := os.join_path(os.temp_dir(), 'vls_cov_progfiles_${os.getpid()}') + interop_test_must_mkdir_all(base) + other_dir := os.join_path(base, 'other') + interop_test_must_mkdir_all(other_dir) + defer { + os.rmdir_all(base) or {} + } + main_file := os.join_path(base, 'main.v') + other_file := os.join_path(base, 'other.v') + far_file := os.join_path(other_dir, 'far.v') + for path in [main_file, other_file, far_file] { + interop_test_must_write_file(path, 'module main\n') + } + main_uri := path_to_uri(main_file) + other_uri := path_to_uri(other_file) + far_uri := path_to_uri(far_file) + mut app := &App{ + open_files: { + main_uri: 'module main\n' + other_uri: 'module main\n' + far_uri: 'module main\n' + } + } + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(base) + temp_work_dir: normalize_overlay_path(base) + source_work_dir: normalize_overlay_path(base) + temp_source_file: normalize_overlay_path(main_file) + } + got := app.program_open_files(normalize_overlay_path(main_file), overlay) + assert got.len == 1, 'only the other program file is mapped, got ${got.len}' + assert got[normalized_index_path(normalize_overlay_path(other_file))] == other_uri, 'the other file maps to its uri' +} + +fn test_cov_split_check_errors_single_and_program_paths() { + base := os.join_path(os.temp_dir(), 'vls_cov_split_${os.getpid()}') + src := os.join_path(base, 'src') + overlay_root := os.join_path(base, 'overlay') + interop_test_must_mkdir_all(src) + interop_test_must_mkdir_all(overlay_root) + defer { + os.rmdir_all(base) or {} + } + main_src := os.join_path(src, 'main.v') + other_src := os.join_path(src, 'other.v') + interop_test_must_write_file(main_src, 'module main\n') + interop_test_must_write_file(other_src, 'module main\n') + main_tmp := os.join_path(overlay_root, 'main.v') + other_tmp := os.join_path(overlay_root, 'other.v') + interop_test_must_write_file(main_tmp, 'module main\n') + interop_test_must_write_file(other_tmp, 'module main\n') + single_out := '${main_src}:2:3: error: boom\n' + single := split_check_errors(single_out, src, main_src, false, CompilationOverlay{}, main_src, map[string]string{}, + src) + assert single.file.len == 1, 'single-file output keeps its diagnostic' + assert single.parsed == 1, 'single-file parsed counts the diagnostic' + assert single.file[0].message == 'boom', 'single-file message is kept' + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(src) + source_display_root: normalize_overlay_path(src) + temp_root: normalize_overlay_path(overlay_root) + source_work_dir: normalize_overlay_path(src) + temp_work_dir: normalize_overlay_path(overlay_root) + temp_source_file: normalize_overlay_path(main_tmp) + } + other_uri := 'file:///other.v' + program_uris := { + normalized_index_path(normalize_overlay_path(other_src)): other_uri + } + multi_out := '${main_tmp}:2:3: error: boom\n${other_tmp}:4:1: warning: slow\n' + multi := split_check_errors(multi_out, overlay_root, main_tmp, true, overlay, main_src, + program_uris, overlay_root) + assert multi.parsed == 2, 'program output parses both diagnostics, got ${multi.parsed}' + assert multi.file.len == 1, 'only the requested file is kept, got ${multi.file.len}' + assert multi.file[0].path == main_src, 'kept errors point at the real path' + assert multi.program[other_uri].len == 1, 'the other program file gets its diagnostic' + assert multi.program[other_uri][0].level == 'warning', 'levels survive the split' +} + +fn test_cov_new_overlay_copy_budget_and_try_reserve() { + mut budget := new_overlay_copy_budget() + assert budget.max_files == 4096, 'the file cap is 4096' + assert budget.max_bytes == u64(64 * 1024 * 1024), 'the byte cap is 64MiB' + base := os.join_path(os.temp_dir(), 'vls_cov_budget_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + small := os.join_path(base, 'small.v') + content := 'module main\n' + interop_test_must_write_file(small, content) + assert budget.try_reserve(small), 'a small file fits the budget' + assert budget.files == 1, 'the file count moves' + assert budget.bytes == u64(content.len), 'the byte count moves' + mut tiny := OverlayCopyBudget{ + max_files: 1 + max_bytes: 1024 + } + assert tiny.try_reserve(small), 'one file fits a cap of one' + assert !tiny.try_reserve(small), 'a second file breaks the file cap' + mut bytes := OverlayCopyBudget{ + max_files: 10 + max_bytes: 4 + } + assert !bytes.try_reserve(small), 'an over-size file breaks the byte cap' + assert bytes.bytes == 0, 'a refused reservation moves nothing' +} + +fn test_cov_overlay_path_in_and_has_descendant_wrappers() { + assert overlay_path_in('src/main.v', ['src/main.v']), 'an exact path is in the set' + assert !overlay_path_in('src/other.v', ['src/main.v']), 'another path is not in the set' + assert overlay_path_has_descendant('src', ['src/main.v']), 'a parent has a descendant in the set' + assert !overlay_path_has_descendant('src/main.v', ['src']), 'a file has no descendant in a parent set' + assert !overlay_path_in('src/main.v', []), 'an empty set contains nothing' +} + +fn test_cov_create_links_and_materialize_overlay_file() { + base := os.join_path(os.temp_dir(), 'vls_cov_links_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + source := os.join_path(base, 'source.v') + interop_test_must_write_file(source, 'module main\n') + linked := os.join_path(base, 'linked.v') + create_overlay_symlink(source, linked) or { + // Symlinks need privileges on some machines; the fallback below is the + // portable assertion. + return + } + assert os.exists(linked), 'a symlinked overlay entry exists' + hard := os.join_path(base, 'hard.v') + create_overlay_hard_link(source, hard) or { + assert false, 'a hard link must be created: ${err}' + return + } + assert os.is_file(hard), 'a hard-linked overlay entry is a file' + mut budget := new_overlay_copy_budget() + materialized := materialize_overlay_file(source, os.join_path(base, 'mat.v'), mut budget) or { + assert false, 'materialize must succeed: ${err}' + return + } + assert materialized, 'a linkable file materializes' + assert budget.files == 0, 'a linked file costs no budget' + mut empty := OverlayCopyBudget{ + max_files: 0 + max_bytes: 0 + } + denied := materialize_overlay_file_with_linker(source, os.join_path(base, 'denied.v'), deny_overlay_symlink, mut + empty) or { + assert false, 'an exhausted budget must not fail: ${err}' + return + } + assert !denied, 'an exhausted budget skips the file' + assert !os.exists(os.join_path(base, 'denied.v')), 'a skipped file is not written' +} + +fn test_cov_local_import_rel_dirs_closure() { + base := os.join_path(os.temp_dir(), 'vls_cov_imports_${os.getpid()}') + root := os.join_path(base, 'proj') + interop_test_must_mkdir_all(os.join_path(root, 'mathutil', 'inner')) + defer { + os.rmdir_all(base) or {} + } + main_content := 'module main\n\nimport mathutil\n' + interop_test_must_write_file(os.join_path(root, 'main.v'), main_content) + interop_test_must_write_file(os.join_path(root, 'mathutil', 'mathutil.v'), 'module mathutil\n\nimport mathutil.inner\n') + interop_test_must_write_file(os.join_path(root, 'mathutil', 'inner', 'inner.v'), 'module inner\n') + main_uri := path_to_uri(os.join_path(root, 'main.v')) + tracked := { + main_uri: main_content + } + got := local_import_rel_dirs(normalize_overlay_path(root), normalize_overlay_path(root), tracked) + assert 'mathutil' in got, 'a directly imported module is found: ${got}' + assert 'mathutil/inner' in got, 'a transitively imported module is found: ${got}' + empty := local_import_rel_dirs(normalize_overlay_path(root), normalize_overlay_path(root), map[string]string{}) + assert empty.len == 0, 'no buffers means no imports' +} + +fn test_cov_parse_embed_and_vmodroot_paths() { + content := 'module main\n\nconst a = \$embed_file(\'assets/a.json\')\nconst b = \$embed_file( "assets/b.txt" )\nconst c = \$embed_file(r\'assets/c.dat\')\n' + embed := parse_embed_file_literal_paths(content) + assert embed == ['assets/a.json', 'assets/b.txt', 'assets/c.dat'], 'quoted embed paths are read: ${embed}' + assert parse_embed_file_literal_paths('no marker here') == [], 'missing markers give nothing' + assert parse_embed_file_literal_paths('\$embed_file without a call') == [], 'a bare marker gives nothing' + flagged := "module main\n#flag -I @VMODROOT/thirdparty/native\n// uses @VMODROOT/thirdparty/lib/lib.h\nconst x = \$embed_file('thirdparty/native/embedded.json')" + thirdparty := parse_vmodroot_thirdparty_paths(flagged) + assert thirdparty == ['@VMODROOT/thirdparty/native', '@VMODROOT/thirdparty/lib/lib.h'], 'vmodroot references are read: ${thirdparty}' + assert parse_vmodroot_thirdparty_paths('nothing here') == [], 'missing vmodroot markers give nothing' +} + +fn test_cov_resolve_thirdparty_overlay_reference_cases() { + base := os.join_path(os.temp_dir(), 'vls_cov_third_${os.getpid()}') + root := os.join_path(base, 'proj') + lib := os.join_path(root, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + defer { + os.rmdir_all(base) or {} + } + header := os.join_path(lib, 'lib.h') + interop_test_must_write_file(header, '#define X 1\n') + source_file := os.join_path(root, 'main.v') + interop_test_must_write_file(source_file, 'module main\n') + if got := resolve_thirdparty_overlay_reference('@VMODROOT/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert got == 'thirdparty/lib/lib.h', 'vmodroot references resolve, got ${got}' + } else { + assert false, 'a present thirdparty file must resolve' + } + if got := resolve_thirdparty_overlay_reference('thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert got == 'thirdparty/lib/lib.h', 'relative references resolve from the source dir, got ${got}' + } else { + assert false, 'a relative thirdparty file must resolve' + } + if _ := resolve_thirdparty_overlay_reference('@VMODROOT/thirdparty/lib/missing.h', source_file, normalize_overlay_path(root)) { + assert false, 'a missing thirdparty file must be none' + } else { + assert true, 'a missing thirdparty file is none' + } + if _ := resolve_thirdparty_overlay_reference('/abs/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert false, 'an absolute reference must be none' + } else { + assert true, 'an absolute reference is none' + } + if _ := resolve_thirdparty_overlay_reference('@VEXEROOT/thirdparty/lib/lib.h', source_file, normalize_overlay_path(root)) { + assert false, 'a vexeroot reference must be none' + } else { + assert true, 'a vexeroot reference is none' + } + interop_test_must_write_file(os.join_path(root, 'plain.v'), 'module main\n') + if _ := resolve_thirdparty_overlay_reference('plain.v', source_file, normalize_overlay_path(root)) { + assert false, 'a non-thirdparty reference must be none' + } else { + assert true, 'a non-thirdparty reference is none' + } +} + +fn test_cov_referenced_thirdparty_rel_paths_collects() { + base := os.join_path(os.temp_dir(), 'vls_cov_refthird_${os.getpid()}') + root := os.join_path(base, 'proj') + lib := os.join_path(root, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(lib, 'embedded.json'), '{}\n') + main_content := "module main\n#flag -I @VMODROOT/thirdparty/lib\nconst x = \$embed_file('thirdparty/lib/embedded.json')\n" + main_file := os.join_path(root, 'main.v') + interop_test_must_write_file(main_file, main_content) + got := referenced_thirdparty_rel_paths(normalize_overlay_path(root), normalize_overlay_path(root), { + path_to_uri(main_file): main_content + }, []string{}) + assert got.contains('thirdparty/lib/embedded.json'), 'embed references are collected: ${got}' + assert got == got.clone().sorted(), 'references are sorted' +} + +fn test_cov_is_overlay_compilation_file_suffixes() { + assert is_overlay_compilation_file('v.mod'), 'the manifest is a compilation file' + assert is_overlay_compilation_file('main.v'), '.v is a compilation file' + assert is_overlay_compilation_file('run.vsh'), '.vsh is a compilation file' + assert is_overlay_compilation_file('native.h'), '.h is a compilation file' + assert is_overlay_compilation_file('lib.a'), '.a is a compilation file' + assert !is_overlay_compilation_file('notes.txt'), '.txt is an asset' + assert !is_overlay_compilation_file('data.json'), '.json is an asset' + assert !is_overlay_compilation_file('README.md'), '.md is an asset' +} + +fn test_cov_copy_bounded_overlay_entry_pass_cycle_and_filter() { + base := os.join_path(os.temp_dir(), 'vls_cov_pass_${os.getpid()}') + src := os.join_path(base, 'src') + dst := os.join_path(base, 'dst') + interop_test_must_mkdir_all(os.join_path(src, '.git')) + interop_test_must_mkdir_all(dst) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(src, 'main.v'), 'module main\n') + interop_test_must_write_file(os.join_path(src, 'notes.txt'), 'asset\n') + interop_test_must_write_file(os.join_path(src, '.git', 'hidden.v'), 'module hidden\n') + mut budget := new_overlay_copy_budget() + mut visited := map[string]bool{} + compilation := copy_bounded_overlay_entry_pass(src, dst, true, mut budget, mut visited) or { + assert false, 'the compilation pass must succeed: ${err}' + return + } + assert compilation == 1, 'only the .v file copies in the compilation pass, got ${compilation}' + assert os.is_file(os.join_path(dst, 'main.v')), 'the compilation file lands' + assert !os.exists(os.join_path(dst, '.git', 'hidden.v')), 'excluded dirs never copy' + again := copy_bounded_overlay_entry_pass(src, dst, true, mut budget, mut visited) or { + assert false, 'a repeated pass must succeed: ${err}' + return + } + assert again == 0, 'visited dirs and existing targets copy nothing, got ${again}' + mut assets_visited := map[string]bool{} + assets := copy_bounded_overlay_entry_pass(src, os.join_path(base, 'assets'), false, mut budget, mut + assets_visited) or { + assert false, 'the asset pass must succeed: ${err}' + return + } + assert assets == 1, 'only the asset copies in the asset pass, got ${assets}' +} + +fn test_cov_materialize_referenced_thirdparty_inputs_copies() { + base := os.join_path(os.temp_dir(), 'vls_cov_matthird_${os.getpid()}') + src := os.join_path(base, 'src') + dst := os.join_path(base, 'dst') + lib := os.join_path(src, 'thirdparty', 'lib') + interop_test_must_mkdir_all(lib) + interop_test_must_mkdir_all(dst) + defer { + os.rmdir_all(base) or {} + } + interop_test_must_write_file(os.join_path(lib, 'lib.v'), 'module lib\n') + interop_test_must_write_file(os.join_path(lib, 'notes.txt'), 'asset\n') + mut budget := new_overlay_copy_budget() + materialize_referenced_thirdparty_inputs(normalize_overlay_path(src), os.join_path(dst, ''), [ + 'thirdparty/lib', + ], mut budget) or { + assert false, 'materialize must succeed: ${err}' + return + } + assert os.is_file(os.join_path(dst, 'thirdparty', 'lib', 'lib.v')), 'referenced compilation inputs copy' + assert !os.exists(os.join_path(dst, 'thirdparty', 'lib', 'notes.txt')), 'assets do not copy for dir references' +} + +fn test_cov_own_overlay_dirs_creates_missing_chain() { + base := os.join_path(os.temp_dir(), 'vls_cov_own_${os.getpid()}') + src := os.join_path(base, 'src') + tmp := os.join_path(base, 'tmp') + interop_test_must_mkdir_all(os.join_path(src, 'a', 'b')) + interop_test_must_mkdir_all(tmp) + defer { + os.rmdir_all(base) or {} + } + mut budget := new_overlay_copy_budget() + owned := own_overlay_dirs(normalize_overlay_path(src), tmp, 'a/b', mut budget) or { + assert false, 'own must succeed: ${err}' + return + } + assert owned.len == 0, 'created dirs need no link replacement, got ${owned}' + assert os.is_dir(os.join_path(tmp, 'a', 'b')), 'the missing chain is created' +} + +fn test_cov_compiler_hover_augments_and_falls_back() { + payload := '{"contents":{"kind":"markdown","value":"fn helper()"}}' + payload_hover := compiler_hover(payload, '') + assert payload_hover is Hover, 'a compiler payload answers hover' + augmented := compiler_hover(payload, 'extra docs') + assert augmented is Hover, 'augmented hover stays hover' + if augmented is Hover { + assert (augmented as Hover).contents.value.contains('fn helper()'), 'the compiler value is kept' + assert (augmented as Hover).contents.value.contains('extra docs'), 'missing docs are appended' + } + unchanged := compiler_hover(payload, 'fn helper()') + assert unchanged is Hover, 'unchanged hover stays hover' + if unchanged is Hover { + assert !(unchanged as Hover).contents.value.contains('\n\nfn helper()'), 'present docs are not duplicated' + } + doc_only := compiler_hover('{}', 'vdoc comment') + assert doc_only is Hover, 'docs alone answer hover' + if doc_only is Hover { + assert (doc_only as Hover).contents.value == 'vdoc comment', 'docs alone answer hover' + } + assert compiler_hover('', '') == ResponseResult('null'), 'no payload and no docs answer null' +} + +fn test_cov_line_info_result_branches() { + base := os.join_path(os.temp_dir(), 'vls_cov_lires_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + source := os.join_path(base, 'main.v') + content := 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + open_files: { + uri: content + } + } + overlay := CompilationOverlay{} + completion := app.line_info_result(.completion, uri, '6:1', '{"details":[{"label":"println","kind":3},{"label":"+op","kind":3}]}', + false, '', overlay, '') + if completion is []Detail { + assert (completion as []Detail).len == 1, 'operator completions are filtered' + assert (completion as []Detail)[0].label == 'println', 'code completions are kept' + } else { + assert false, 'completion must decode to details' + } + assert app.line_info_result(.signature_help, uri, '1:1', '{"signatures":[]}', false, '', overlay, + '') == ResponseResult('null'), 'an empty signature answers null' + sig := app.line_info_result(.signature_help, uri, '1:1', '{"signatures":[{"label":"fn f()","parameters":[]}],"activeSignature":0}', + false, '', overlay, '') + assert sig is SignatureHelp, 'a found signature answers help' + hover := app.line_info_result(.hover, uri, '4:hv^1', '{"contents":{"kind":"markdown","value":"fn helper()"}}', + false, '', overlay, '') + assert hover is Hover, 'a compiler hover answers hover' + assert app.line_info_result(.inlay_hint, uri, '1:1', '', false, '', overlay, '') == ResponseResult('null'), 'an empty inlay answer is null' + hints := app.line_info_result(.inlay_hint, uri, '1:1', '{"inlay_hints":[{"line":0,"col":1,"label":": int","kind":1,"tooltip":""}]}', + false, '', overlay, '') + assert hints is []InlayHint, 'inlay payload decodes to hints' + assert app.line_info_result(.definition, uri, '1:1', '', false, '', overlay, '') == ResponseResult('null'), 'an empty definition is null' + definition := app.line_info_result(.definition, uri, '1:1', '${source}:3:5', false, '', overlay, + '') + assert definition is Location, 'a definition maps to a location' + if definition is Location { + assert (definition as Location).uri == uri, 'the definition reuses the open uri' + assert (definition as Location).range.start.line == 2, 'definition lines are zero-based' + } +} + +fn test_cov_run_v_argv_cancelled_branches() { + cancelled := run_v_argv_cancelled(['-check', '.'], '', fn () bool { + return true + }) + assert cancelled.exit_code == diagnostics_check_cancelled, 'a cancelled check reports cancellation' + missing_dir := os.join_path(os.temp_dir(), 'vls_cov_nodir_${os.getpid()}') + missing := run_v_argv_cancelled(['-check', '.'], missing_dir, fn () bool { + return false + }) + assert missing.exit_code != 0, 'a missing work dir fails' + assert missing.output.contains('Working dir does not exist'), 'a missing work dir is reported' + previous := os.getenv('VLS_V_COMMAND') + defer { + restore_v_command(previous) + } + os.setenv('VLS_V_COMMAND', os.join_path(os.temp_dir(), 'vls_cov_no_compiler_${os.getpid()}'), true) + base := os.join_path(os.temp_dir(), 'vls_cov_argv_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + gone := run_v_argv_cancelled(['-check', '.'], base, fn () bool { + return false + }) + assert gone.exit_code != 0, 'a missing compiler fails' + assert gone.output.contains('not found'), 'a missing compiler is reported: ${gone.output}' +} + +fn test_cov_run_v_line_info_once_singlefile_via_stub() { + $if windows { + // The stand-in compiler is a POSIX shell script. + return + } + previous := os.getenv('VLS_V_COMMAND') + mut app, uri, root := line_info_stub_app('vls_cov_once', compat_matrix_modern_direct_stub) + defer { + restore_v_command(previous) + os.rmdir_all(root) or {} + } + work_dir := os.dir(uri_to_path(uri)) + result := app.run_v_line_info_once(.hover, uri, '6:hv^4', work_dir) + assert app.line_info_mode == .direct, 'a direct compiler probes direct' + assert result is Hover, 'a direct single-file lookup answers hover' + if result is Hover { + assert (result as Hover).contents.value.contains('fn helper()'), 'the stub payload is kept' + } +} + +fn test_cov_run_v_line_info_pooled_returns_none_without_pool() { + mut compat_app := App{ + line_info_mode: .compat + } + if _ := compat_app.run_v_line_info_pooled(.hover, 'file:///tmp/x.v', '1:hv^1') { + assert false, 'compat mode never uses the pool' + } else { + assert true, 'compat mode skips the pool' + } + mut missing_app := App{ + line_info_mode: .missing + } + if _ := missing_app.run_v_line_info_pooled(.hover, 'file:///tmp/x.v', '1:hv^1') { + assert false, 'missing mode never uses the pool' + } else { + assert true, 'missing mode skips the pool' + } +} + +fn test_cov_names_a_local_detects_locals() { + content := 'module main\n\nfn main() {\n\tx := 1\n\tprintln(x)\n}\n' + line := '\tprintln(x)' + mut app := App{} + at_use := Position{ + line: 4 + char: 10 + } + assert app.names_a_local(content, line, 'x', at_use), 'a use names its local' + assert !app.names_a_local(content, line, 'zzz', at_use), 'an unknown name is not a local' + params := 'module main\n\nfn greet(name string) {\n\tprintln(name)\n}\n' + param_line := '\tprintln(name)' + param_pos := Position{ + line: 3 + char: 14 + } + assert app.names_a_local(params, param_line, 'name', param_pos), 'a use names its parameter' +} + +fn test_cov_diag_cache_base_dir_override_and_default() { + previous := os.getenv('VLS_DIAG_CACHE_DIR') + defer { + restore_diag_cache_dir(previous) + } + custom := os.join_path(os.temp_dir(), 'vls_cov_cachebase_${os.getpid()}') + os.setenv('VLS_DIAG_CACHE_DIR', custom, true) + assert diag_cache_base_dir() == custom, 'an override wins' + os.unsetenv('VLS_DIAG_CACHE_DIR') + assert diag_cache_base_dir() == os.join_path(os.cache_dir(), 'vls', 'check-diag'), 'the default joins the cache dir' +} + +fn test_cov_code_lens_complete_utf8_prefix_len_boundaries() { + assert code_lens_complete_utf8_prefix_len('') == 0, 'empty input gives 0' + assert code_lens_complete_utf8_prefix_len('abc') == 3, 'ascii is complete' + full := 'abé' + assert code_lens_complete_utf8_prefix_len(full) == full.len, 'a complete multi-byte tail is kept' + truncated := full[..full.len - 1] + assert code_lens_complete_utf8_prefix_len(truncated) == 2, 'a split multi-byte tail is cut, got ${code_lens_complete_utf8_prefix_len(truncated)}' + emoji := 'a🙂' + assert code_lens_complete_utf8_prefix_len(emoji) == emoji.len, 'a complete emoji is kept' + cut_emoji := emoji[..emoji.len - 1] + assert code_lens_complete_utf8_prefix_len(cut_emoji) == 1, 'a split emoji is cut' +} + +fn test_cov_v_source_code_mask_hides_strings_and_comments() { + mask := v_source_code_mask('a "b" c') + mask_str := mask.bytestr() + assert mask.len == 'a "b" c'.len, 'the mask keeps the length' + assert mask_str[0..1] == 'a', 'code before a string is kept' + assert mask_str[6..7] == 'c', 'code after a string is kept' + assert mask_str[2..5] == ' ', 'string contents are hidden: ${mask_str}' + line_mask := v_source_code_mask('code // comment\nnext') + line_str := line_mask.bytestr() + assert line_str.contains('code'), 'code before a comment is kept' + assert !line_str.contains('comment'), 'line comments are hidden: ${line_str}' + assert line_str.contains('\n'), 'newlines survive the mask' + block_mask := v_source_code_mask('/* hidden */ code') + block_str := block_mask.bytestr() + assert block_str.ends_with('code'), 'code after a block comment is kept' + assert !block_str.contains('hidden'), 'block comments are hidden: ${block_str}' + interp_mask := v_source_code_mask("'\${x}y'") + interp_str := interp_mask.bytestr() + assert interp_str.contains('x'), 'interpolations stay visible: ${interp_str}' +} + +fn test_cov_code_lens_mask_token_and_hash_directive() { + source := 'println(@FILE) @FILEX' + mask := v_source_code_mask(source) + at := source.index('@FILE') or { -1 } + assert at >= 0, 'the fixture contains @FILE' + assert code_lens_mask_has_at_token(mask, at, '@FILE'), '@FILE matches at its position' + assert !code_lens_mask_has_at_token(mask, at + 1, '@FILE'), '@FILE does not match off by one' + assert !code_lens_mask_has_at_token(mask, mask.len - 2, '@FILE'), 'a token past the end never matches' + second := source.last_index('@FILE') or { -1 } + assert !code_lens_mask_has_at_token(mask, second, '@FILE'), '@FILEX is not @FILE' + hash_source := '#flag -I @VMODROOT/x\nprintln(@FILE)' + hash_mask := v_source_code_mask(hash_source) + hash_at := hash_source.index('@VMODROOT') or { -1 } + plain_at := hash_source.index('@FILE') or { -1 } + assert code_lens_token_is_in_hash_directive(hash_mask, hash_at), 'a flag token sits in a hash directive' + assert !code_lens_token_is_in_hash_directive(hash_mask, plain_at), 'a call token is not a directive' +} + +fn test_cov_code_lens_run_target_and_executable_and_args() { + base := os.join_path(os.temp_dir(), 'vls_cov_target_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n') + holder := App{} + mut main_job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + main_target := code_lens_run_target(main_job) + assert main_target.starts_with('main:'), 'main jobs tag main: ${main_target}' + assert main_target.ends_with(':${main_job.fn_name}'), 'the target carries the function name' + file_job := CodeLensRunJob{ + kind: .test_file + title: 'Run File' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + assert code_lens_run_target(file_job).starts_with('test-file:'), 'file jobs tag test-file' + fn_job := CodeLensRunJob{ + kind: .test_function + title: 'Run Test' + uri: path_to_uri(path) + path: path + fn_name: 'test_one' + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + fn_target := code_lens_run_target(fn_job) + assert fn_target.starts_with('test-function:'), 'function jobs tag test-function: ${fn_target}' + assert fn_target.ends_with(':test_one'), 'function targets carry the test name: ${fn_target}' + exe := code_lens_executable_path(base) + assert exe.contains('code_lens_program'), 'the executable is named: ${exe}' + $if windows { + assert exe.ends_with('.exe'), 'windows executables carry .exe: ${exe}' + } $else { + assert !exe.ends_with('.exe'), 'posix executables carry no .exe: ${exe}' + } + assert code_lens_compile_args(main_job, path, exe) == build_v_run_compile_args(exe), 'main compiles as a run' + assert code_lens_compile_args(file_job, path, exe) == build_v_test_compile_args(path, '', exe), 'a file compiles as a test file' + assert code_lens_compile_args(fn_job, path, exe) == build_v_test_compile_args(path, 'test_one', exe), 'a function compiles with -run-only' + assert code_lens_display_args(main_job) == build_v_run_args(), 'main displays as a run' + assert code_lens_display_args(file_job) == build_v_test_args(path, ''), 'a file displays as a test file' + assert code_lens_display_args(fn_job) == build_v_test_args(path, 'test_one'), 'a function displays with -run-only' +} + +fn test_cov_run_command_manager_lifecycle() { + mut manager := new_run_command_manager() + assert manager.next_id == 0, 'a fresh manager has no ids' + base := os.join_path(os.temp_dir(), 'vls_cov_mgr_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n') + holder := App{} + job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + } + target := code_lens_run_target(job) + id, accepted := manager.begin_job(target) + assert accepted, 'a fresh target is accepted' + assert !manager.job_is_cancelled(id, target), 'a fresh job is not cancelled' + manager.cancel_process_locked(id) + assert !manager.job_is_cancelled(id, target), 'an empty cancel changes nothing' + id2, accepted2 := manager.begin_job(target) + assert accepted2, 'a newer run for the same target is accepted' + assert manager.job_is_cancelled(id, target), 'the older run is cancelled' + assert !manager.job_is_cancelled(id2, target), 'the newer run is current' + mut dummy := os.new_process('v') + assert !manager.register_process(999, target, dummy), 'an unknown id never registers' + assert manager.register_process(id2, target, dummy), 'the current id registers' + manager.unregister_process(id2) + assert !manager.job_is_cancelled(id2, target), 'unregistering keeps the target current' + manager.finish_job(id, target) + manager.finish_job(id2, target) + assert manager.job_is_cancelled(id2, target), 'a finished target reads as cancelled' + // A stopped manager refuses every new worker without spawning anything. + manager.cancel_all_and_wait() + _, refused := manager.begin_job(target) + assert !refused, 'a stopped manager refuses new jobs' + assert !manager.launch(job), 'a stopped manager refuses launches' + assert manager.run_sync(job) == [], 'a stopped manager runs nothing synchronously' + assert manager.job_is_cancelled(id2, target), 'a stopped manager cancels everything' + mut stopped_dummy := os.new_process('v') + assert !manager.register_process(id2, target, stopped_dummy), 'a stopped manager registers nothing' +} + +fn test_cov_run_managed_process_cancelled_and_missing_dir() { + mut manager := new_run_command_manager() + base := os.join_path(os.temp_dir(), 'vls_cov_managed_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + target := 'main:4:${base}:' + old_id, _ := manager.begin_job(target) + new_id, _ := manager.begin_job(target) + stale := run_managed_process(mut manager, old_id, target, 'v', [], base) + assert stale.cancelled, 'a replaced job is cancelled without spawning' + assert stale.result.exit_code == 0, 'a cancelled job carries no result' + missing := run_managed_process(mut manager, new_id, target, 'v', [], os.join_path(base, 'no-such-dir')) + assert !missing.cancelled, 'a current job is not cancelled' + assert missing.result.exit_code != 0, 'a missing work dir fails' + assert missing.result.output.contains('Working dir does not exist'), 'a missing work dir is reported' + manager.finish_job(old_id, target) + manager.finish_job(new_id, target) + manager.cancel_all_and_wait() +} + +fn test_cov_log_code_lens_output_and_get_manager() { + mut worker := App{ + capture_output: true + } + overlay := CompilationOverlay{ + source_display_root: '/src/proj' + temp_root: '/tmp/overlay' + } + log_code_lens_output(mut worker, os.Result{ + exit_code: 0 + output: 'built /tmp/overlay/main.v\n' + }, overlay) + assert worker.captured_output.len == 1, 'compiler output is logged' + assert worker.captured_output[0].contains('/src/proj/main.v'), 'overlay paths map back to sources' + assert worker.captured_output[0].contains('logMessage'), 'output goes to the log channel' + mut quiet := App{ + capture_output: true + } + log_code_lens_output(mut quiet, os.Result{ + exit_code: 0 + }, overlay) + assert quiet.captured_output.len == 0, 'empty output logs nothing' + mut app := App{} + first := app.get_run_command_manager() + second := app.get_run_command_manager() + first.cancel_all_and_wait() + _, accepted := second.begin_job('probe') + assert !accepted, 'both handles are the same manager' + app.stop_run_commands() +} + +fn test_cov_preserve_code_lens_overlay_rewrites_pseudos() { + base := os.join_path(os.temp_dir(), 'vls_cov_pseudo_${os.getpid()}') + src := os.join_path(base, 'src') + tmp := os.join_path(base, 'tmp') + interop_test_must_mkdir_all(src) + interop_test_must_mkdir_all(tmp) + defer { + os.rmdir_all(base) or {} + } + source := 'module main\n\nfn main() {\n\tprintln(@FILE)\n}\n' + source_path := os.join_path(src, 'main.v') + interop_test_must_write_file(source_path, source) + temp_path := os.join_path(tmp, 'main.v') + interop_test_must_write_file(temp_path, source) + overlay := CompilationOverlay{ + source_root: normalize_overlay_path(src) + source_display_root: normalize_overlay_path(src) + temp_root: normalize_overlay_path(tmp) + source_work_dir: normalize_overlay_path(src) + temp_work_dir: normalize_overlay_path(tmp) + temp_source_file: normalize_overlay_path(temp_path) + } + open_sources := { + normalize_overlay_path(source_path): source + } + preserve_code_lens_overlay_dir(overlay, normalize_overlay_path(tmp), open_sources) or { + assert false, 'preserve dir must succeed: ${err}' + return + } + rewritten := os.read_file(temp_path) or { '' } + assert !rewritten.contains('@FILE'), 'pseudos are rewritten: ${rewritten}' + assert rewritten.contains('main.v'), 'the real file name survives: ${rewritten}' + // The map form derives the same sources from open files. + interop_test_must_write_file(temp_path, source) + preserve_code_lens_overlay_source_paths(overlay, { + path_to_uri(source_path): source + }) or { + assert false, 'preserve paths must succeed: ${err}' + return + } + again := os.read_file(temp_path) or { '' } + assert !again.contains('@FILE'), 'the map form rewrites too: ${again}' +} + +fn test_cov_run_code_lens_job_cancelled_and_start_refused() { + mut manager := new_run_command_manager() + base := os.join_path(os.temp_dir(), 'vls_cov_codelens_${os.getpid()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + path := os.join_path(base, 'main.v') + interop_test_must_write_file(path, 'module main\n\nfn main() {}\n') + holder := App{} + job := CodeLensRunJob{ + kind: .main + title: 'Run Main' + uri: path_to_uri(path) + path: path + open_files: map[string]string{} + write_mutex: holder.write_mutex + capture_output: true + } + target := code_lens_run_target(job) + old_id, _ := manager.begin_job(target) + new_id, _ := manager.begin_job(target) + out := run_code_lens_job(mut manager, old_id, target, job) + assert out.len == 1, 'a cancelled job only keeps its start log line: ${out}' + assert out[0].contains('Run Main'), 'the start log names the job: ${out[0]}' + assert !out[0].contains('showMessage'), 'a cancelled job sends no show message' + // run_code_lens_job already finished old_id; only the replacing job is left. + manager.finish_job(new_id, target) + // A stopping server refuses new runs with a message instead of spawning. + mut app := App{ + capture_output: true + } + app.run_command_manager = new_run_command_manager() + app.get_run_command_manager().cancel_all_and_wait() + app.start_code_lens_run(job) + assert app.captured_output.len == 1, 'a refused start tells the client' + assert app.captured_output[0].contains('cannot start'), 'the refusal names the problem: ${app.captured_output[0]}' + app.stop_run_commands() +} diff --git a/lsp_test.v b/lsp_test.v index cfd6ef5b..38750437 100644 --- a/lsp_test.v +++ b/lsp_test.v @@ -1937,3 +1937,25 @@ fn test_a_request_that_asks_no_compiler_goes_before_one_that_may() { eof: true }, tokens]) == 0 } + +fn test_position_to_json_encodes_line_and_character() { + assert position_to_json(Position{ + line: 1 + char: 2 + }) == '{"line":1,"character":2}', 'position encodes line and character' + assert position_to_json(Position{}) == '{"line":0,"character":0}', 'zero position encodes zeros' +} + +fn test_lsprange_to_json_nests_start_and_end() { + r := LSPRange{ + start: Position{ + line: 1 + char: 2 + } + end: Position{ + line: 3 + char: 4 + } + } + assert lsprange_to_json(r) == '{"start":{"line":1,"character":2},"end":{"line":3,"character":4}}', 'range nests both positions' +} diff --git a/main_test.v b/main_test.v index 374c0d7a..3ebc3aeb 100644 --- a/main_test.v +++ b/main_test.v @@ -115,3 +115,197 @@ fn test_find_v_dir_from_exe_rejects_unresolved_name() { fn test_find_v_dir_from_exe_rejects_missing_file() { assert find_v_dir_from_exe(os.join_path(os.temp_dir(), 'vls_no_such_v_exe_9f3a')) == '' } + +fn test_main_is_loopback_host_accepts_local_only() { + assert is_loopback_host('127.0.0.1'), 'IPv4 loopback must be loopback' + assert is_loopback_host('::1'), 'IPv6 loopback must be loopback' + assert is_loopback_host('localhost'), 'localhost must be loopback' + assert is_loopback_host(''), 'empty host must be loopback' + assert !is_loopback_host('0.0.0.0'), 'all-interfaces bind must not be loopback' + assert !is_loopback_host('192.168.1.1'), 'LAN address must not be loopback' + assert !is_loopback_host('example.com'), 'public hostname must not be loopback' +} + +fn test_main_method_may_ask_compiler_splits_compiler_backed_methods() { + assert method_may_ask_compiler(.definition), 'definition may wait for the compiler' + assert method_may_ask_compiler(.hover), 'hover may wait for the compiler' + assert method_may_ask_compiler(.rename), 'rename may wait for the compiler' + assert method_may_ask_compiler(.formatting), 'formatting may wait for v fmt' + assert method_may_ask_compiler(.inlay_hint), 'inlay_hint may wait for the compiler' + assert !method_may_ask_compiler(.initialize), 'initialize never waits for the compiler' + assert !method_may_ask_compiler(.semantic_tokens), 'semantic_tokens never waits for the compiler' + assert !method_may_ask_compiler(.shutdown), 'shutdown never waits for the compiler' + assert !method_may_ask_compiler(.did_open), 'did_open never waits for the compiler' +} + +fn test_main_method_never_asks_compiler_lists_index_only_methods() { + assert method_never_asks_compiler(.semantic_tokens), 'semantic_tokens is index-only' + assert method_never_asks_compiler(.semantic_tokens_range), 'semantic_tokens_range is index-only' + assert method_never_asks_compiler(.document_symbols), 'document_symbols is index-only' + assert method_never_asks_compiler(.folding_range), 'folding_range is index-only' + assert method_never_asks_compiler(.selection_range), 'selection_range is index-only' + assert method_never_asks_compiler(.code_action), 'code_action is index-only' + assert !method_never_asks_compiler(.hover), 'hover may ask the compiler' + assert !method_never_asks_compiler(.definition), 'definition may ask the compiler' + assert !method_never_asks_compiler(.rename), 'rename may ask the compiler' +} + +fn test_main_split_mime_parameters_keeps_quoted_semicolons() { + assert split_mime_parameters('application/json') == ['application/json'], 'type without params stays whole' + assert split_mime_parameters('text/plain; charset=utf-8') == ['text/plain', 'charset=utf-8'], 'plain parameter splits' + quoted := split_mime_parameters('text/plain; title="a;b"; charset=utf-8') + assert quoted == ['text/plain', 'title="a;b"', 'charset=utf-8'], 'semicolon inside quotes must not split' +} + +fn test_main_unquote_mime_parameter_decodes_quoted_pairs() { + assert unquote_mime_parameter('"utf-8"') == 'utf-8', 'quoted charset unquotes' + assert unquote_mime_parameter('utf-8') == 'utf-8', 'bare value passes through' + assert unquote_mime_parameter('""') == '', 'empty quotes decode to empty' + assert unquote_mime_parameter('"a\\"b"') == 'a"b', 'quoted-pair escape decodes' + assert unquote_mime_parameter('"abc\\"') == '"abc\\"', 'dangling escape stays invalid' +} + +fn test_main_charset_is_unsupported_rejects_non_utf8() { + assert !charset_is_unsupported('application/json'), 'no charset means UTF-8' + assert !charset_is_unsupported('text/plain; charset=utf-8'), 'utf-8 is supported' + assert !charset_is_unsupported('text/plain; charset=UTF8'), 'historic utf8 spelling is supported' + assert !charset_is_unsupported('text/plain; charset="utf-8"'), 'quoted utf-8 is supported' + assert charset_is_unsupported('text/plain; charset=latin-1'), 'latin-1 is unsupported' + assert charset_is_unsupported('text/plain; CHARSET=UTF-16'), 'charset name match is case-insensitive' + assert charset_is_unsupported('text/plain; boundary=x; charset=iso-8859-1'), 'unrelated params are skipped' +} + +fn test_main_raw_id_to_int_collapses_non_numeric_ids() { + assert raw_id_to_int('') == 0, 'absent id collapses to 0' + assert raw_id_to_int('"abc"') == 0, 'string id collapses to 0' + assert raw_id_to_int('null') == 0, 'null id collapses to 0' + assert raw_id_to_int('5') == 5, 'numeric id converts' + assert raw_id_to_int('-3') == -3, 'negative id converts' +} + +fn test_main_json_is_ws_matches_insignificant_bytes_only() { + assert json_is_ws(u8(32)), 'space is whitespace' + assert json_is_ws(u8(9)), 'tab is whitespace' + assert json_is_ws(u8(10)), 'newline is whitespace' + assert json_is_ws(u8(13)), 'carriage return is whitespace' + assert !json_is_ws(u8(65)), 'letter is not whitespace' + assert !json_is_ws(u8(123)), 'brace is not whitespace' + assert !json_is_ws(u8(48)), 'digit is not whitespace' +} + +fn test_main_hex4_to_int_parses_four_hex_digits() { + assert hex4_to_int('0041') or { -1 } == 65, 'hex 0041 is 65' + assert hex4_to_int('ffff') or { -1 } == 65535, 'lowercase ffff parses' + assert hex4_to_int('ABCD') or { -1 } == 43981, 'uppercase ABCD parses' + assert hex4_to_int('00G1') == none, 'non-hex digit is none' + assert hex4_to_int('123') == none, 'short input is none' + assert hex4_to_int('12345') == none, 'long input is none' + assert hex4_to_int('') == none, 'empty input is none' +} + +fn test_main_decode_json_unicode_escape_reads_bmp_and_astral() { + if cp, adv := decode_json_unicode_escape('\\u0041', 0) { + assert cp == 65, 'U+0041 decodes to 65' + assert adv == 6, 'BMP escape consumes 6 bytes' + } else { + assert false, 'valid BMP escape must decode' + } + if cp, adv := decode_json_unicode_escape('\\uD83D\\uDE00', 0) { + assert cp == 128512, 'surrogate pair decodes to U+1F600' + assert adv == 12, 'pair consumes 12 bytes' + } else { + assert false, 'valid surrogate pair must decode' + } + assert decode_json_unicode_escape('\\uZZZZ', 0) == none, 'non-hex escape is none' + assert decode_json_unicode_escape('\\u00', 0) == none, 'truncated escape is none' + assert decode_json_unicode_escape('\\uD83D', 0) == none, 'unpaired high surrogate is none' +} + +fn test_main_skip_json_value_stops_after_one_value() { + assert skip_json_value('{"a":1},rest', 0) == 7, 'object value ends after closing brace' + assert skip_json_value('[1,[2]],x', 0) == 7, 'nested arrays end after matching bracket' + assert skip_json_value('"ab\\"cd" ,', 0) == 8, 'escaped quote does not end the string' + assert skip_json_value('123, 456', 0) == 3, 'number ends before comma' + assert skip_json_value('true}', 0) == 4, 'keyword ends before brace' + assert skip_json_value('', 0) == 0, 'empty input stays at start' +} + +fn test_main_content_has_member_reads_top_level_keys_only() { + assert content_has_member('{"id":1,"method":"initialize"}', 'id'), 'top-level id is found' + assert content_has_member('{"id":1,"method":"initialize"}', 'method'), 'top-level method is found' + assert !content_has_member('{"id":1,"method":"initialize"}', 'result'), 'absent key is not found' + assert !content_has_member('{"params":{"id":99}}', 'id'), 'nested id must not count as top-level' + assert !content_has_member('{}', 'id'), 'empty object has no members' + assert !content_has_member('not json', 'id'), 'non-object has no members' +} + +fn test_main_matches_needle_at_checks_byte_prefix() { + assert matches_needle_at('hello', 1, 'ell'), 'matching offset reports true' + assert !matches_needle_at('hello', 2, 'ell'), 'mismatching offset reports false' + assert !matches_needle_at('hi', 5, 'x'), 'overflow start reports false' + assert !matches_needle_at('hi', 0, 'hello'), 'needle longer than rest reports false' + assert matches_needle_at('abc', 3, ''), 'empty needle always matches' +} + +fn test_main_derive_diagnostic_code_and_tags_maps_known_messages() { + code1, tags1 := derive_diagnostic_code_and_tags('unused variable `x`') + assert code1 or { '' } == 'unused_variable', 'unused variable maps to unused_variable' + assert (tags1 or { []int{} }) == [1], 'unused_variable carries tag 1' + code2, tags2 := derive_diagnostic_code_and_tags('UNUSED IMPORT os') + assert code2 or { '' } == 'unused_import', 'match is case-insensitive' + assert (tags2 or { []int{} }) == [1], 'unused_import carries tag 1' + code3, tags3 := derive_diagnostic_code_and_tags('fn is deprecated') + assert code3 or { '' } == 'deprecated', 'deprecated maps to deprecated' + assert (tags3 or { []int{} }) == [2], 'deprecated carries tag 2' + code4, tags4 := derive_diagnostic_code_and_tags('undefined: foo') + assert code4 or { '' } == 'undefined', 'undefined maps to undefined' + assert tags4 == none, 'undefined carries no tags' + code5, _ := derive_diagnostic_code_and_tags('cannot convert int to string') + assert code5 or { '' } == 'type_mismatch', 'conversion failure maps to type_mismatch' + code6, _ := derive_diagnostic_code_and_tags('unknown module `foo`') + assert code6 or { '' } == 'unknown_module', 'unknown module maps to unknown_module' + code7, tags7 := derive_diagnostic_code_and_tags('some other error') + assert code7 == none, 'unknown message has no code' + assert tags7 == none, 'unknown message has no tags' +} + +fn test_main_method_requires_response_separates_requests_from_notifications() { + assert method_requires_response(.initialize), 'initialize expects a response' + assert method_requires_response(.completion), 'completion expects a response' + assert method_requires_response(.shutdown), 'shutdown expects a response' + assert method_requires_response(.code_action), 'code_action expects a response' + assert method_requires_response(.workspace_symbol), 'workspace_symbol expects a response' + assert !method_requires_response(.initialized), 'initialized is notification-only' + assert !method_requires_response(.did_open), 'did_open is notification-only' + assert !method_requires_response(.did_change), 'did_change is notification-only' + assert !method_requires_response(.exit), 'exit is notification-only' + assert !method_requires_response(.set_trace), 'set_trace is notification-only' +} + +fn test_main_is_valid_v_identifier_name_checks_shape_only() { + assert is_valid_v_identifier_name('foo'), 'plain name is valid' + assert is_valid_v_identifier_name('_bar'), 'leading underscore is valid' + assert is_valid_v_identifier_name('foo123'), 'trailing digits are valid' + assert is_valid_v_identifier_name(' foo '), 'surrounding spaces are trimmed' + assert !is_valid_v_identifier_name(''), 'empty name is invalid' + assert !is_valid_v_identifier_name(' '), 'blank name is invalid' + assert !is_valid_v_identifier_name('2foo'), 'leading digit is invalid' + assert !is_valid_v_identifier_name('foo bar'), 'inner space is invalid' + assert !is_valid_v_identifier_name('foo!'), 'punctuation is invalid' +} + +fn test_main_make_request_failed_error_response_carries_code_and_message() { + resp := make_request_failed_error_response(7, 'rename would break the program') + assert resp.id == 7, 'failed response keeps the request id' + assert resp.error.code == jsonrpc_err_request_failed, 'failed response uses request-failed code' + assert resp.error.message == 'rename would break the program', 'failed response keeps the message' +} + +fn test_main_make_invalid_request_error_response_defaults_empty_message() { + custom := make_invalid_request_error_response(3, 'bad shape') + assert custom.id == 3, 'invalid response keeps the request id' + assert custom.error.code == jsonrpc_err_invalid_request, 'invalid response uses invalid-request code' + assert custom.error.message == 'bad shape', 'invalid response keeps a custom message' + fallback := make_invalid_request_error_response(4, '') + assert fallback.error.message == 'Invalid request', 'empty message falls back to default' +} From 7fd054082ebe3ed100a03772afe8b1bb2a10468b Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 8 Oct 2026 23:50:51 +0330 Subject: [PATCH 15/83] test: cover rename, module-import, expression-type helpers; fix temp-path collisions Direct tests for the remaining pure helpers in rename, rename_conflicts, module_imports (now fully covered) and expression_types. make_unique_temp_path gains a per-process counter suffix: the Windows clock returned the same tick 19987 times in 20000 probes, so pid plus timestamp collided by construction. --- handlers_test.v | 517 ++++++++++++++++++++++++++++++++++++++++++++++++ interop.v | 17 +- 2 files changed, 530 insertions(+), 4 deletions(-) diff --git a/handlers_test.v b/handlers_test.v index 0aa7373d..c375e12d 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -15429,3 +15429,520 @@ fn test_cov_add_workspace_symbol() { assert results.len == 1, 'duplicate suppressed' assert results[0].name == 'foo' && results[0].location.uri == 'file:///a.v', 'symbol kept' } + +fn test_cov_rename_conflicts_pure_helpers() { + // Renaming replaces the bytes of each span and nothing else. + single := renamed_text('fn foo() {\n\tfoo()\n}', [ + RenameSpan{ + line: 1 + start: 1 + end: 4 + }, + ], 'bar') + assert single == 'fn foo() {\n\tbar()\n}', 'span renamed, rest kept' + multi := renamed_text('a foo b foo', [ + RenameSpan{ + line: 0 + start: 2 + end: 5 + }, + RenameSpan{ + line: 0 + start: 8 + end: 11 + }, + ], 'qux') + assert multi == 'a qux b qux', 'every span renamed' + assert renamed_text('hi', [ + RenameSpan{ + line: 5 + start: 0 + end: 2 + }, + ], 'x') == 'hi', 'span past the end skipped' + mut check_spans := map[string][]RenameSpan{} + check_spans['/a.v'] = [ + RenameSpan{ + line: 0 + start: 4 + end: 7 + }, + ] + rc := RenameCheck{ + old_name: 'foo' + new_name: 'longer' + spans: check_spans + } + assert rc.renamed_col('/a.v', 0, 2) == 2, 'column before the span kept' + assert rc.renamed_col('/a.v', 0, 4) == 4, 'span start kept' + assert rc.renamed_col('/a.v', 0, 7) == 10, 'column after the span shifted by the growth' + assert rc.renamed_col('/b.v', 0, 9) == 9, 'file without spans kept' + assert rc.original_pos(NamePos{ + path: '/a.v' + line: 0 + col: 4 + }) == NamePos{ + path: '/a.v' + line: 0 + col: 4 + }, 'position inside the new name maps back' + assert rc.original_pos(NamePos{ + path: '/a.v' + line: 0 + col: 10 + }) == NamePos{ + path: '/a.v' + line: 0 + col: 7 + }, 'position after the new name shifted back' + assert rc.original_pos(NamePos{ + path: '/b.v' + line: 0 + col: 3 + }) == NamePos{ + path: '/b.v' + line: 0 + col: 3 + }, 'file without spans kept' + assert rc.is_renamed(NamePos{ + path: '/a.v' + line: 0 + col: 4 + }), 'span start is renamed' + assert !rc.is_renamed(NamePos{ + path: '/a.v' + line: 0 + col: 0 + }), 'other column is not' + assert !rc.is_renamed(NamePos{ + path: '/b.v' + line: 0 + col: 4 + }), 'other file is not' + assert same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/a.v' + line: 1 + col: 6 + }), 'one byte of drift matches' + assert !same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/a.v' + line: 1 + col: 7 + }), 'two bytes differ' + assert !same_name_pos(NamePos{ + path: '/a.v' + line: 1 + col: 5 + }, NamePos{ + path: '/b.v' + line: 1 + col: 5 + }), 'other file differs' + assert name_pos_text(NamePos{ + path: '/tmp/proj/main.v' + line: 2 + col: 6 + }) == 'main.v:3:7', 'written from 1' + assert without_quoted_names('say `hi` loud') == 'say `` loud', 'quoted names dropped' + assert (new_check_message([], ['/tmp/main.v:4: error: boom']) or { '' }) == 'break the program: boom (main.v:4)', 'new error breaks' + assert (new_check_message([], ['program: warning: shaky']) or { '' }) == 'make V warn: shaky', 'program warning warns' + assert (new_check_message(['a `x` b'], ['a `y` b']) or { 'same' }) == 'same', 'renamed quotes compare equal' + assert (new_check_message(['a'], ['a']) or { 'same' }) == 'same', 'nothing new is none' + got_messages := check_messages('/tmp/main.v:4:7: error: boom `x`\nwarning: shaky\nnotice: fyi\n') + assert got_messages == ['/tmp/main.v:4: error: boom `x`', 'program: warning: shaky'], 'errors and warnings, no notices' + overlay := CompilationOverlay{ + source_display_root: '/src/proj' + temp_root: '/tmp/cov_overlay' + temp_work_dir: '/tmp/cov_overlay' + } + if pos := parse_name_pos('/tmp/cov_overlay/main.v:3:7', overlay) { + assert pos.path == '/src/proj/main.v' && pos.line == 2 && pos.col == 7, 'copy position mapped back' + } else { + assert false, 'valid answer parses' + } + assert parse_name_pos('', overlay) == none, 'empty answer is none' + assert parse_name_pos('/tmp/x.v:3', overlay) == none, 'answer without a column is none' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///cov_name_pos.v' + app.open_files[uri] = 'fn main() {}\n' + at := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 0, char: 3 } + end: Position{ line: 0, char: 7 } + } + } + named := app.name_pos_of(at) + assert named.line == 0 && named.col == 3, 'position in bytes' + assert named.path == '/cov_name_pos.v', 'path without the scheme' +} + +fn test_cov_rename_pure_helpers() { + old_cap := os.getenv('VLS_RENAME_MAX_OCCURRENCES') + defer { + if old_cap == '' { + os.unsetenv('VLS_RENAME_MAX_OCCURRENCES') + } else { + os.setenv('VLS_RENAME_MAX_OCCURRENCES', old_cap, true) + } + } + os.unsetenv('VLS_RENAME_MAX_OCCURRENCES') + assert rename_max_occurrences() == reference_semantic_max_candidates, 'default cap used' + os.setenv('VLS_RENAME_MAX_OCCURRENCES', '5', true) + assert rename_max_occurrences() == 5, 'environment cap used' + os.setenv('VLS_RENAME_MAX_OCCURRENCES', 'many', true) + assert rename_max_occurrences() == reference_semantic_max_candidates, 'garbage cap ignored' + decl_lines := ['fn foo() {', '\tx := 1', '}', 'fn bar() {', '}'] + assert declaration_end_line(decl_lines, decl_lines, 0) == 2, 'ends before the next declaration' + assert declaration_end_line(decl_lines, decl_lines, 3) == 4, 'last declaration runs on' + check_new_name('count', 'total') or { assert false, 'valid rename refused: ${err}' } + check_new_name('count', 'count') or { assert false, 'same name refused: ${err}' } + mut refused_empty := false + check_new_name('count', '') or { + refused_empty = true + assert err.msg().contains('valid'), 'empty name needs the valid-name reason' + } + assert refused_empty, 'empty name refused' + mut refused_keyword := false + check_new_name('count', 'fn') or { + refused_keyword = true + assert err.msg().contains('keyword'), 'keyword needs the keyword reason' + } + assert refused_keyword, 'keyword refused' + mut refused_case := false + check_new_name('Point', 'point') or { + refused_case = true + assert err.msg().contains('capital'), 'case needs the capital reason' + } + assert refused_case, 'capitalized type keeps its capital' + mut refused_lower := false + check_new_name('count', 'Total') or { + refused_lower = true + assert err.msg().contains('lowercase'), 'lowercase name stays lowercase' + } + assert refused_lower, 'lowercase name keeps its case' + str_content := 'fn f() {\n\ts := "{x}" // }\n}' + masked := code_only(str_content) + assert masked.len == str_content.len, 'masking keeps positions' + assert masked.contains('fn f()'), 'code kept' + assert !masked.contains('{x}') && !masked.contains('//'), 'strings and comments masked' + assert code_only('fn f() { s := "\${x}" }').contains('x'), 'interpolation is code' + assert in_comptime_branch(['\$if debug {', '\tx := 1', '}'], 1, 6), 'body of \$if is conditional' + assert !in_comptime_branch(['fn f() {', '\tx := 1', '}'], 1, 6), 'plain body is not' + assert declaration_receiver('fn (mut p Process) kill() {') == 'Process', 'receiver read' + assert declaration_receiver('pub fn (p Point) moved() Point {') == 'Point', 'pub receiver read' + assert declaration_receiver('fn plain() {') == '', 'plain function has none' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + assert app.rename_anchor_cache().len == 0, 'fresh cache is empty' + app.keep_rename_anchors(map[string]?Location{}) + uri := 'file:///cov_rename_helpers.v' + content := 'module main\n\nimport time\n\nfn f() {\n\ttime.now()\n}\n' + app.open_files[uri] = content + assert app.file_text(uri) == content, 'open buffer read' + assert app.index_doc_symbols(uri).len == 0, 'no index without one' + dot_use := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 5, char: 6 } + end: Position{ line: 5, char: 9 } + } + } + assert app.is_reached_through_dot(dot_use), 'name after a dot reached through it' + assert (app.line_text(dot_use) or { '' }) == '\ttime.now()', 'line read back' + assert app.line_at(dot_use) == '\ttime.now()', 'line at the location' + if word := app.word_location(uri, 5, 6) { + assert word.range.start.char == 6 && word.range.end.char == 9, 'word bounds found' + assert app.identifier_at(word) == 'now', 'identifier read' + } else { + assert false, 'word at the use found' + } + mod_qual := Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 5, char: 1 } + end: Position{ line: 5, char: 5 } + } + } + assert !app.is_reached_through_dot(mod_qual), 'qualifier not reached through a dot' + assert app.is_module_qualifier(mod_qual), 'imported name before a dot qualifies' + assert !app.is_module_qualifier(dot_use), 'called name does not' + mod_uri := 'file:///cov_sub/mod.v' + app.open_files[mod_uri] = 'module mymod\n\nfn f() {}\n' + mod_name := Location{ + uri: mod_uri + range: LSPRange{ + start: Position{ line: 0, char: 7 } + end: Position{ line: 0, char: 12 } + } + } + assert app.is_module_line_occurrence(mod_name), 'module line names a module' + assert !app.is_module_line_occurrence(dot_use), 'call does not' + assert app.in_module_of(dot_use, dot_use), 'same file is its own module' + assert !app.in_module_of(mod_name, dot_use), 'other module is not the same' + assert app.line_text(Location{ + uri: uri + range: LSPRange{ + start: Position{ line: 99, char: 0 } + end: Position{ line: 99, char: 1 } + } + }) == none, 'line past the end is none' +} + +fn test_cov_module_import_pure_helpers() { + assert attribute_value("@[deprecated_after: '2026-01-31']", 'deprecated_after') == '2026-01-31', 'single quotes read' + assert attribute_value('@[deprecated_after: "2026-01-31"]', 'deprecated_after') == '2026-01-31', 'double quotes read' + assert attribute_value('@[deprecated]', 'deprecated_after') == '', 'missing value empty' + assert attribute_value('', 'deprecated') == '', 'empty attribute empty' + after_imports := import_spot('module m\n\nimport os\n\nfn main() {}\n') + assert after_imports.line == 3 && after_imports.after == '\n', 'after the last import' + after_module := import_spot('module m\n\nfn main() {}\n') + assert after_module.line == 1 && after_module.before == '\n' && after_module.after == '\n', 'own paragraph after module' + bare_top := import_spot('fn main() {}\n') + assert bare_top.line == 0 && bare_top.after == '\n\n', 'top without a module' + edit := after_imports.edit('json') + assert edit.new_text == 'import json\n', 'import line written' + assert edit.range.start.line == 3 && edit.range.start.char == 0, 'edit at the spot' + str_content := 'fn main() {\n\tx := "hello"\n}\n' + assert position_in_string_or_comment(str_content, Position{ line: 1, char: 8 }, .utf8), 'inside a string' + assert !position_in_string_or_comment(str_content, Position{ line: 1, char: 2 }, .utf8), 'code is not' + comment_content := 'fn main() {\n\t// note\n\tx := 1\n}\n' + assert position_in_string_or_comment(comment_content, Position{ line: 1, char: 5 }, .utf8), 'inside a comment' + assert !position_in_string_or_comment(str_content, Position{ line: 0, char: 0 }, .utf8), 'start of line is not' + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + root := os.join_path(app.temp_dir, 'covimports') + must_mkdir_all(root) + must_write_file(os.join_path(root, 'main.v'), 'module main\n\nfn main() {}\n') + m1_dir := os.join_path(root, 'm1') + must_mkdir_all(m1_dir) + must_write_file(os.join_path(m1_dir, 'm1.v'), 'module m1\n\nimport os\n\npub fn hello() int {\n\treturn 1\n}\n') + m2_dir := os.join_path(root, 'm2') + must_mkdir_all(m2_dir) + must_write_file(os.join_path(m2_dir, 'm2.v'), 'module m2\n\nimport m1\n\npub fn double() int {\n\treturn 1\n}\n') + assert declared_module_of_dir(m1_dir) == 'm1', 'module read' + assert declared_module_of_dir(root) == 'main', 'file without module is main' + assert declared_module_of_dir(os.join_path(app.temp_dir, 'cov_missing_dir')) == '', 'missing dir empty' + assert module_line_attributes(os.join_path(m1_dir, 'm1.v')) == [], 'no attributes without any' + dep_dir := os.join_path(root, 'dep') + must_mkdir_all(dep_dir) + must_write_file(os.join_path(dep_dir, 'dep.v'), '@[deprecated]\nmodule dep\n') + assert module_line_attributes(os.join_path(dep_dir, 'dep.v')) == ['@[deprecated]'], 'attribute above module read' + dated_dir := os.join_path(root, 'dated') + must_mkdir_all(dated_dir) + must_write_file(os.join_path(dated_dir, 'dated.v'), "@[deprecated_after: '2000-01-01']\nmodule dated\n") + future_dir := os.join_path(root, 'future') + must_mkdir_all(future_dir) + must_write_file(os.join_path(future_dir, 'future.v'), "@[deprecated_after: '2999-01-01']\nmodule future\n") + dep, dep_refused := module_deprecation(dep_dir) + assert dep && !dep_refused, 'deprecated warns' + dated_dep, dated_refused := module_deprecation(dated_dir) + assert dated_dep && dated_refused, 'past date refuses' + future_dep, future_refused := module_deprecation(future_dir) + assert future_dep && !future_refused, 'future date still warns' + plain_dep, plain_refused := module_deprecation(m1_dir) + assert !plain_dep && !plain_refused, 'plain module is neither' + assert module_dir_imports(m1_dir) == ['os'], 'imports read' + flag_dir := os.join_path(root, 'flagged') + must_mkdir_all(flag_dir) + must_write_file(os.join_path(flag_dir, 'x_notd_flag.v'), "\$compile_error('needs -d flag')\nmodule flagged\n") + assert module_needs_flag(flag_dir), 'guarded file needs a flag' + assert !module_needs_flag(m1_dir), 'plain module needs none' + mut found := []ImportableModule{} + mut seen_paths := map[string]bool{} + mut seen_dirs := map[string]bool{} + collect_importable_modules(root, '', .project, 0, mut found, mut seen_paths, mut seen_dirs) + assert found.any(it.path == 'm1'), 'nested module collected' + m1_mod := ImportableModule{ + path: 'm1' + name: 'm1' + dir: os.real_path(m1_dir) + origin: .project + } + m2_mod := ImportableModule{ + path: 'm2' + name: 'm2' + dir: os.real_path(m2_dir) + origin: .project + } + assert app.module_imports_of(m1_mod) == ['os'], 'cached imports read' + reaching := app.modules_reaching([m1_mod], [m1_mod, m2_mod]) + assert reaching['m2'], 'importer reaches the module' + assert m1_mod.dir in app.module_imports_cache, 'imports cached' + app.forget_module_folder(os.join_path(m1_dir, 'm1.v')) + assert m1_mod.dir !in app.module_imports_cache, 'forgotten folder dropped' + main_path := os.join_path(root, 'main.v') + offered := app.importable_modules(main_path) + assert offered.any(it.path == 'm1'), 'project module offered' + assert offered.any(it.path == 'os'), 'vlib module offered' + assert app.cached_importable_modules(root).any(it.path == 'm1'), 'cached project module' + completions := app.module_import_completions(path_to_uri(main_path), 'module main\n\nfn main() {}\n', + Position{ line: 2, char: 0 }) + assert completions.any(it.label == 'm1'), 'missing module completed' + line_items := app.import_line_module_completions(main_path, 'import m') + assert line_items.any(it.label == 'm1'), 'import line completed by segment' +} + +fn test_cov_expression_type_constructors() { + field := language_field('array fixed', 'len', 'int') + assert field.kinds == ['array', 'fixed'] && field.name == 'len' && field.ret == 'int' && field.is_field, 'field built' + method := language_method('array', 'first', '', '{E}') + assert method.kinds == ['array'] && method.name == 'first' && method.params == '' && method.ret == '{E}' && !method.is_field && !method.is_static, 'method built' + on := language_method_on('flag', 'e &{T}', 'has', 'flag_ {T}', 'bool') + assert on.receiver == 'e &{T}' && on.name == 'has' && on.params == 'flag_ {T}' && on.ret == 'bool', 'receiver method built' + enum_static := language_static('enum', 'from', '[W]', 'input W', '!{T}') + assert enum_static.generic == '[W]' && enum_static.is_static && enum_static.name == 'from', 'static built' + assert language_receiver_name(['array']) == 'a', 'array receiver' + assert language_receiver_name(['fixed']) == 'a', 'fixed receiver' + assert language_receiver_name(['map']) == 'm', 'map receiver' + assert language_receiver_name(['chan']) == 'ch', 'channel receiver' + assert language_receiver_name(['enum']) == 'e', 'enum receiver' + assert language_receiver_name(['sum']) == 'x', 'sum receiver' + assert composite_member_kinds('typeof') == ['typeof'], 'typeof kinds' + assert composite_member_kinds('[]string') == ['array', 'array_string', 'printable'], 'string array kinds' + assert composite_member_kinds('[]int') == ['array', 'printable'], 'array kinds' + assert composite_member_kinds('[10]int') == ['fixed', 'printable'], 'fixed kinds' + assert composite_member_kinds('map[string]int') == ['map', 'printable'], 'map kinds' + assert composite_member_kinds('chan int') == ['chan'], 'channel kinds' + assert composite_member_kinds('int') == []string{}, 'named type has none' + map_elem, map_key, map_value := composite_type_parts('map[string]int') + assert map_elem == '' && map_key == 'string' && map_value == 'int', 'map parts' + arr_elem, arr_key, arr_value := composite_type_parts('[]int') + assert arr_elem == 'int' && arr_key == '' && arr_value == '', 'array element' + chan_elem, chan_key, chan_value := composite_type_parts('chan int') + assert chan_elem == 'int' && chan_key == '' && chan_value == '', 'channel element' + assert member_receiver_type('mut &?Point') == 'Point', 'modifiers stripped' + assert member_receiver_type('shared []int') == '[]int', 'shared stripped' + assert member_receiver_type('int') == 'int', 'plain kept' + assert type_modifiers('&Point') == '&', 'reference kept' + assert type_modifiers('?&Point') == '?&', 'option reference kept' + assert type_modifiers('Point') == '', 'plain has none' + assert slice_result_type('[]int') == '[]int', 'array slices to array' + assert slice_result_type('[10]int') == '[]int', 'fixed slices to array' + assert slice_result_type('int') == 'int', 'plain is no slice' + assert unwrap_option_type('?Point') == 'Point', 'option unwrapped' + assert unwrap_option_type('!Point') == 'Point', 'result unwrapped' + assert unwrap_option_type('Point') == 'Point', 'plain kept' + assert index_expression_type('string') == 'u8', 'string indexes to a byte' + assert index_expression_type('[]int') == 'int', 'array element' + assert index_expression_type('map[string]int') == 'int', 'map value' + assert is_type_name('Point'), 'capitalized is a type' + assert is_type_name('time.Time'), 'qualified type' + assert !is_type_name('point'), 'lowercase is not' + assert !is_type_name(''), 'empty is not' + assert declaration_has_attribute(['@[flag]', 'enum Color {'], 1, 'flag'), 'new attribute style' + assert declaration_has_attribute(['[flag]', 'enum Color {'], 1, 'flag'), 'old attribute style' + assert !declaration_has_attribute(['fn f() {}'], 0, 'flag'), 'no line above' + assert !declaration_has_attribute(['@[other]', 'struct S {}'], 1, 'flag'), 'other attribute' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'enum' + is_flag: true + }) == ['enum', 'flag', 'printable'], 'flag kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'enum' + }) == ['enum', 'printable'], 'enum kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'sum' + }) == ['sum', 'printable'], 'sum kinds' + assert declaration_member_kinds(TypeDeclaration{ + kind: 'struct' + }) == ['printable'], 'struct kinds' + assert declaration_member_kinds(TypeDeclaration{}) == []string{}, 'unknown has none' + assert signature_fn_type('fn (u User) greet(name string) string') == 'fn (name string) string', 'receiver dropped' + assert signature_fn_type('pub fn run(port int) !int') == 'fn (port int) !int', 'pub dropped' + assert signature_fn_type('x := 1') == '', 'no signature' + assert signature_return_type('fn (p Point) moved() Point') == 'Point', 'method result' + assert signature_return_type('fn serve(port int) !http.Server') == '!http.Server', 'result kept' + assert signature_return_type('fn main()') == '', 'no result' + assert qualify_member_type('Duration', 'time.Time') == 'time.Duration', 'member qualified' + assert qualify_member_type('Time', 'Point') == 'Time', 'unqualified owner kept' + assert qualify_member_type('int', 'time.Time') == 'int', 'builtin kept' + assert top_level_slice_range('1..5'), 'range found' + assert !top_level_slice_range('a.b'), 'single dot is not' + assert !top_level_slice_range('f(1..2)'), 'nested range hidden' +} + +fn test_cov_expression_text_helpers() { + assert expression_start_before('foo.bar', 7) == 0, 'dotted chain from the start' + assert expression_start_before('a + b', 5) == 4, 'operand after the operator' + assert opening_delimiter_before('(a)', 2) == 0, 'matching paren' + assert opening_delimiter_before('abc', 2) == -1, 'no delimiter' + assert (string_literal_end("'hi'") or { -1 }) == 4, 'single quoted' + assert (string_literal_end('"hi"') or { -1 }) == 4, 'double quoted' + assert (string_literal_end('nose') or { -1 }) == -1, 'no literal' + typ, after := type_at('int rest', 0) + assert typ == 'int' && after == 3, 'plain type' + arr_typ, arr_after := type_at('[]int rest', 0) + assert arr_typ == '[]int' && arr_after == 5, 'array type' + map_typ, map_after := type_at('map[string]int x', 0) + assert map_typ == 'map[string]int' && map_after == 14, 'map type' + empty_typ, empty_after := type_at('', 0) + assert empty_typ == '' && empty_after == 0, 'empty has none' + assert (function_literal_type('fn (a int) string { return a }') or { '' }) == 'fn (a int) string', 'literal with result' + assert (function_literal_type('fn [x] (a int) { }') or { '' }) == 'fn (a int)', 'captures dropped' + assert (function_literal_type('plain') or { 'none' }) == 'none', 'no literal' + assert without_trailing_comment('x := 1 // done') == 'x := 1 ', 'comment dropped' + assert without_trailing_comment('s := "a//b"') == 's := "a//b"', 'comment in string kept' + typeof_typ, typeof_rest := typeof_operand('(x) tail') + assert typeof_typ == 'typeof' && typeof_rest == ' tail', 'typeof operand' + bad_typ, bad_rest := typeof_operand('x') + assert bad_typ == '' && bad_rest == 'x', 'no operand' + assert binary_result_type('int', '== 1') == 'bool', 'comparison is bool' + assert binary_result_type('int', '+ 1') == 'int', 'arithmetic keeps the type' + assert binary_result_type('int', 'foo') == '', 'unknown operator' + assert (is_check_type('if x is Point', 'x') or { '' }) == 'Point', 'smart cast read' + assert (is_check_type('fn f()', 'x') or { 'none' }) == 'none', 'no check' + assert (match_header_subject('match x') or { '' }) == 'x', 'match subject' + assert (match_header_subject('match mut y') or { '' }) == 'y', 'mut subject' + assert (match_header_subject('fn f()') or { 'none' }) == 'none', 'no match' + assert (smart_cast_type(['if x is Point {', '\ty := x'], Position{ line: 1, char: 6 }, 'x', + .utf8) or { '' }) == 'Point', 'narrowed in if is' + assert (smart_cast_type(['fn f() {', '\tx := 1'], Position{ line: 1, char: 6 }, 'x', + .utf8) or { 'none' }) == 'none', 'no narrowing' + no_params := callable_member_item('clone', 'fn (a []int) clone() []int', '', 'int', 2) + assert (no_params.insert_text or { '' }) == 'clone()' && (no_params.insert_text_format or { 0 }) == 1, 'call without parameters' + callback := callable_member_item('filter', 'fn (a []int) filter(predicate fn (int) bool) []int', + 'predicate fn (int) bool', 'int', 2) + assert (callback.insert_text or { '' }) == 'filter(fn (x int) bool {\n\t\$0\n})' && (callback.insert_text_format or { 0 }) == 2, 'callback skeleton inserted' + placeholder := callable_member_item('repeat', 'fn (a []int) repeat(count int) []int', 'count int', + 'int', 2) + assert (placeholder.insert_text or { '' }) == 'repeat(\${1:count})\$0' && (placeholder.insert_text_format or { 0 }) == 2, 'first parameter as placeholder' + assert (callback_skeleton_text('fn (int) bool', 'int') or { '' }) == 'fn (x int) bool {\n\t\$0\n}', 'skeleton text' + assert (callback_skeleton_text('nope', 'int') or { 'none' }) == 'none', 'no skeleton' + members := language_member_items('[]int', ['array', 'printable'], false) + assert members.any(it.label == 'len'), 'array field offered' + assert members.any(it.label == 'filter'), 'array method offered' + statics := language_member_items('Color', ['enum', 'printable'], true) + assert statics.any(it.label == 'from'), 'enum static offered' + assert !language_member_items('[]int', ['array', 'printable'], true).any(it.label == 'len'), 'values hide statics' +} + +fn test_cov_make_unique_temp_path_sequentially_distinct() { + // Windows reports the same `unix_nano` tick for thousands of consecutive + // calls, so the timestamp alone cannot separate two temp paths. The + // per-process counter suffix does: 50 sequential calls must all differ. + mut seen := map[string]bool{} + for _ in 0 .. 50 { + path := make_unique_temp_path('cov', '/tmp/example.v') + assert path !in seen, 'duplicate temp path: ${path}' + assert path.ends_with('.v') && path.contains('cov_'), 'tag and extension kept' + assert path.contains(os.getpid().str()), 'pid tagged' + seen[path] = true + } + assert seen.len == 50, 'every call unique' +} diff --git a/interop.v b/interop.v index 982ee4b5..c25132a1 100644 --- a/interop.v +++ b/interop.v @@ -263,15 +263,24 @@ fn path_to_uri(path string) string { } // make_unique_temp_path returns a collision-resistant temp file path in the -// system temp dir, tagged with the caller's purpose, the pid, and a nanosecond -// timestamp, so concurrent requests for same-named files never overwrite one -// another (P1-12). +// system temp dir, tagged with the caller's purpose, the pid, a nanosecond +// timestamp, and a per-process counter, so sequential requests for same-named +// files never return the same path (P1-12). The counter is what separates two +// calls: on Windows consecutive `unix_nano` readings usually fall in the same +// tick, so the timestamp alone does not tell them apart. fn make_unique_temp_path(tag string, real_path string) string { ext := os.file_ext(real_path) safe_ext := if ext == '' { '.v' } else { ext } name := os.file_name(real_path) base := if name.contains('.') { name.all_before_last('.') } else { name } - return os.join_path(os.temp_dir(), '${tag}_${os.getpid()}_${time.now().unix_nano()}_${base}${safe_ext}') + // A function-local static keeps the counter out of module scope. It needs + // `unsafe` because statics are unchecked shared state; the bump happens + // before anything else reads it. + unsafe { + mut static seq := u64(0) + seq++ + return os.join_path(os.temp_dir(), '${tag}_${os.getpid()}_${time.now().unix_nano()}_${seq}_${base}${safe_ext}') + } } fn make_singlefile_temp_path(temp_root string, real_path string, purpose string) string { From 69dbe935ea832c56e30f2dcba3db31114be1dcda Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 05:58:03 +0330 Subject: [PATCH 16/83] test: regression coverage for nested programs and cold-open siblings Issues 474 and 473 both reproduce clean on this tree (compile-target rework and sibling-aware completion cover them), so these are guards with breaking controls: a nested two-file program checks clean and maps diagnostics to real paths, and an unopened sibling resolves through completion, definition, diagnostics, and didOpen. --- handlers_test.v | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ interop_test.v | 65 ++++++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/handlers_test.v b/handlers_test.v index c375e12d..751ebbe4 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -15946,3 +15946,148 @@ fn test_cov_make_unique_temp_path_sequentially_distinct() { } assert seen.len == 50, 'every call unique' } + +fn test_completion_cold_open_sees_unopened_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: banner.v and main.v share one `module main` directory. + // Only main.v is opened; banner.v stays on disk and the index starts empty. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_completion') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tban\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + assert app.symbol_index.len == 0, 'index starts empty on cold open' + response := app.operation_at_pos(.completion, Request{ + id: 1 + method: 'textDocument/completion' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 4 + } + }, + escape_unicode: true + ) + }) + assert response.id == 1, 'response id echoes' + assert response.result is CompletionList, 'completion answers a list on cold open' + labels := (response.result as CompletionList).items.map(it.label) + assert 'banner' in labels, 'unopened sibling fn banner offered on cold open' +} + +fn test_definition_cold_open_resolves_unopened_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: same cold-open shape as the completion test, but the + // cursor sits on the `banner()` call and must resolve into banner.v. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_definition') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + assert app.symbol_index.len == 0, 'index starts empty on cold open' + location := app.resolve_indexed_definition(main_uri, Position{ + line: 3 + char: 3 + }) or { + assert false, 'cold-open definition of banner resolves into banner.v' + return + } + assert location.uri == path_to_uri(banner_path), 'definition points at the sibling file' + assert location.range.start.line == 2, 'definition points at the banner declaration' +} + +fn test_diagnostics_cold_open_resolves_sibling_banner_call() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473: on a cold open only main.v is open; its `banner()` call + // must not be reported as unknown while banner.v sits beside it on disk. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_diagnostics') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.open_files[main_uri] = main_content + app.text = main_content + errors := app.run_v_check(main_uri, main_content) + assert !errors.any(it.message.contains('banner')), 'sibling call resolves on cold open' +} + +fn test_cold_open_via_did_open_sees_sibling_banner() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // vlang/vls#473 through the real open handler: main.v opens with no text + // payload (read from disk) and no edit follows; banner.v stays unopened. + // Completion and definition must both see the sibling straight away. + project_dir := os.join_path(app.temp_dir, 'banner_cold_open_did_open') + must_mkdir_all(project_dir) + banner_path := os.join_path(project_dir, 'banner.v') + main_path := os.join_path(project_dir, 'main.v') + must_write_file(banner_path, 'module main\n\npub fn banner() string {\n\treturn "hi"\n}\n') + main_content := 'module main\n\nfn main() {\n\tbanner()\n}\n' + must_write_file(main_path, main_content) + main_uri := path_to_uri(main_path) + app.on_did_open(Request{ + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + }, + escape_unicode: true + ) + }) + assert main_uri in app.open_files, 'main.v is tracked after open' + assert app.symbol_index.len == 0, 'index starts empty on cold open' + response := app.operation_at_pos(.completion, Request{ + id: 1 + method: 'textDocument/completion' + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 4 + } + }, + escape_unicode: true + ) + }) + assert response.result is CompletionList, 'completion answers a list on cold open' + labels := (response.result as CompletionList).items.map(it.label) + assert 'banner' in labels, 'unopened sibling fn banner offered on cold open' + location := app.resolve_indexed_definition(main_uri, Position{ + line: 3 + char: 3 + }) or { + assert false, 'cold-open definition of banner resolves into banner.v' + return + } + assert location.uri == path_to_uri(banner_path), 'definition points at the sibling file' + assert location.range.start.line == 2, 'definition points at the banner declaration' +} diff --git a/interop_test.v b/interop_test.v index 5215ece5..022a5556 100644 --- a/interop_test.v +++ b/interop_test.v @@ -1271,6 +1271,71 @@ fn test_run_v_check_returns_cached_result_without_compiler() { os.rmdir_all(root) or {} } +fn test_run_v_check_nested_program_subdir_is_clean() { + // Issue vlang/vls#474: a project whose main module sits in a subdirectory + // reported false errors while v-analyzer stayed clean. The file must be + // checked from its own program directory, with errors mapped back to it. + assert compiler_is_available(), 'a V compiler must be reachable to check the nested fixture' + previous_cache := with_temp_diag_cache_dir('nested474') + defer { + restore_diag_cache_dir(previous_cache) + } + base := os.join_path(os.temp_dir(), 'vls_nested474_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + for with_vmod in [false, true] { + tag := if with_vmod { 'with_vmod' } else { 'without_vmod' } + project := os.join_path(base, tag) + app_dir := os.join_path(project, 'app') + interop_test_must_mkdir_all(app_dir) + if with_vmod { + interop_test_must_write_file(os.join_path(project, 'v.mod'), "Module {\n\tname: 'nested474'\n}\n") + } + main_file := os.join_path(app_dir, 'main.v') + util_file := os.join_path(app_dir, 'util.v') + clean_main := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + util_content := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, clean_main) + interop_test_must_write_file(util_file, util_content) + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + mut app := &App{ + temp_dir: work + open_files: { + main_uri: clean_main + util_uri: util_content + } + } + // The nested program is checked from its own directory. + program_dir := app.program_root(main_file) + assert program_dir == normalize_overlay_path(app_dir), '${tag}: program of ${main_file} is ${program_dir}, not ${app_dir}' + overlay := app.prepare_compilation_overlay(main_file) or { + assert false, '${tag}: overlay failed: ${err}' + continue + } + assert overlay.source_work_dir == normalize_overlay_path(app_dir), '${tag}: overlay checks from ${overlay.source_work_dir}, not ${app_dir}' + mapped := source_path_from_overlay(overlay.temp_source_file, overlay, overlay.temp_work_dir) + assert normalize_overlay_path(mapped) == normalize_overlay_path(main_file), '${tag}: overlay maps to ${mapped}, not ${main_file}' + os.rmdir_all(overlay.temp_root) or {} + // A clean nested program reports no diagnostics. + clean := app.run_v_check(main_uri, clean_main) + assert clean.len == 0, '${tag}: false errors for a clean nested program: ${clean}' + // A real error in the unsaved buffer still surfaces at the right place, + // which proves the clean answer above came from checking the program. + broken_main := 'module main\n\nfn main() {\n\tprintln(unknown_ident_474)\n}\n' + app.open_files[main_uri] = broken_main + broken := app.run_v_check(main_uri, broken_main) + assert broken.len == 1, '${tag}: expected one error, got ${broken}' + assert broken[0].message.contains('unknown_ident_474'), '${tag}: error names the wrong thing: ${broken[0].message}' + assert broken[0].line_nr == 4, '${tag}: error is on line ${broken[0].line_nr}, not 4' + assert normalize_overlay_path(broken[0].path) == normalize_overlay_path(main_file), '${tag}: error mapped to ${broken[0].path}, not ${main_file}' + } +} + fn test_run_v_argv_reports_missing_working_dir() { missing_dir := os.join_path(os.temp_dir(), 'vls_missing_dir_${os.getpid()}_${time.now().unix_nano()}') original := os.getwd() From ed107a921696ea62fa81ac9d08674feef84991a0 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 06:09:36 +0330 Subject: [PATCH 17/83] perf: per-stage timing breakdown for slow checks run_v_check logs one VLS_PERF_LOG line per slow check with fingerprint, overlay prep, compiler run, and parse milliseconds. First measurement on this host: the compiler run is 97 percent of a 360ms single-file check. --- interop.v | 21 +++++++++++++++-- interop_test.v | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/interop.v b/interop.v index c25132a1..3f288b2a 100644 --- a/interop.v +++ b/interop.v @@ -1304,6 +1304,10 @@ fn source_path_from_overlay(reported_path string, overlay CompilationOverlay, ba } fn (mut app App) run_v_check(path string, text string) []JsonError { + // Stage timings are pure observation: each stage records wall-clock + // milliseconds, and one summary line is logged at the end of the check. + stages_total_start_ms := time.now().unix_milli() + fp_stage_start_ms := time.now().unix_milli() real_path := uri_to_path(path) working_dir := os.dir(real_path) mut temp_project_dir := '' @@ -1319,6 +1323,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { program_dir := app.program_root(real_path) overlay_root := program_overlay_root(real_path, program_dir) fingerprint := compiler_fingerprint() + '\n' + app.program_content_fingerprint(overlay_root) + fp_ms := time.now().unix_milli() - fp_stage_start_ms mut cache_hit := '' if cached := app.diag_cache[path] { if cached.fingerprint == fingerprint { @@ -1341,7 +1346,9 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { log('Returning ${cache_hit}-cached diagnostics for ${path}') return app.diag_cache[path].errors } - + // Prep covers overlay/program-copy/single-file preparation and argument + // setup, up to just before the compiler is spawned. + prep_stage_start_ms := time.now().unix_milli() log('running v.exe check for ${real_path}') log('Open files count: ${app.open_files.len}') mut pool := app.diagnostics_servers @@ -1468,6 +1475,8 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { return false } } + prep_ms := time.now().unix_milli() - prep_stage_start_ms + run_stage_start_ms := time.now().unix_milli() x := if server_exe != '' { mut servers := app.diagnostics_servers servers.check(server_exe, cmd_args, exec_dir, cancelled, partial) or { @@ -1476,6 +1485,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } else { run_v_argv_cancelled(cmd_args, exec_dir, cancelled) } + run_ms := time.now().unix_milli() - run_stage_start_ms if shared_copy != unsafe { nil } { shared_copy.mutex.unlock() } @@ -1485,9 +1495,16 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // A newer check replaced this one; its answer goes nowhere. return [] } - + // Parse covers splitting the compiler output into per-file diagnostics. + parse_stage_start_ms := time.now().unix_milli() found := split_check_errors(x.output, diagnostic_source_dir, file_to_check, use_multifile, overlay, real_path, program_uris, compile_target) + parse_ms := time.now().unix_milli() - parse_stage_start_ms + if os.getenv('VLS_PERF_LOG') != '' { + total_ms := time.now().unix_milli() - stages_total_start_ms + app.send_log_message('diagnostics stages fp=${fp_ms} prep=${prep_ms} run=${run_ms} parse=${parse_ms} total=${total_ms}', + 4) + } if server_exe == '' { cleanup_compilation_temp(temp_project_dir, singlefile_tmppath) } diff --git a/interop_test.v b/interop_test.v index 022a5556..678ca10e 100644 --- a/interop_test.v +++ b/interop_test.v @@ -4124,3 +4124,66 @@ fn test_cov_run_code_lens_job_cancelled_and_start_refused() { assert app.captured_output[0].contains('cannot start'), 'the refusal names the problem: ${app.captured_output[0]}' app.stop_run_commands() } + +fn test_run_v_check_logs_single_stage_line_with_perf_log() { + // The slow-check stage breakdown is pure observation: one summary line + // gated on VLS_PERF_LOG, with no change to check semantics or caching. + assert compiler_is_available(), 'a V compiler must be reachable to time a real check' + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('stages') + base := os.join_path(os.temp_dir(), 'vls_stages_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + restore_diag_cache_dir(previous_cache) + os.rmdir_all(base) or {} + } + os.setenv('VLS_PERF_LOG', '1', true) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + // A lone file with no siblings and no v.mod takes the single-file path, + // so the check spawns the compiler exactly once. + source := os.join_path(base, 'main.v') + content := 'module main\n\nfn main() {\n\tprintln("hi")\n}\n' + interop_test_must_write_file(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + capture_output: true + open_files: { + uri: content + } + } + got := app.run_v_check(uri, content) + assert got.len == 0, 'a clean lone file reports no diagnostics: ${got}' + stages_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stages_lines.len == 1, 'one stage line per slow check: ${app.captured_output}' + line := stages_lines[0] + for key in ['fp=', 'prep=', 'run=', 'parse=', 'total='] { + assert line.contains(key), 'the stage line names every stage: ${line}' + } + assert line.contains('window/logMessage'), 'the stage line goes to the log channel: ${line}' + // A cache hit reuses the answer without checking again, so it logs the + // cache line but no second stage line. + again := app.run_v_check(uri, content) + assert again.len == 0, 'a cached check answers the same: ${again}' + assert app.captured_output.filter(it.contains('diagnostics stages')).len == 1, 'a cache hit logs no stage line: ${app.captured_output}' + // Without VLS_PERF_LOG a slow check logs no stage line either. + os.unsetenv('VLS_PERF_LOG') + changed := 'module main\n\n// staged timings stay gated\n\nfn main() {\n\tprintln("hi")\n}\n' + interop_test_must_write_file(source, changed) + mut quiet := &App{ + temp_dir: work + capture_output: true + open_files: { + uri: changed + } + } + quiet_got := quiet.run_v_check(uri, changed) + assert quiet_got.len == 0, 'the changed file is still clean: ${quiet_got}' + assert quiet.captured_output.filter(it.contains('diagnostics stages')).len == 0, 'no stage line without VLS_PERF_LOG: ${quiet.captured_output}' +} From 47d9e2c16500a77898b1c399cbcb4b4ba9f0ed9b Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 06:44:46 +0330 Subject: [PATCH 18/83] fix: apply the index size gate to open buffers reindex_uri capped disk files at 2MB but indexed open buffers of any size, allowing one huge buffer to force an unbounded index build. The gate now covers both paths; oversized entries are dropped and marked skipped exactly like oversized disk files. --- index.v | 7 +++++++ index_test.v | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/index.v b/index.v index e96c32b5..9fbd9955 100644 --- a/index.v +++ b/index.v @@ -397,6 +397,13 @@ fn (app &App) path_is_in_removed_workspace(path string) bool { fn (mut app App) reindex_uri(uri string) { mut content := '' if open_content := app.open_files[uri] { + // Open buffers bypass the disk size gate below; apply it here so a + // huge buffer cannot force an unbounded index build. + if u64(open_content.len) > index_max_file_bytes { + app.drop_index_uri(uri) + app.index_skipped_uris[uri] = true + return + } content = open_content app.index_skipped_uris.delete(uri) } else { diff --git a/index_test.v b/index_test.v index f2ef5c24..6d3d9fc8 100644 --- a/index_test.v +++ b/index_test.v @@ -130,6 +130,33 @@ fn test_watched_file_reindex_drops_oversized_disk_entry() { assert uri !in app.ref_occurrences } +fn test_open_buffer_reindex_skips_oversized_content() { + // Open buffers bypass the disk size gate; a huge buffer must not force + // an unbounded index build. + root := index_test_tmpdir('open_large') + defer { + os.rmdir_all(root) or {} + } + path := os.join_path(root, 'large.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn small() {}\n') or { + assert false, 'write initial file failed: ${err}' + return + } + mut app := index_test_app() + big := 'x'.repeat(int(index_max_file_bytes) + 1) + app.open_files[uri] = big + app.reindex_uri(uri) + assert uri !in app.symbol_index + assert uri !in app.ref_occurrences + assert uri in app.index_skipped_uris + // Shrinking the buffer below the gate re-indexes it. + app.open_files[uri] = 'module main\n\nfn small_again() {}\n' + app.reindex_uri(uri) + assert uri in app.symbol_index + assert uri !in app.index_skipped_uris +} + fn test_watched_file_reindex_obeys_total_entry_limit() { root := index_test_tmpdir('watched_count') defer { From bbb77fe1cd8934cd109f916a4186ced0cf005c3b Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:23:44 +0330 Subject: [PATCH 19/83] perf: stable overlay dir with sync-only-changed files The overlay directory persists per program instead of being wiped per check; buffers rewrite only when their content hash differs, and stale copies are pruned. Warm prep time measured 90ms down to 21ms on a 52-file overlay. --- interop.v | 107 ++++++++++++++++++++++++--- interop_test.v | 191 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 10 deletions(-) diff --git a/interop.v b/interop.v index 3f288b2a..131f1abd 100644 --- a/interop.v +++ b/interop.v @@ -1225,6 +1225,76 @@ fn (mut app App) prepare_line_info_overlay(real_path string, work_dir string) !C return app.prepare_compilation_overlay_with(real_path, work_dir, importers) } +// overlay_file_content_matches reports whether the file at `existing_path` +// already holds `content`, comparing content hashes before any write: an +// overlay that persists across checks rewrites only what changed. A file that +// changed always differs, so it is never skipped. +fn overlay_file_content_matches(existing_path string, content string) bool { + existing := os.read_file(existing_path) or { return false } + if existing.len != content.len { + return false + } + return existing.hash() == content.hash() +} + +// drop_failed_overlay removes a freshly built overlay after a failure. A stable +// overlay is kept: it still holds the last good sync, and the next check syncs +// it again. +fn drop_failed_overlay(temp_root string, stable bool) { + if stable { + return + } + os.rmdir_all(temp_root) or {} +} + +// remove_stale_stable_overlay_files drops what a stable overlay holds that the +// current sync no longer tracks: a buffer that is closed again reads from disk, +// so its copy goes and the symlink step links it back, and a link whose source +// is gone goes too. Copies that still match disk stay, wherever they came from. +fn remove_stale_stable_overlay_files(temp_root string, source_root string, tracked map[string]string) { + mut keep := map[string]bool{} + for uri, _ in tracked { + if rel := overlay_relative_path(normalize_overlay_path(uri_to_path(uri)), source_root) { + keep[normalize_overlay_path(rel)] = true + } + } + mut pending := [''] + + for pending.len > 0 { + rel_dir := pending.pop() + dir := if rel_dir == '' { temp_root } else { os.join_path(temp_root, rel_dir) } + for entry in os.ls(dir) or { continue } { + entry_rel := if rel_dir == '' { entry } else { rel_dir + '/' + entry } + normalized_rel := normalize_overlay_path(entry_rel) + copy_path := os.join_path(temp_root, entry_rel) + if os.is_link(copy_path) { + if normalized_rel in keep { + continue + } + if !os.exists(os.join_path(source_root, entry_rel)) { + os.rm(copy_path) or {} + } + continue + } + if os.is_dir(copy_path) { + pending << entry_rel + continue + } + if normalized_rel in keep { + continue + } + source_path := os.join_path(source_root, entry_rel) + if !os.is_file(source_path) { + os.rm(copy_path) or {} + continue + } + if !overlay_file_content_matches(copy_path, os.read_file(source_path) or { '' }) { + os.rm(copy_path) or {} + } + } + } +} + // prepare_compilation_overlay_with builds the overlay with `importers`, files // from disk by path, written into it next to the buffers. fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir string, importers map[string]string) !CompilationOverlay { @@ -1232,28 +1302,38 @@ fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir str source_work_dir := normalize_overlay_path(work_dir) source_root := program_overlay_root(source_path, source_work_dir) source_display_root := source_root + // A stable overlay persists across checks: it is synced, not rebuilt, so + // only files whose buffer content differs get rewritten. + stable := app.overlay_dir != '' + had_stable_overlay := stable && os.is_dir(app.overlay_dir) temp_root_unresolved := app.write_tracked_files_to_temp(source_root)! temp_root := normalize_overlay_path(os.real_path(temp_root_unresolved)) mut tracked := app.open_files.clone() for path, content in importers { rel := overlay_relative_path(path, source_root) or { continue } - os.write_file(os.join_path(temp_root, rel), content) or { - os.rmdir_all(temp_root) or {} - return error('Failed to write ${rel} into the compilation overlay: ${err}') + target := os.join_path(temp_root, rel) + if !overlay_file_content_matches(target, content) { + os.write_file(target, content) or { + drop_failed_overlay(temp_root, stable) + return error('Failed to write ${rel} into the compilation overlay: ${err}') + } } tracked[path_to_uri(path)] = content } + if had_stable_overlay { + remove_stale_stable_overlay_files(temp_root, source_root, tracked) + } symlink_untracked_files(source_root, source_work_dir, temp_root, tracked) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Failed to populate compilation overlay: ${err}') } work_rel := overlay_relative_path(source_work_dir, source_root) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Source work directory is outside overlay root: ${source_work_dir}') } file_rel := overlay_relative_path(source_path, source_root) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Source file is outside overlay root: ${source_path}') } temp_work_dir := if work_rel == '' { @@ -1263,7 +1343,7 @@ fn (mut app App) prepare_compilation_overlay_with(real_path string, work_dir str } if !os.exists(temp_work_dir) { os.mkdir_all(temp_work_dir) or { - os.rmdir_all(temp_root) or {} + drop_failed_overlay(temp_root, stable) return error('Failed to create overlay work directory ${temp_work_dir}: ${err}') } } @@ -1602,10 +1682,9 @@ fn split_check_errors(output string, diagnostic_source_dir string, file_to_check fn (mut app App) write_tracked_files_to_temp(working_dir string) !string { log('WRITING ${app.open_files.len} tracked files to temp directory') - // create subdir; a diagnostics server checks the same one every time, so it - // is rebuilt in place + // create subdir; a diagnostics server checks the same stable directory every + // time, so it is synced in place instead of rebuilt temp_project_dir := if app.overlay_dir != '' { - os.rmdir_all(app.overlay_dir) or {} app.overlay_dir } else { os.join_path(app.temp_dir, 'project_${time.now().unix_nano()}') @@ -1629,6 +1708,14 @@ fn (mut app App) write_tracked_files_to_temp(working_dir string) !string { } temp_file_path := os.join_path(temp_project_dir, rel_path) + // Sync-only-changed: a buffer the overlay already holds is left alone, + // keeping its modification time. A file that changed always differs, so + // the file the check runs for is never skipped. + if overlay_file_content_matches(temp_file_path, content) { + log('KEPT FILE: ${temp_file_path}') + continue + } + // create parent dir temp_file_dir := os.dir(temp_file_path) os.mkdir_all(temp_file_dir) or { diff --git a/interop_test.v b/interop_test.v index 678ca10e..d64ac58e 100644 --- a/interop_test.v +++ b/interop_test.v @@ -4187,3 +4187,194 @@ fn test_run_v_check_logs_single_stage_line_with_perf_log() { assert quiet_got.len == 0, 'the changed file is still clean: ${quiet_got}' assert quiet.captured_output.filter(it.contains('diagnostics stages')).len == 0, 'no stage line without VLS_PERF_LOG: ${quiet.captured_output}' } + +// interop_test_wait_for_next_second waits until the wall-clock second flips, +// so a file rewrite after this lands in a strictly newer second than any mtime +// read before it: an unchanged mtime then proves no write happened. +fn interop_test_wait_for_next_second() { + start := time.now().unix() + for _ in 0 .. 100 { + if time.now().unix() != start { + return + } + time.sleep(20 * time.millisecond) + } + assert time.now().unix() != start, 'the wall-clock second never flipped' +} + +fn test_overlay_file_content_matches_compares_hashes() { + base := os.join_path(os.temp_dir(), 'vls_overlay_match_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + probe := os.join_path(base, 'probe.v') + assert !overlay_file_content_matches(probe, 'module main\n'), 'a missing file never matches' + interop_test_must_write_file(probe, 'module main\n') + assert overlay_file_content_matches(probe, 'module main\n'), 'identical content matches' + assert !overlay_file_content_matches(probe, 'module main\n\n'), 'changed content does not match' + assert !overlay_file_content_matches(probe, ''), 'emptied content does not match' + interop_test_must_write_file(probe, '') + assert overlay_file_content_matches(probe, ''), 'empty content matches empty' +} + +fn test_stable_overlay_syncs_only_changed_files() { + base := os.join_path(os.temp_dir(), 'vls_stable_overlay_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + os.rmdir_all(base) or {} + } + project := os.join_path(base, 'project') + interop_test_must_mkdir_all(project) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + main_file := os.join_path(project, 'main.v') + util_file := os.join_path(project, 'util.v') + disk_main := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + disk_util := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, disk_main) + interop_test_must_write_file(util_file, disk_util) + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + // The main buffer is unsaved, so closing it later must restore the disk + // content in the overlay instead of keeping the stale buffer. + main_buffer := disk_main + '\n// unsaved\n' + mut app := &App{ + temp_dir: work + open_files: { + main_uri: main_buffer + util_uri: disk_util + } + } + // A stable overlay persists across checks instead of being rebuilt. + stable := os.join_path(work, 'stable_project') + app.overlay_dir = stable + overlay1 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'first overlay prepare failed: ${err}' + return + } + assert overlay1.temp_root == normalize_overlay_path(os.real_path(stable)), 'the stable overlay is reused, not rebuilt elsewhere: ${overlay1.temp_root}' + overlay_main := os.join_path(overlay1.temp_root, 'main.v') + overlay_util := os.join_path(overlay1.temp_root, 'util.v') + assert os.read_file(overlay_main) or { '' } == main_buffer, 'the overlay holds the main buffer' + assert os.read_file(overlay_util) or { '' } == disk_util, 'the overlay holds the util buffer' + // An identical prepare rewrites nothing: every mtime stands still. + interop_test_wait_for_next_second() + main_mtime := os.file_last_mod_unix(overlay_main) + util_mtime := os.file_last_mod_unix(overlay_util) + interop_test_wait_for_next_second() + overlay2 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'second overlay prepare failed: ${err}' + return + } + assert overlay2.temp_root == overlay1.temp_root, 'the stable directory persists across prepares' + assert os.file_last_mod_unix(overlay_main) == main_mtime, 'an unchanged buffer is not rewritten' + assert os.file_last_mod_unix(overlay_util) == util_mtime, 'an unchanged buffer is not rewritten' + // Changing one buffer rewrites exactly that file: the changed file itself + // is never skipped. + changed_util := disk_util.replace("'hi'", "'changed'") + app.open_files[util_uri] = changed_util + interop_test_wait_for_next_second() + overlay3 := app.prepare_compilation_overlay_in(main_file, app.program_root(main_file)) or { + assert false, 'third overlay prepare failed: ${err}' + return + } + assert os.read_file(overlay_util) or { '' } == changed_util, 'the changed buffer reaches the overlay' + assert os.file_last_mod_unix(overlay_util) > util_mtime, 'the changed file is rewritten' + assert os.file_last_mod_unix(overlay_main) == main_mtime, 'the other buffer stays untouched' + assert os.read_file(overlay_main) or { '' } == main_buffer, 'the untouched buffer keeps its content' + // Closing a file returns its overlay entry to disk content. + app.open_files.delete(main_uri) + overlay4 := app.prepare_compilation_overlay_in(util_file, app.program_root(util_file)) or { + assert false, 'fourth overlay prepare failed: ${err}' + return + } + assert overlay4.temp_root == overlay1.temp_root, 'the stable directory still persists' + assert os.read_file(os.join_path(overlay4.temp_root, 'main.v')) or { '' } == disk_main, 'a closed buffer reads from disk again' + assert os.read_file(os.join_path(overlay4.temp_root, 'util.v')) or { '' } == changed_util, 'the open buffer is still the buffer' +} + +fn test_stable_overlay_slow_check_reports_stage_timings() { + assert compiler_is_available(), 'a V compiler must be reachable to time a real slow check' + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('stableprep') + // The stable overlay path is the one through a diagnostics server pool. The + // V in use may speak a newer server protocol than this branch, so the + // checks may fall back to one-shot compiler runs; the overlay they prepare + // is still the stable per-program directory either way. + previous_server := os.getenv('VLS_DIAGNOSTICS_SERVER') + server_exe := resolve_v_compiler_exe() + assert os.exists(server_exe), 'a V compiler must be reachable to time a real slow check' + base := os.join_path(os.temp_dir(), 'vls_stableprep_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(base) + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + if previous_server == '' { + os.unsetenv('VLS_DIAGNOSTICS_SERVER') + } else { + os.setenv('VLS_DIAGNOSTICS_SERVER', previous_server, true) + } + restore_diag_cache_dir(previous_cache) + os.rmdir_all(base) or {} + } + os.setenv('VLS_PERF_LOG', '1', true) + os.setenv('VLS_DIAGNOSTICS_SERVER', server_exe, true) + work := os.join_path(base, 'work') + interop_test_must_mkdir_all(work) + project := os.join_path(base, 'project') + interop_test_must_mkdir_all(project) + main_file := os.join_path(project, 'main.v') + util_file := os.join_path(project, 'util.v') + main_content := 'module main\n\nfn main() {\n\tprintln(helper())\n}\n' + util_content := "module main\n\nfn helper() string {\n\treturn 'hi'\n}\n" + interop_test_must_write_file(main_file, main_content) + interop_test_must_write_file(util_file, util_content) + // Siblings that are never opened: the cold check links every one of them + // into the overlay, while the warm check leaves them all alone. + for i in 0 .. 50 { + interop_test_must_write_file(os.join_path(project, 'extra_${i}.v'), + 'module main\n\nfn extra_${i}() {}\n') + } + main_uri := path_to_uri(main_file) + util_uri := path_to_uri(util_file) + mut pool := new_diagnostics_server_pool() + defer { + pool.stop_all() + } + mut app := &App{ + temp_dir: work + capture_output: true + open_files: { + main_uri: main_content + util_uri: util_content + } + diagnostics_servers: pool + } + // The cold check builds the stable overlay; the warm one, with a single + // changed buffer, only syncs it. + first := app.run_v_check(main_uri, main_content) + assert first.len == 0, 'a clean program reports no diagnostics: ${first}' + program_dir := app.program_root(main_file) + expected_stable := pool.stable_dir('project', program_overlay_root(normalize_overlay_path(main_file), + program_dir)) + assert os.is_dir(expected_stable), 'the slow check used the stable overlay directory: ${expected_stable}' + assert os.is_file(os.join_path(expected_stable, 'main.v')), 'the stable overlay holds main.v' + assert os.is_file(os.join_path(expected_stable, 'util.v')), 'the stable overlay holds util.v' + changed_util := util_content.replace("'hi'", "'warm'") + app.open_files[util_uri] = changed_util + second := app.run_v_check(util_uri, changed_util) + assert second.len == 0, 'the changed program is still clean: ${second}' + stage_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stage_lines.len == 2, 'one stage line per slow check: ${app.captured_output}' + for line in stage_lines { + assert line.contains('prep='), 'the stage line reports prep time: ${line}' + } + os.write_file(os.join_path(os.temp_dir(), 'vls_stable_overlay_prep_${os.getpid()}.log'), + stage_lines.join('\n') + '\n') or { + assert false, 'could not record stage timings: ${err}' + } +} From c79b4598098dd86bb53ba0b93db0e4f026b44f8e Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:23:44 +0330 Subject: [PATCH 20/83] feat: granular inlay-hint toggles vls.inlayHints gains variableTypes and parameterNames options matching the two kinds the compiler emits. Defaults preserve current behavior; runtime toggles apply without restart. --- handlers.v | 127 +++++++++++++++++++++++++++++++++-- handlers_test.v | 171 ++++++++++++++++++++++++++++++++++++++++++++++++ lsp.v | 15 ++++- 3 files changed, 303 insertions(+), 10 deletions(-) diff --git a/handlers.v b/handlers.v index 365ca449..9d1ebd3c 100644 --- a/handlers.v +++ b/handlers.v @@ -6904,11 +6904,13 @@ fn (mut app App) handle_inlay_hints(request Request) Response { // The compiler knows the type of every variable and the parameter names of // every call; the source heuristics below only run when it cannot answer. + // Both paths report the same two LSP kinds, so the granular toggles filter + // either answer without invalidating the compiler cache. if compiler_hints := app.compiler_inlay_hints(uri, content) { return Response{ id: request.id - result: compiler_hints.filter(it.position.line >= start_line - && it.position.line <= end_line) + result: filter_inlay_hints_by_toggles(compiler_hints.filter(it.position.line >= start_line + && it.position.line <= end_line), app.inlay_hint_toggles()) } } @@ -7041,8 +7043,78 @@ fn (mut app App) handle_inlay_hints(request Request) Response { return Response{ id: request.id - result: hints + result: filter_inlay_hints_by_toggles(hints, app.inlay_hint_toggles()) + } +} + +// inlay_hint_variable_types_disabled_key and +// inlay_hint_parameter_names_disabled_key are reserved entries of +// inlay_hint_cache holding the granular inlay-hint switches: a present entry +// means the client disabled that kind, an absent one means it is enabled, so +// a fresh server shows every hint exactly as before. Each key holds a space, +// which a document URI never holds raw, so the per-URI cache logic never +// reads them as a document. They live in the cache because App is declared +// in another file: no new server state can be added from here, and a module +// global would need `-enable-globals`, which the build does not pass. +const inlay_hint_variable_types_disabled_key = 'vls inlay variable types disabled' +const inlay_hint_parameter_names_disabled_key = 'vls inlay parameter names disabled' + +// InlayHintToggles carries the granular inlay-hint switches of +// `vls.inlayHints`: `variable_types` filters the type hints (kind 1) and +// `parameter_names` filters the parameter hints (kind 2, which also covers +// the field names of positional struct literals: the compiler reports both +// with the same kind, so they cannot be toggled apart downstream). +struct InlayHintToggles { +mut: + variable_types bool = true + parameter_names bool = true +} + +// inlay_hint_toggles reports the granular switches, both enabled unless the +// client disabled them through workspace/didChangeConfiguration. +fn (app &App) inlay_hint_toggles() InlayHintToggles { + return InlayHintToggles{ + variable_types: inlay_hint_variable_types_disabled_key !in app.inlay_hint_cache + parameter_names: inlay_hint_parameter_names_disabled_key !in app.inlay_hint_cache + } +} + +// set_inlay_hint_toggles stores the granular switches for the inlayHint +// requests that follow. It takes effect without a restart and without +// invalidating the cached compiler hints, which the toggles filter after. +fn (mut app App) set_inlay_hint_toggles(toggles InlayHintToggles) { + if toggles.variable_types { + app.inlay_hint_cache.delete(inlay_hint_variable_types_disabled_key) + } else { + app.inlay_hint_cache[inlay_hint_variable_types_disabled_key] = CachedInlayHints{} + } + if toggles.parameter_names { + app.inlay_hint_cache.delete(inlay_hint_parameter_names_disabled_key) + } else { + app.inlay_hint_cache[inlay_hint_parameter_names_disabled_key] = CachedInlayHints{} + } +} + +// inlay_hint_kind_allowed reports whether a hint of `kind` survives the +// granular toggles. A kind the toggles do not know passes through, so a +// newer compiler reporting a new kind keeps showing it. +fn inlay_hint_kind_allowed(kind int, toggles InlayHintToggles) bool { + if kind == inlay_hint_kind_type { + return toggles.variable_types + } + if kind == inlay_hint_kind_parameter { + return toggles.parameter_names } + return true +} + +// filter_inlay_hints_by_toggles drops the hint kinds the client disabled. +// With every toggle on it returns the input unchanged. +fn filter_inlay_hints_by_toggles(hints []InlayHint, toggles InlayHintToggles) []InlayHint { + if toggles.variable_types && toggles.parameter_names { + return hints + } + return hints.filter(inlay_hint_kind_allowed(it.kind, toggles)) } struct CachedInlayHints { @@ -8385,6 +8457,8 @@ fn (mut app App) handle_selection_range(request Request) Response { // on_did_change_configuration handles the workspace/didChangeConfiguration notification. // It applies settings that affect server behaviour: // vls.inlayHints – enable or disable inlay type hints +// vls.inlayHints.variableTypes – enable or disable the type hints (kind 1) +// vls.inlayHints.parameterNames – enable or disable the parameter hints (kind 2) // vls.diagnostics – enable or disable live compile-time diagnostics fn (mut app App) on_did_change_configuration(request Request) { resolved := resolve_workspace_settings(request.params) @@ -8394,6 +8468,17 @@ fn (mut app App) on_did_change_configuration(request Request) { log('VLS: inlay_hints_enabled=${enabled}') } } + if resolved.has_variable_types || resolved.has_parameter_names { + mut toggles := app.inlay_hint_toggles() + if variable_types := resolved.variable_types { + toggles.variable_types = variable_types + } + if parameter_names := resolved.parameter_names { + toggles.parameter_names = parameter_names + } + app.set_inlay_hint_toggles(toggles) + log('VLS: inlay_hint_toggles=variableTypes:${toggles.variable_types},parameterNames:${toggles.parameter_names}') + } if resolved.has_diagnostics { if enabled := resolved.diagnostics { app.diagnostics_enabled = enabled @@ -8407,10 +8492,14 @@ fn (mut app App) on_did_change_configuration(request Request) { struct ResolvedWorkspaceSettings { mut: - inlay_hints ?bool - diagnostics ?bool - has_inlay_hints bool - has_diagnostics bool + inlay_hints ?bool + diagnostics ?bool + variable_types ?bool + parameter_names ?bool + has_inlay_hints bool + has_diagnostics bool + has_variable_types bool + has_parameter_names bool } fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { @@ -8425,6 +8514,7 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { DidChangeConfigurationParamsCompat{} } merge_workspace_settings(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints.enabled, sectioned_inlay_nested.settings.vls.diagnostics) + merge_inlay_hint_options(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints) sectioned_diagnostics_nested := json2.decode[DidChangeConfigurationParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationParamsNestedDiagnosticsCompat{} @@ -8435,6 +8525,7 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { DidChangeConfigurationParamsNestedFeaturesCompat{} } merge_workspace_settings(mut resolved, sectioned_nested.settings.vls.inlay_hints.enabled, sectioned_nested.settings.vls.diagnostics.enabled) + merge_inlay_hint_options(mut resolved, sectioned_nested.settings.vls.inlay_hints) direct_flat := json2.decode[DidChangeConfigurationDirectParams](params_json) or { DidChangeConfigurationDirectParams{} @@ -8445,6 +8536,7 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { DidChangeConfigurationDirectParamsCompat{} } merge_workspace_settings(mut resolved, direct_inlay_nested.settings.inlay_hints.enabled, direct_inlay_nested.settings.diagnostics) + merge_inlay_hint_options(mut resolved, direct_inlay_nested.settings.inlay_hints) direct_diagnostics_nested := json2.decode[DidChangeConfigurationDirectParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationDirectParamsNestedDiagnosticsCompat{} @@ -8455,6 +8547,7 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { DidChangeConfigurationDirectParamsNestedFeaturesCompat{} } merge_workspace_settings(mut resolved, direct_nested.settings.inlay_hints.enabled, direct_nested.settings.diagnostics.enabled) + merge_inlay_hint_options(mut resolved, direct_nested.settings.inlay_hints) return resolved } @@ -8474,6 +8567,26 @@ fn merge_workspace_settings(mut resolved ResolvedWorkspaceSettings, inlay_hints } } +// merge_inlay_hint_options folds the granular inlay-hint switches of one +// decoded settings shape into the resolved settings. Like +// merge_workspace_settings it is first-wins: an absent key keeps whatever an +// earlier shape already set, and unknown keys never reach it because the +// decoder drops them. +fn merge_inlay_hint_options(mut resolved ResolvedWorkspaceSettings, inlay_hints WorkspaceInlayHintsSettings) { + if !resolved.has_variable_types { + if variable_types := inlay_hints.variable_types { + resolved.variable_types = variable_types + resolved.has_variable_types = true + } + } + if !resolved.has_parameter_names { + if parameter_names := inlay_hints.parameter_names { + resolved.parameter_names = parameter_names + resolved.has_parameter_names = true + } + } +} + fn (mut app App) on_initialize(request Request) ?string { params := json2.decode[InitializeParams](request.params) or { msg := 'Invalid initialize params: ${err.msg()}' diff --git a/handlers_test.v b/handlers_test.v index 751ebbe4..16177c12 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -16091,3 +16091,174 @@ fn test_cold_open_via_did_open_sees_sibling_banner() { assert location.uri == path_to_uri(banner_path), 'definition points at the sibling file' assert location.range.start.line == 2, 'definition points at the banner declaration' } + +fn test_inlay_hint_toggles_default_to_enabled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + toggles := app.inlay_hint_toggles() + assert toggles.variable_types, 'variable type hints are enabled by default' + assert toggles.parameter_names, 'parameter name hints are enabled by default' + uri := 'file:///test_inlay_toggles_default.v' + content := 'module main\n\nfn main() {\n\tx := 42\n}\n' + app.open_files[uri] = content + hints := inlay_hints_request(mut app, uri, content) + assert hints.len == 1, 'expected one heuristic type hint, got ${hints.len}' + assert hints[0].kind == inlay_hint_kind_type, 'the heuristic hint is a type hint' + assert hints[0].label == ': int', 'the heuristic hint labels the int type, got ${hints[0].label}' +} + +fn test_inlay_hint_variable_types_toggle_filters_type_hints() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///test_inlay_toggles_variable_types.v' + content := 'module main\n\nfn main() {\n\tx := 42\n}\n' + app.open_files[uri] = content + assert inlay_hints_request(mut app, uri, content).len == 1, 'baseline shows the type hint' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":false}}}}' + }) + assert !app.inlay_hint_toggles().variable_types, 'variableTypes:false is stored' + assert app.inlay_hint_toggles().parameter_names, 'parameterNames stays enabled' + assert app.inlay_hints_enabled, 'a sub-option leaves the master switch untouched' + assert inlay_hints_request(mut app, uri, content).len == 0, 'a disabled type hint filters out' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":true}}}}' + }) + assert app.inlay_hint_toggles().variable_types, 'variableTypes:true re-enables without restart' + assert inlay_hints_request(mut app, uri, content).len == 1, 'a re-enabled type hint shows again' +} + +fn test_inlay_hint_parameter_names_toggle_filters_parameter_hints() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///test_inlay_toggles_parameter_names.v' + content := 'module main\n\nfn main() {}\n' + app.open_files[uri] = content + app.inlay_hint_cache[uri] = CachedInlayHints{ + stamp: app.inlay_hint_stamp(uri, content) + hints: [ + InlayHint{ + position: Position{ + line: 2 + char: 10 + } + label: ': string' + kind: inlay_hint_kind_type + }, + InlayHint{ + position: Position{ + line: 2 + char: 16 + } + label: 'name: ' + kind: inlay_hint_kind_parameter + }, + ] + } + labels := inlay_hints_request(mut app, uri, content).map(it.label) + assert ': string' in labels, 'baseline shows the type hint: ${labels}' + assert 'name: ' in labels, 'baseline shows the parameter hint: ${labels}' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"parameterNames":false}}}}' + }) + filtered := inlay_hints_request(mut app, uri, content).map(it.label) + assert filtered == [': string'], 'only the parameter hint filters out: ${filtered}' + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"parameterNames":true}}}}' + }) + restored := inlay_hints_request(mut app, uri, content).map(it.label) + assert restored.len == 2, 'a re-enabled parameter hint shows again: ${restored}' +} + +fn test_inlay_hint_toggles_ignore_unknown_keys() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"variableTypes":false,"futureKind":true,"nested":{"deep":true}}}}}' + }) + toggles := app.inlay_hint_toggles() + assert !toggles.variable_types, 'a known sub-option applies despite unknown siblings' + assert toggles.parameter_names, 'parameterNames stays enabled' + assert app.inlay_hints_enabled, 'unknown keys never touch the master switch' +} + +fn test_inlay_hint_toggles_cover_all_configuration_shapes() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + // Direct settings shape without the `vls` section. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"inlayHints":{"parameterNames":false}}}' + }) + assert !app.inlay_hint_toggles().parameter_names, 'the direct shape applies parameterNames' + assert app.inlay_hint_toggles().variable_types, 'the direct shape leaves variableTypes alone' + // Nested shape carrying the master switch together with sub-options. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":{"enabled":true,"variableTypes":false,"parameterNames":true},"diagnostics":{"enabled":true}}}}' + }) + assert app.inlay_hints_enabled, 'a nested enabled applies alongside sub-options' + assert !app.inlay_hint_toggles().variable_types, 'the nested shape applies variableTypes' + assert app.inlay_hint_toggles().parameter_names, 'the nested shape applies parameterNames' + assert app.diagnostics_enabled, 'a sibling diagnostics shape still applies' + // The flat boolean master switch keeps working and leaves sub-options alone. + app.on_did_change_configuration(Request{ + method: 'workspace/didChangeConfiguration' + params: '{"settings":{"vls":{"inlayHints":false}}}' + }) + assert !app.inlay_hints_enabled, 'the flat master switch still disables hints' + assert !app.inlay_hint_toggles().variable_types, 'the flat master switch leaves variableTypes alone' + assert app.inlay_hint_toggles().parameter_names, 'the flat master switch leaves parameterNames alone' +} + +fn test_filter_inlay_hints_by_toggles_keeps_unknown_kinds() { + hints := [ + InlayHint{ + position: Position{ + line: 0 + char: 1 + } + label: ': int' + kind: inlay_hint_kind_type + }, + InlayHint{ + position: Position{ + line: 0 + char: 5 + } + label: 'name: ' + kind: inlay_hint_kind_parameter + }, + InlayHint{ + position: Position{ + line: 0 + char: 9 + } + label: '?' + kind: 99 + }, + ] + all_on := filter_inlay_hints_by_toggles(hints, InlayHintToggles{}) + assert all_on.len == 3, 'enabled toggles keep every hint, got ${all_on.len}' + all_off := filter_inlay_hints_by_toggles(hints, InlayHintToggles{ + variable_types: false + parameter_names: false + }) + assert all_off.len == 1, 'disabled toggles filter only the known kinds, got ${all_off.len}' + assert all_off[0].kind == 99, 'an unknown future kind passes through' +} diff --git a/lsp.v b/lsp.v index 1143ac82..52415092 100644 --- a/lsp.v +++ b/lsp.v @@ -389,9 +389,16 @@ struct WorkspaceVlsSettings { } // WorkspaceInlayHintsSettings supports client payloads that send -// `settings.vls.inlayHints.enabled` as a nested object. +// `settings.vls.inlayHints` as a nested object. `enabled` is the master +// switch; `variableTypes` filters the type hints (kind 1, `: int`) and +// `parameterNames` filters the parameter hints (kind 2, `name: `, including +// the field names of positional struct literals, which the compiler reports +// with the same kind). Absent keys keep the current value, so old payloads +// behave exactly as before; unknown keys are ignored by the decoder. struct WorkspaceInlayHintsSettings { - enabled ?bool + enabled ?bool + variable_types ?bool @[json: 'variableTypes'] + parameter_names ?bool @[json: 'parameterNames'] } // WorkspaceDiagnosticsSettings supports client payloads that send @@ -781,8 +788,10 @@ struct TextEdit { new_text string @[json: 'newText'] } -// InlayHintKind 1 = Type hint, 2 = Parameter hint +// InlayHintKind 1 = Type hint, 2 = Parameter hint. The compiler reports both: +// variable and const types as 1, call argument and struct field names as 2. const inlay_hint_kind_type = 1 +const inlay_hint_kind_parameter = 2 // InlayHint represents a hint shown inline in the editor (type or parameter). struct InlayHint { From 8cd9cdf4ba1499a605492b1669119563c4f3cf3a Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:23:44 +0330 Subject: [PATCH 21/83] feat: trigram fuzzy ranking for workspace symbols Per-symbol trigram caches maintained incrementally inside index entries, ranked by overlap then match class, case and underscore insensitive with a bounded per-symbol cap. --- fuzzy_index.v | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.v | 47 +++++++++---- index_test.v | 70 +++++++++++++++++++ 3 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 fuzzy_index.v diff --git a/fuzzy_index.v b/fuzzy_index.v new file mode 100644 index 00000000..57a6c061 --- /dev/null +++ b/fuzzy_index.v @@ -0,0 +1,186 @@ +module main + +// Trigram/fuzzy ranking for workspace symbols (see query_workspace_symbols). +// +// Per-symbol trigrams are extracted lowercased and underscore-insensitive, and +// cached in IndexEntry.symbol_trigrams keyed by display name (`Parent.child` +// for members, mirroring the query traversal). The trigram-to-symbols reverse +// map is assembled per query from that cache and used to score candidates by +// trigram overlap; matches are then ranked by overlap first and by +// exact/prefix/substring/subsequence preference second. +// +// The cache is maintained incrementally by construction: build_index_entry +// fills it on every (re)parse, the content fingerprint in reindex_uri skips +// rebuilds for unchanged files, and every drop path (drop_index_uri, +// drop_index_aliases_for_path, drop_index_under, reconcile_indexed_dir, +// invalidate_index_uri) deletes the entry and its trigrams together — the same +// lifecycle as symbol_index/ref_occurrences. No separate bookkeeping map is +// kept: this repo builds without `-enable-globals`, and the App struct lives +// in main.v, so an index-side global reverse map is not available. + +// fuzzy_trigram_max_per_symbol caps the stored trigram list per symbol, so the +// cache grows with the symbol count and never with name length. +const fuzzy_trigram_max_per_symbol = 32 + +// Match classes, best first. Only classes 0..3 are returned; anything else is +// not a match. +const fuzzy_class_exact = 0 +const fuzzy_class_prefix = 1 +const fuzzy_class_substring = 2 +const fuzzy_class_subsequence = 3 + +// fuzzy_normalize lowercases and drops underscores so `helper_name` also +// matches `helpername`-style queries. +fn fuzzy_normalize(s string) string { + return s.to_lower().replace('_', '') +} + +// fuzzy_trigrams returns the distinct trigrams of an already-normalized name +// in first-seen order, capped at fuzzy_trigram_max_per_symbol. Names shorter +// than 3 characters contribute no trigrams. +fn fuzzy_trigrams(normalized string) []string { + if normalized.len < 3 { + return []string{} + } + mut out := []string{cap: fuzzy_trigram_max_per_symbol} + mut seen := map[string]bool{} + for i in 0 .. normalized.len - 2 { + if out.len >= fuzzy_trigram_max_per_symbol { + break + } + tri := normalized[i..i + 3] + if tri in seen { + continue + } + seen[tri] = true + out << tri + } + return out +} + +// fuzzy_entry_trigrams flattens `doc_symbols` (top-level symbols plus one +// level of `Parent.child` members, mirroring query_workspace_symbols) into +// display-name to trigram lists for IndexEntry.symbol_trigrams. +fn fuzzy_entry_trigrams(doc_symbols []DocumentSymbol) map[string][]string { + mut out := map[string][]string{} + for sym in doc_symbols { + out[sym.name] = fuzzy_trigrams(fuzzy_normalize(sym.name)) + for child in sym.children { + child_name := '${sym.name}.${child.name}' + out[child_name] = fuzzy_trigrams(fuzzy_normalize(child_name)) + } + } + return out +} + +// fuzzy_is_subsequence reports whether every byte of `needle` appears in +// `haystack` in order. Both sides must already be normalized. +fn fuzzy_is_subsequence(needle string, haystack string) bool { + if needle.len == 0 { + return true + } + mut ni := 0 + for hi in 0 .. haystack.len { + if haystack[hi] == needle[ni] { + ni++ + if ni >= needle.len { + return true + } + } + } + return false +} + +// fuzzy_match_class ranks how one symbol relates to the query: exact, prefix, +// or substring on the normalized (and raw-lowercased) name, else +// subsequence-only, else -1 for no match. +fn fuzzy_match_class(normalized_name string, raw_lower_name string, q string, nq string) int { + if normalized_name == nq { + return fuzzy_class_exact + } + if normalized_name.starts_with(nq) { + return fuzzy_class_prefix + } + if raw_lower_name.contains(q) || normalized_name.contains(nq) { + return fuzzy_class_substring + } + if fuzzy_is_subsequence(nq, normalized_name) { + return fuzzy_class_subsequence + } + return -1 +} + +// FuzzyRankedSymbol is one workspace-symbol candidate with its ranking keys. +struct FuzzyRankedSymbol { + overlap int + cls int + name string + uri string + kind int + sel LSPRange +} + +// fuzzy_rank_compare orders candidates by trigram overlap (best first), then +// match class, then name/uri/position/kind so the order is total and stable +// across runs. +fn fuzzy_rank_compare(a &FuzzyRankedSymbol, b &FuzzyRankedSymbol) int { + if a.overlap != b.overlap { + return b.overlap - a.overlap + } + if a.cls != b.cls { + return a.cls - b.cls + } + if a.name != b.name { + return if a.name < b.name { -1 } else { 1 } + } + if a.uri != b.uri { + return if a.uri < b.uri { -1 } else { 1 } + } + if a.sel.start.line != b.sel.start.line { + return a.sel.start.line - b.sel.start.line + } + if a.sel.start.char != b.sel.start.char { + return a.sel.start.char - b.sel.start.char + } + if a.kind != b.kind { + return a.kind - b.kind + } + return 0 +} + +// fuzzy_rank_symbol scores one display name against the query and appends it +// to `ranked` when it matches. `cached_tris` is the symbol's trigram list from +// IndexEntry.symbol_trigrams (empty when the entry predates the cache). +// Subsequence-only recall needs a normalized query of at least 3 characters; +// shorter queries keep the legacy substring behavior exactly. +fn fuzzy_rank_symbol(mut ranked []FuzzyRankedSymbol, uri string, name string, kind int, sel LSPRange, cached_tris []string, q string, nq string, query_tris []string, use_fuzzy_recall bool) { + raw_lower := name.to_lower() + normalized := fuzzy_normalize(name) + cls := fuzzy_match_class(normalized, raw_lower, q, nq) + if cls < 0 || cls > fuzzy_class_subsequence { + return + } + if cls == fuzzy_class_subsequence && !use_fuzzy_recall { + return + } + mut overlap := 0 + if query_tris.len > 0 && cached_tris.len > 0 { + mut sym_set := map[string]bool{} + for tri in cached_tris { + sym_set[tri] = true + } + for tri in query_tris { + if tri in sym_set { + overlap++ + } + } + } + ranked << FuzzyRankedSymbol{ + overlap: overlap + cls: cls + name: name + uri: uri + kind: kind + sel: sel + } +} diff --git a/index.v b/index.v index 9fbd9955..80e4c3ed 100644 --- a/index.v +++ b/index.v @@ -27,7 +27,8 @@ struct IndexEntry { public_module_completions []Detail // exported completion items for imported modules has_conditional_module_completions bool has_conditional_public_completions bool - conditional_lines []bool // declarations guarded by $if/$else or @[if] + conditional_lines []bool // declarations guarded by $if/$else or @[if] + symbol_trigrams map[string][]string // display name -> capped trigram list for fuzzy ranking (see fuzzy_index.v) } // build_index_entry parses `content` into an IndexEntry. Symbol ranges are @@ -67,6 +68,7 @@ fn build_index_entry(content string, enc PositionEncoding) IndexEntry { has_conditional_module_completions: module_completion_index.has_conditional has_conditional_public_completions: public_module_completion_index.has_conditional conditional_lines: conditional_lines + symbol_trigrams: fuzzy_entry_trigrams(doc_syms) } } @@ -966,28 +968,49 @@ fn (mut app App) ensure_loose_file_dirs_shallow_indexed() { } } -// query_workspace_symbols returns all indexed symbols whose name contains -// `query` (case-insensitive; empty matches all), including struct fields and -// enum members as `Parent.child`. The index must already be populated. +// query_workspace_symbols returns indexed symbols matching `query`, +// ranked by trigram overlap first and by exact/prefix/substring/subsequence +// preference second (see fuzzy_index.v). An empty query lists every indexed +// symbol, including struct fields and enum members as `Parent.child`. The +// index must already be populated. fn (app &App) query_workspace_symbols(query string) []WorkspaceSymbol { q := query.to_lower() - mut results := []WorkspaceSymbol{} - mut seen := map[string]bool{} mut uris := app.symbol_index.keys() uris.sort() + if q == '' { + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + for uri in uris { + entry := app.symbol_index[uri] or { continue } + for sym in entry.doc_symbols { + add_workspace_symbol(mut results, mut seen, sym.name, sym.kind, uri, sym.selection_range) + for child in sym.children { + add_workspace_symbol(mut results, mut seen, '${sym.name}.${child.name}', child.kind, uri, child.selection_range) + } + } + } + return results + } + nq := fuzzy_normalize(query) + use_fuzzy_recall := nq.len >= 3 + query_tris := fuzzy_trigrams(nq) + mut ranked := []FuzzyRankedSymbol{} for uri in uris { entry := app.symbol_index[uri] or { continue } for sym in entry.doc_symbols { - if q == '' || sym.name.to_lower().contains(q) { - add_workspace_symbol(mut results, mut seen, sym.name, sym.kind, uri, sym.selection_range) - } + fuzzy_rank_symbol(mut ranked, uri, sym.name, sym.kind, sym.selection_range, entry.symbol_trigrams[sym.name], q, nq, query_tris, use_fuzzy_recall) for child in sym.children { - if q == '' || child.name.to_lower().contains(q) { - add_workspace_symbol(mut results, mut seen, '${sym.name}.${child.name}', child.kind, uri, child.selection_range) - } + child_name := '${sym.name}.${child.name}' + fuzzy_rank_symbol(mut ranked, uri, child_name, child.kind, child.selection_range, entry.symbol_trigrams[child_name], q, nq, query_tris, use_fuzzy_recall) } } } + ranked.sort_with_compare(fuzzy_rank_compare) + mut results := []WorkspaceSymbol{} + mut seen := map[string]bool{} + for r in ranked { + add_workspace_symbol(mut results, mut seen, r.name, r.kind, r.uri, r.sel) + } return results } diff --git a/index_test.v b/index_test.v index 6d3d9fc8..9d057a3c 100644 --- a/index_test.v +++ b/index_test.v @@ -1205,3 +1205,73 @@ fn test_index_uri_within_any_matches_paths_under_dirs() { assert !uri_within_any(uri, [os.join_path(dir, 'sub')]), 'uri outside subdir does not match' assert !uri_within_any(uri, []string{}), 'empty dir list matches nothing' } + +// --- trigram/fuzzy workspace symbol ranking (see fuzzy_index.v) --- + +fn test_fuzzy_normalize_drops_underscores_and_case() { + assert fuzzy_normalize('Helper_Name') == 'helpername', 'underscore and case are dropped' + assert fuzzy_normalize('abc') == 'abc', 'plain names pass through' + assert fuzzy_normalize('') == '', 'empty stays empty' +} + +fn test_fuzzy_trigrams_cap_bounds_memory() { + assert fuzzy_trigrams('ab').len == 0, 'names shorter than 3 contribute no trigrams' + assert fuzzy_trigrams('abc') == ['abc'], 'a 3-letter name yields its single trigram' + tris := fuzzy_trigrams('abcdefghijklmnopqrstuvwxyz0123456789') + assert tris.len == fuzzy_trigram_max_per_symbol, 'long names are capped, got ${tris.len}' + assert tris[0] == 'abc', 'trigrams keep first-seen order' +} + +fn test_query_workspace_symbols_ranks_overlap_then_match_class() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_rank.v'] = 'module main\n\nfn alphabet() {}\n\nfn xxalphabet() {}\n\nfn axlphabet() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + got := app.query_workspace_symbols('alphabet').map(it.name) + assert got == ['alphabet', 'xxalphabet', 'axlphabet'], 'exact, then higher-overlap substring, then subsequence-only, got ${got}' +} + +fn test_query_workspace_symbols_matches_underscore_insensitive_and_subsequence() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_sub.v'] = 'module main\n\nfn helper_name() {}\n\nfn unrelated() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + exact := app.query_workspace_symbols('helpername').map(it.name) + assert exact == ['helper_name'], 'underscore-insensitive exact match, got ${exact}' + sub := app.query_workspace_symbols('hlprnm').map(it.name) + assert sub == ['helper_name'], 'subsequence-only match with zero trigram overlap, got ${sub}' +} + +fn test_query_workspace_symbols_short_query_keeps_substring_recall() { + mut app := index_test_app() + app.open_files['file:///tmp/fuzzy_short.v'] = 'module main\n\nfn alpha() {}\n\nfn beta() {}\n' + app.ensure_dirs_indexed(app.index_query_dirs()) + got := app.query_workspace_symbols('al').map(it.name) + assert got == ['alpha'], 'two-letter query keeps legacy substring recall, got ${got}' +} + +fn test_fuzzy_trigram_cache_follows_reindex_and_drop() { + mut app := index_test_app() + uri := 'file:///tmp/fuzzy_inc.v' + app.open_files[uri] = 'module main\n\nfn one() {}\n' + app.reindex_uri(uri) + assert 'one' in app.symbol_index[uri].symbol_trigrams, 'reindex caches trigrams for the new symbol' + assert app.symbol_index[uri].symbol_trigrams['one'].len > 0, 'short symbol one still yields a trigram' + app.open_files[uri] = 'module main\n\nfn two() {}\n' + app.reindex_uri(uri) + assert 'one' !in app.symbol_index[uri].symbol_trigrams, 'reindex drops trigrams of the removed symbol' + assert 'two' in app.symbol_index[uri].symbol_trigrams, 'reindex caches trigrams of the new symbol' + assert app.query_workspace_symbols('one').len == 0, 'removed symbol is not found' + assert app.query_workspace_symbols('two').len == 1, 'new symbol is found' + app.drop_index_uri(uri) + assert app.query_workspace_symbols('two').len == 0, 'dropped uri yields no symbols' +} + +fn test_fuzzy_trigram_cache_caps_long_names() { + mut app := index_test_app() + uri := 'file:///tmp/fuzzy_cap.v' + long_name := 'abcdefghijklmnopqrstuvwxyz0123456789' + app.open_files[uri] = 'module main\n\nfn ${long_name}() {}\n' + app.reindex_uri(uri) + cached := app.symbol_index[uri].symbol_trigrams[long_name] + assert cached.len == fuzzy_trigram_max_per_symbol, 'cached trigrams of a long name are capped, got ${cached.len}' + assert app.query_workspace_symbols(long_name).any(it.name == long_name), 'capped long name is still found exactly' +} From 9144e3600b4c78012fa0d58f915e61c3ecfc2089 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:52:56 +0330 Subject: [PATCH 22/83] perf: pre-size the symbol index before bulk walks Inserting thousands of entries one by one doubles the map backing store repeatedly; a late doubling can abort under a fragmented heap (GC_alloc_large on big trees). Reserve once from the walk's file count. --- index.v | 9 +++++++++ index_test.v | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/index.v b/index.v index 80e4c3ed..fb8f61cd 100644 --- a/index.v +++ b/index.v @@ -639,6 +639,15 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { walk_complete := collect_v_files(dir, mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true + } else { + // Pre-size once: inserting thousands of entries one by one would + // otherwise double the backing store repeatedly, and a late + // doubling can fail under a fragmented heap (GC_alloc_large abort + // on big trees). Reserve is a no-op for non-empty maps. + want := app.symbol_index.len + files.len + if want > 0 && want <= index_max_files { + app.symbol_index.reserve(u32(want)) + } } mut present := map[string]bool{} for f in files { diff --git a/index_test.v b/index_test.v index 9d057a3c..863bf4e9 100644 --- a/index_test.v +++ b/index_test.v @@ -19,6 +19,28 @@ fn index_test_tmpdir(tag string) string { return dir } +fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { + // ensure_dirs_indexed reserves the map before a bulk walk so inserts do + // not double the backing store repeatedly under a fragmented heap. + mut app := index_test_app() + app.symbol_index['file:///old.v'] = IndexEntry{} + app.symbol_index.reserve(1000) + assert app.symbol_index.len == 1, 'reserve must keep existing entries' + root := index_test_tmpdir('presized') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 50 { + os.write_file(os.join_path(root, 'f${i}.v'), 'module main\n\nfn f${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + app.ensure_dirs_indexed([root]) + assert app.symbol_index.len == 51, 'got ${app.symbol_index.len}' + assert 'file:///old.v' in app.symbol_index +} + fn test_index_workspace_symbols_from_open_buffers() { mut app := index_test_app() app.open_files['file:///tmp/a.v'] = 'module main\n\nfn alpha() {}\n\nstruct Beta {\n\tx int\n}\n' From dcbe82d31dfee9c0306d79fd9f0daf170f89b7ac Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:57:04 +0330 Subject: [PATCH 23/83] docs: record fingerprint cache, pool routing, parse errors, matrix --- ROADMAP.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5f56fe8a..565f3c2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,8 +98,12 @@ needs the V1 compatibility compiler, which this host cannot build ## Phase 2 — navigation / hints parity -- Fuzzy workspace symbols (trigram index). -- Granular inlay-hint toggles, conservative defaults. +- Fuzzy workspace symbols (trigram index): DONE — per-symbol + trigram caches in index entries, overlap then match-class + ranking, case/underscore insensitive, capped per symbol. +- Granular inlay-hint toggles, conservative defaults: DONE — + `vls.inlayHints.variableTypes` / `.parameterNames`, both + default on, runtime toggle, no restart. - Richer hover (const values, methods, doc links). ## Phase 3 — full refactors From 2df3084ee69c4fab0741695479f0cacb61c9c4eb Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 06:44:46 +0330 Subject: [PATCH 24/83] fix: apply the index size gate to open buffers reindex_uri capped disk files at 2MB but indexed open buffers of any size, allowing one huge buffer to force an unbounded index build. The gate now covers both paths; oversized entries are dropped and marked skipped exactly like oversized disk files. --- index.v | 7 +++++++ index_test.v | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/index.v b/index.v index e96c32b5..9fbd9955 100644 --- a/index.v +++ b/index.v @@ -397,6 +397,13 @@ fn (app &App) path_is_in_removed_workspace(path string) bool { fn (mut app App) reindex_uri(uri string) { mut content := '' if open_content := app.open_files[uri] { + // Open buffers bypass the disk size gate below; apply it here so a + // huge buffer cannot force an unbounded index build. + if u64(open_content.len) > index_max_file_bytes { + app.drop_index_uri(uri) + app.index_skipped_uris[uri] = true + return + } content = open_content app.index_skipped_uris.delete(uri) } else { diff --git a/index_test.v b/index_test.v index aaab9ab9..3886f9b7 100644 --- a/index_test.v +++ b/index_test.v @@ -130,6 +130,33 @@ fn test_watched_file_reindex_drops_oversized_disk_entry() { assert uri !in app.ref_occurrences } +fn test_open_buffer_reindex_skips_oversized_content() { + // Open buffers bypass the disk size gate; a huge buffer must not force + // an unbounded index build. + root := index_test_tmpdir('open_large') + defer { + os.rmdir_all(root) or {} + } + path := os.join_path(root, 'large.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn small() {}\n') or { + assert false, 'write initial file failed: ${err}' + return + } + mut app := index_test_app() + big := 'x'.repeat(int(index_max_file_bytes) + 1) + app.open_files[uri] = big + app.reindex_uri(uri) + assert uri !in app.symbol_index + assert uri !in app.ref_occurrences + assert uri in app.index_skipped_uris + // Shrinking the buffer below the gate re-indexes it. + app.open_files[uri] = 'module main\n\nfn small_again() {}\n' + app.reindex_uri(uri) + assert uri in app.symbol_index + assert uri !in app.index_skipped_uris +} + fn test_watched_file_reindex_obeys_total_entry_limit() { root := index_test_tmpdir('watched_count') defer { From 4d8d0d57926a4797abef2e6cafb2d1440828e301 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 07:52:56 +0330 Subject: [PATCH 25/83] perf: pre-size the symbol index before bulk walks Inserting thousands of entries one by one doubles the map backing store repeatedly; a late doubling can abort under a fragmented heap (GC_alloc_large on big trees). Reserve once from the walk's file count. --- index.v | 9 +++++++++ index_test.v | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/index.v b/index.v index 9fbd9955..25b9765c 100644 --- a/index.v +++ b/index.v @@ -637,6 +637,15 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { walk_complete := collect_v_files(dir, mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true + } else { + // Pre-size once: inserting thousands of entries one by one would + // otherwise double the backing store repeatedly, and a late + // doubling can fail under a fragmented heap (GC_alloc_large abort + // on big trees). Reserve is a no-op for non-empty maps. + want := app.symbol_index.len + files.len + if want > 0 && want <= index_max_files { + app.symbol_index.reserve(u32(want)) + } } mut present := map[string]bool{} for f in files { diff --git a/index_test.v b/index_test.v index 3886f9b7..beadb1cf 100644 --- a/index_test.v +++ b/index_test.v @@ -19,6 +19,28 @@ fn index_test_tmpdir(tag string) string { return dir } +fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { + // ensure_dirs_indexed reserves the map before a bulk walk so inserts do + // not double the backing store repeatedly under a fragmented heap. + mut app := index_test_app() + app.symbol_index['file:///old.v'] = IndexEntry{} + app.symbol_index.reserve(1000) + assert app.symbol_index.len == 1, 'reserve must keep existing entries' + root := index_test_tmpdir('presized') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 50 { + os.write_file(os.join_path(root, 'f${i}.v'), 'module main\n\nfn f${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + app.ensure_dirs_indexed([root]) + assert app.symbol_index.len == 51, 'got ${app.symbol_index.len}' + assert 'file:///old.v' in app.symbol_index +} + fn test_index_workspace_symbols_from_open_buffers() { mut app := index_test_app() app.open_files['file:///tmp/a.v'] = 'module main\n\nfn alpha() {}\n\nstruct Beta {\n\tx int\n}\n' From 677d507eb4f8e2e39e6f0f04aed3ae02c471d9a3 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 13:47:13 +0330 Subject: [PATCH 26/83] feat: refuse renames that shadow a binding or break an interface Renaming to a name already live in the scope, or renaming a member of an interface its implementations must keep, is refused with a message naming the conflict, instead of silently producing code that will not compile. --- handlers.v | 8 + handlers_test.v | 522 +++++++++++++++++++++++++++++++++++++++++++++ rename.v | 107 ++++++++-- rename_conflicts.v | 175 +++++++++++++++ 4 files changed, 790 insertions(+), 22 deletions(-) diff --git a/handlers.v b/handlers.v index 9d1ebd3c..471573d3 100644 --- a/handlers.v +++ b/handlers.v @@ -7939,6 +7939,14 @@ fn (mut app App) handle_code_action(request Request) Response { } } + // 3. Fill struct literal — add the fields a literal omits, with their zero + // values, so an incomplete literal can be completed from the lightbulb. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + if action := app.build_fill_struct_literal_action(uri, content, params.range) { + actions << action + } + } + return Response{ id: request.id result: actions diff --git a/handlers_test.v b/handlers_test.v index 16177c12..dfa01a1b 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -10593,6 +10593,378 @@ fn test_code_action_kind_wanted_respects_only_filter() { assert !code_action_kind_wanted(['source.organizeImports'], 'quickfix') } +// --- Fill struct literal --- + +const fill_literal_types = 'module main + +enum Mode { + idle + fast +} + +struct Shade { + level int +} + +struct Pixel { + name string + x int + ratio f64 + tags []string + scores map[string]int + shade Shade + mode Mode + retries ?int + hidden bool +} +' + +// fill_literal_main returns a project whose main() holds `body`, with `Pixel`, +// `Shade` and `Mode` declared for it to use. +fn fill_literal_main(body string) string { + return fill_literal_types + '\nfn main() {\n' + body + '\n}\n' +} + +// new_fill_literal_app writes `files` into a project on disk, opens them all in +// the editor and returns the app with the URI of each file. +fn new_fill_literal_app(files map[string]string) (&App, map[string]string) { + mut app := create_test_app() + dir := os.join_path(app.temp_dir, 'fill_project') + must_mkdir_all(dir) + must_write_file(os.join_path(dir, 'v.mod'), 'Module {}\n') + mut uris := map[string]string{} + for name, content in files { + path := os.join_path(dir, name) + must_mkdir_all(os.dir(path)) + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + uris[name] = uri + } + app.workspace_roots = [dir] + return app, uris +} + +// fill_literal_action returns the "Fill struct literal" action offered for the +// range `sel` in `uri`, or none when the request offers no such action. +fn fill_literal_action(mut app App, uri string, sel LSPRange, only []string) ?CodeAction { + resp := app.handle_code_action(Request{ + id: 1 + params: json2.encode(CodeActionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: sel + context: CodeActionContext{ + only: only + } + }, + escape_unicode: true + ) + }) + if resp.result is []CodeAction { + for action in resp.result as []CodeAction { + if action.title == 'Fill struct literal' { + return action + } + } + } + return none +} + +// position_of returns where the first `needle` of `content` sits, which is both +// where a cursor on it lands and where a range selecting it starts. +fn position_of(content string, needle string) Position { + for line, text in content.split_into_lines() { + if col := text.index(needle) { + return Position{ + line: line + char: col + } + } + } + assert false, 'fixture holds no ${needle}' + return Position{} +} + +// cursor_range returns the empty range an editor reports for a cursor sitting on +// the first `needle` of `content`. +fn cursor_range(content string, needle string) LSPRange { + at := position_of(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// selection_range returns the range covering the first `needle` of `content`, +// what an editor reports for a selected token. +fn selection_range(content string, needle string) LSPRange { + at := position_of(content, needle) + return LSPRange{ + start: at + end: Position{ + line: at.line + char: at.char + needle.len + } + } +} + +// apply_text_edit applies one edit to `content`, so a test can assert what the +// document looks like once the quick fix is taken. +fn apply_text_edit(content string, edit TextEdit) string { + mut lines := content.split_into_lines() + line := lines[edit.range.start.line] + lines[edit.range.start.line] = line[..edit.range.start.char] + edit.new_text + + line[edit.range.end.char..] + return if content.ends_with('\n') { lines.join('\n') + '\n' } else { lines.join('\n') } +} + +// The mask every scan below reads keeps the length of the text it stands for, so +// an offset found in it addresses the same byte of the original document, and it +// blanks a string body where it sits rather than at the end of its line. A line +// comment, a block comment, and a string holding a brace all have to survive. +fn test_masked_v_code_keeps_every_offset_pointing_at_the_same_byte() { + for content in ['', 'a', 'a\n', 'p := Pixel{}\n', 'x := "{" + \'}\'\n', '// c\np := Pixel{}\n', + 'p := Pixel{ // c\n}\n', 'p := Pixel{\n\t\tx: 1\n\t}\n', 's := "x${1 + 2}y"\n'] { + assert masked_v_code(content).len == content.len, 'the mask of `${content}` keeps its length' + } + assert masked_v_code('x := "}"\n').len == 9, 'the mask of a string keeps its length' + assert masked_v_code('x := "}"\n')[5] == `"`, 'the quote that opens a string stays' + assert masked_v_code('x := "}"\n')[6] == ` `, 'a string body is blanked where it sits' + assert masked_v_code('x := "}"\n')[7] == `"`, 'the quote that closes a string stays' + assert masked_v_code('x := "}"\n')[0] == `x`, 'the code around a string is untouched' + assert !masked_v_code('// dropped\np := Pixel{}\n').contains('dropped'), 'a comment is masked away' + assert masked_v_code('/* multi\nline */ p := x\n').contains('p := x'), 'code after a block comment stays' +} + +// A one-line literal gains every field the index can give a zero value for, in +// the order the struct declares them: the nested struct the index sees becomes +// `Shade{}`, and the enum field and the optional one are left out because no +// literal can be written for either. +fn test_fill_struct_literal_adds_the_missing_fields() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Pixel{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{}'), []) or { + assert false, 'a literal that omits fields must offer "Fill struct literal"' + return + } + assert action.kind == code_action_kind_quickfix, 'the fill is offered as a quick fix' + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + assert edits[0].range.start == edits[0].range.end, 'the fill inserts, it replaces nothing' + literal_line := position_of(content, 'Pixel{}').line + assert edits[0].range.start.line == literal_line, 'the edit is on the line of the literal' + assert edits[0].range.start.char == '\tp := Pixel{'.len, 'the edit goes before the closing brace' + assert edits[0].new_text == "name: '', x: 0, ratio: 0, tags: []string{}, scores: map[string]int{}, shade: Shade{}, hidden: false", 'each missing field is added with its zero value' + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got.len == content.split_into_lines().len, 'a one-line fill adds and removes no line' + assert got[literal_line] == "\tp := Pixel{name: '', x: 0, ratio: 0, tags: []string{}, scores: map[string]int{}, shade: Shade{}, hidden: false}", 'the filled literal holds every field on one line' +} + +// A literal already spanning lines keeps the fields it sets and gains the rest +// on their own lines at the indentation its last field uses, with its closing +// brace left on a line of its own. +fn test_fill_struct_literal_keeps_the_fields_already_set() { + body := '\tp := Pixel{\n\t\tx: 1\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'a partial literal must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + '\t\tx: 1,', + "\t\tname: '',", + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false', + '\t}', + '\tprintln(p)', + '}', + ], 'the fields already set are kept and the rest follow at their indentation' +} + +// A brace inside a string must not close the literal, and a comment after the +// last field must not take its place: the new fields follow the field, and the +// comment ends up on the last of them. +fn test_fill_struct_literal_ignores_braces_in_strings_and_comments() { + body := '\tp := Pixel{\n\t\tname: "a}b"\n\t\tx: 1 // note , here\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'a literal with a brace in a string must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + edits := edit.changes[uri] + assert edits.len == 1, 'one edit fills the literal' + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edits[0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + '\t\tname: "a}b"', + '\t\tx: 1,', + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false // note , here', + '\t}', + '\tprintln(p)', + '}', + ], 'the fields follow the last field, and the comment ends up on the last of them' +} + +// A literal that spans lines but holds no field yet takes its fields one level +// past the statement that opens it, and its closing brace stays where it was. +fn test_fill_struct_literal_indents_an_empty_multiline_literal() { + body := '\tp := Pixel{\n\t}\n\tprintln(p)' + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + action := fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) or { + assert false, 'an empty multi-line literal must offer "Fill struct literal"' + return + } + edit := action.edit or { + assert false, 'the fill action must carry an edit' + return + } + literal_line := position_of(content, 'Pixel{').line + got := apply_text_edit(content, edit.changes[uri][0]).split_into_lines() + assert got[literal_line..] == [ + '\tp := Pixel{', + "\t\tname: '',", + '\t\tx: 0,', + '\t\tratio: 0,', + '\t\ttags: []string{},', + '\t\tscores: map[string]int{},', + '\t\tshade: Shade{},', + '\t\thidden: false', + '\t}', + '\tprintln(p)', + '}', + ], 'the first fields of an empty literal sit one level past the statement' +} + +// A literal that already sets every field it can be given has nothing to add, +// and so does one left with only an enum field and an optional one missing. +fn test_fill_struct_literal_is_not_offered_when_nothing_can_be_added() { + complete := "\tp := Pixel{\n\t\tname: 'a'\n\t\tx: 1\n\t\tratio: 0.5\n\t\ttags: ['t']\n\t\tscores: {'a': 1}\n\t\tshade: Shade{level: 1}\n\t\tmode: Mode.fast\n\t\tretries: 2\n\t\thidden: false\n\t}\n\tprintln(p)" + only_unfillable_left := "\tp := Pixel{\n\t\tname: 'a'\n\t\tx: 1\n\t\tratio: 0.5\n\t\ttags: []\n\t\tscores: map[string]int{}\n\t\tshade: Shade{}\n\t\thidden: false\n\t}\n\tprintln(p)" + for body in [complete, only_unfillable_left] { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main(body) + }) + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + assert fill_literal_action(mut app, uri, selection_range(content, 'Pixel{'), []) == none, 'a literal with nothing that can be filled must not offer the action' + cleanup_test_app(app) + } +} + +// A type the index does not know is never filled: guessing its fields would +// invent code. +fn test_fill_struct_literal_is_not_offered_for_an_unknown_type() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Ghost{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + assert fill_literal_action(mut app, uri, selection_range(content, 'Ghost{}'), []) == none, 'a literal of an unknown type must not offer the action' +} + +// A quick fix is offered for a struct literal and nothing else: not for a block +// or a function body, not for a composite or an anonymous element literal, not +// for a struct declaration body, and not for a literal still being typed. +fn test_fill_struct_literal_is_not_offered_outside_a_struct_literal() { + blocks := fill_literal_main('\tprintln("hi")\n\tpixels := []Pixel{\n\t\t{x: 1},\n\t}\n\tprintln(pixels)') + mut app, uris := new_fill_literal_app({ + 'main.v': blocks + }) + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + for needle in ['println("hi")', 'println(pixels)', 'pixels', '[]Pixel{', '{x: 1},', 'retries ?int'] { + assert fill_literal_action(mut app, uri, cursor_range(content, needle), []) == none, 'a cursor on `${needle}` is not on a struct literal' + } + cleanup_test_app(app) + + partial := fill_literal_types + '\nfn main() {\n\tp := Pixel{\n\t\tna\n' + mut partial_app, partial_uris := new_fill_literal_app({ + 'main.v': partial + }) + defer { + cleanup_test_app(partial_app) + } + partial_content := partial_app.open_files[partial_uris['main.v']] or { '' } + assert fill_literal_action(mut partial_app, partial_uris['main.v'], cursor_range(partial_content, + 'na'), []) == none, 'a literal with no closing brace yet must not be filled' +} + +// A client that asks for one kind of action is sent that kind: the quick fix +// filter sees the fill, a filter asking only for something else does not. +fn test_fill_struct_literal_respects_the_kind_the_client_asks_for() { + mut app, uris := new_fill_literal_app({ + 'main.v': fill_literal_main('\tp := Pixel{}\n\tprintln(p)') + }) + defer { + cleanup_test_app(app) + } + uri := uris['main.v'] + content := app.open_files[uri] or { '' } + sel := selection_range(content, 'Pixel{}') + fill_literal_action(mut app, uri, sel, ['quickfix']) or { + assert false, 'a client asking for quick fixes must see the fill' + return + } + assert fill_literal_action(mut app, uri, sel, ['refactor']) == none, 'a client asking only for refactors must not be sent a quick fix' +} + // --- P0-01: PositionCodec (UTF-16 / UTF-8 / UTF-32) --- fn test_encoded_col_to_byte_ascii() { @@ -12013,6 +12385,156 @@ fn test_rename_occurrence_cap_comes_from_the_environment() { assert rename_edit_count(mut app, uris, tock) or { panic(err) } == 12 } +// A local renamed to a name that a `$if` branch this build leaves out already +// holds, and the same the other way round: V checks the branch this build takes, +// so neither clash is visible here, and the platform that builds the branch +// would report a redefinition. +const rename_branch_main = 'module main + +fn main() { + x := 1 + $if !windows { + y := 2 + println(y) + } + println(x) +} +' + +const rename_import_branch_main = 'module main + +import time + +fn main() { + read := 1 + $if !windows { + value := 2 + println(value) + } + println(read + time.now().year) +} +' + +fn test_rename_refuses_a_name_that_a_branch_the_build_leaves_out_would_shadow() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_branch_main + }) + defer { + cleanup_rename_app(mut app) + } + // From the branch, to a name live outside it. + if _ := app.rename_request(rename_request_named(uris, 'main.v:6:3', 'x')) { + assert false, 'renaming `y` to `x` must be refused' + } else { + assert err.msg().contains('`x` is already declared at main.v:4'), err.msg() + assert err.msg().contains('redefinition of `x`'), err.msg() + } + // From outside it, to a name live in the branch. + if _ := app.rename_request(rename_request_named(uris, 'main.v:4:2', 'y')) { + assert false, 'renaming `x` to `y` must be refused' + } else { + assert err.msg().contains('`y` is already declared at main.v:6'), err.msg() + assert err.msg().contains('redefinition of `y`'), err.msg() + } + // A name nothing else holds is renamed as before, branch included. + assert rename_edits_in({ + 'main.v': rename_branch_main + }, 'main.v:6:3') == ['main.v:6:3', 'main.v:7:11'] +} + +// A local inside a branch this build leaves out, renamed to the name of an +// imported module: the other platform reports a duplicate of an import symbol, +// and every use of the module in that function would name the local. +fn test_rename_refuses_a_name_that_a_branch_the_build_leaves_out_would_import() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_import_branch_main + }) + defer { + cleanup_rename_app(mut app) + } + if _ := app.rename_request(rename_request_named(uris, 'main.v:8:3', 'time')) { + assert false, 'renaming `value` to `time` must be refused' + } else { + assert err.msg().contains('`time` is already declared at main.v:3'), err.msg() + assert err.msg().contains('redefinition of `time`'), err.msg() + } + // The module itself is renamed by none of this: its uses stay where they are. + assert rename_edits_in({ + 'main.v': rename_import_branch_main + }, 'main.v:8:3') == ['main.v:8:3', 'main.v:9:11'] +} + +// A struct method with the name of a method an interface declares: the types +// that implement the interface must keep that name, the rename does not change +// the interface with it, so it is refused and the interface is named. +const rename_interface_methods_main = "module main + +interface Speaker { + speak() string +} + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} + +fn (d Dog) fetch() int { + return 1 +} + +fn greet(s Speaker) string { + return s.speak() +} + +fn main() { + d := Dog{ + name: 'rex' + } + println(greet(d) + d.speak()) + println(d.fetch()) +} +" + +fn test_rename_refuses_a_method_an_interface_declares_and_names_the_interface() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_interface_methods_main + }) + defer { + cleanup_rename_app(mut app) + } + // The method of the type that implements the interface. + if _ := app.rename_request(rename_request_named(uris, 'main.v:11:12', 'bark')) { + assert false, 'renaming a method an interface declares must be refused' + } else { + assert err.msg().contains('is a member of the interface `Speaker`'), err.msg() + } + // And the member of the interface itself. + if _ := app.rename_request(rename_request_named(uris, 'main.v:4:2', 'talk')) { + assert false, 'renaming a member of an interface must be refused' + } else { + assert err.msg().contains('is a member of the interface `Speaker`'), err.msg() + } + // A method no interface declares is renamed, with its use. + assert rename_edits_in({ + 'main.v': rename_interface_methods_main + }, 'main.v:15:12') == ['main.v:15:12', 'main.v:28:12'] + // The methods of V's own error interface are named as such. + mut ierror_app, ierror_uris := new_rename_project_app_with({ + 'main.v': rename_interface_main + }) + defer { + cleanup_rename_app(mut ierror_app) + } + if _ := ierror_app.rename_request(rename_request_named(ierror_uris, 'main.v:21:13', 'message')) { + assert false, 'renaming a method of `IError` must be refused' + } else { + assert err.msg().contains('interface `IError`'), err.msg() + } +} + fn test_folding_range_covers_imports_comments_and_code_blocks() { mut app := create_test_app() defer { diff --git a/rename.v b/rename.v index 90026f48..e9f5825a 100644 --- a/rename.v +++ b/rename.v @@ -395,21 +395,38 @@ fn (mut app App) check_implicit_name(target RenameTarget, new_name string) ! { // around and prints it through them. const ierror_method_names = ['msg', 'code'] +// InterfaceDeclaration is an interface, and where its own name is declared. +struct InterfaceDeclaration { + name string + where Location +} + +// InterfaceMember is a method or field that an interface declares, and the +// interface that declares it. +struct InterfaceMember { + member string + iface InterfaceDeclaration +} + // check_interface_member refuses a rename of a member of an interface, or of a // method or a field with the name of one: V needs no declaration to implement // an interface, so the types that implement it must keep that name, and a -// rename cannot see which types those are. +// rename cannot see which types those are. The rename does not change the +// interface with it, so it is refused, and the interface that declares the +// member is named. fn (mut app App) check_interface_member(anchor Location, symbol string, scope IndexScope) ! { - if app.in_interface_body(anchor) { - return error('`${symbol}` is a member of an interface: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') + if iface := app.enclosing_interface(anchor) { + return error('`${symbol}` is a member of the interface `${iface.name}` at ${name_pos_text(app.name_pos_of(iface.where))}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') } kind := app.indexed_declaration_kind(anchor) if kind !in [sym_kind_method, sym_kind_field] { return } - if (kind == sym_kind_method && symbol in ierror_method_names) - || symbol in app.interface_member_names(scope) { - return error('`${symbol}` has the name of a member of an interface: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program') + if member := app.interface_member_owner(symbol, scope) { + return error('`${symbol}` is a member of the interface `${member.iface.name}` at ${name_pos_text(app.name_pos_of(member.iface.where))}: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program') + } + if kind == sym_kind_method && symbol in ierror_method_names { + return error('`${symbol}` is a member of the interface `IError` that V declares itself: a type that has them is an error, and V passes it around and prints it through them, so this rename could break the program') } } @@ -429,10 +446,10 @@ fn (mut app App) indexed_declaration_kind(loc Location) int { return 0 } -// in_interface_body reports whether `loc` is in the body of an interface: the -// name of one of its members, which the index does not list. -fn (mut app App) in_interface_body(loc Location) bool { - content := app.open_files[loc.uri] or { os.read_file(uri_to_path(loc.uri)) or { return false } } +// enclosing_interface returns the interface whose body holds `loc`: where the +// name of one of its members is, which the index does not list. +fn (mut app App) enclosing_interface(loc Location) ?InterfaceDeclaration { + content := app.file_text(loc.uri) lines := content.split_into_lines() code := source_code_lines(content) for s in app.index_doc_symbols(loc.uri) { @@ -441,16 +458,33 @@ fn (mut app App) in_interface_body(loc Location) bool { } first := s.range.start.line if loc.range.start.line > first && loc.range.start.line <= declaration_end_line(lines, code, first) { - return true + return InterfaceDeclaration{ + name: s.name + where: Location{ + uri: loc.uri + range: s.selection_range + } + } } } - return false + return none } -// interface_member_names returns the names of the methods and fields that the -// interfaces of `scope` declare. -fn (mut app App) interface_member_names(scope IndexScope) []string { - mut names := []string{} +// interface_member_owner returns the interface of `scope` that declares +// `member` as one of its methods or fields. +fn (mut app App) interface_member_owner(member string, scope IndexScope) ?InterfaceMember { + for found in app.interface_members(scope) { + if found.member == member { + return found + } + } + return none +} + +// interface_members returns the methods and fields that the interfaces of +// `scope` declare, each with the interface that declares it. +fn (mut app App) interface_members(scope IndexScope) []InterfaceMember { + mut members := []InterfaceMember{} mut uris := app.symbol_index.keys() uris.sort() for uri in uris { @@ -458,13 +492,20 @@ fn (mut app App) interface_member_names(scope IndexScope) []string { || !app.index_doc_symbols(uri).any(it.kind == sym_kind_interface) { continue } - content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { continue } } + content := app.file_text(uri) lines := content.split_into_lines() code := source_code_lines(content) for s in app.index_doc_symbols(uri) { if s.kind != sym_kind_interface { continue } + iface := InterfaceDeclaration{ + name: s.name + where: Location{ + uri: uri + range: s.selection_range + } + } first := s.range.start.line last := declaration_end_line(lines, code, first) for i in first + 1 .. last + 1 { @@ -479,12 +520,15 @@ fn (mut app App) interface_member_names(scope IndexScope) []string { // An embedded interface is a type, capitalized; `mut:` opens a section. name := member[..end] if name != '' && !name[0].is_capital() && !member[end..].starts_with(':') { - names << name + members << InterfaceMember{ + member: name + iface: iface + } } } } } - return names + return members } const type_declaration_kinds = [sym_kind_struct, sym_kind_enum, sym_kind_interface, sym_kind_class] @@ -880,8 +924,19 @@ fn (mut app App) in_conditional_code(loc Location) bool { // `lines`, code without its comments and the text of its strings, is inside // the braces of a `$if` or `$else` branch. fn in_comptime_branch(lines []string, line int, col int) bool { + return comptime_branch_chain(lines, line, col).len > 0 +} + +// comptime_branch_chain returns the lines of the `$if` and `$else` blocks that +// hold the column `col` of the line `line` of `code`, code without its comments +// and the text of its strings, outermost first, and empty when it is outside +// any of them. Two positions in the same file chain are inside the same branch, +// whatever the platform that builds it: V compiles the branch this build takes, +// and nothing of the other one. +fn comptime_branch_chain(code []string, line int, col int) []int { + mut opened := []int{} mut branches := []bool{} - for i, text in lines { + for i, text in code { if i > line { break } @@ -891,11 +946,13 @@ fn in_comptime_branch(lines []string, line int, col int) bool { match text[j] { `{` { head := text[head_start..j] + opened << i branches << (head.contains('\$if') || head.contains('\$else')) head_start = j + 1 } `}` { - if branches.len > 0 { + if opened.len > 0 { + opened.pop() branches.pop() } head_start = j + 1 @@ -907,7 +964,13 @@ fn in_comptime_branch(lines []string, line int, col int) bool { } } } - return branches.any(it) + mut chain := []int{} + for k, is_branch in branches { + if is_branch { + chain << opened[k] + } + } + return chain } // in_module_of reports whether `loc` is in a file of the module whose file diff --git a/rename_conflicts.v b/rename_conflicts.v index 551f7ba1..b2dc361b 100644 --- a/rename_conflicts.v +++ b/rename_conflicts.v @@ -96,6 +96,7 @@ fn (mut app App) check_rename_conflicts(target RenameTarget, locations []Locatio existing: existing uris: uris } + app.check_rename_shadowing(target, rc)! // The files of a program are checked together, each program apart. mut programs := map[string][]string{} for path, _ in uris { @@ -111,6 +112,180 @@ fn (mut app App) check_rename_conflicts(target RenameTarget, locations []Locatio } } +// check_rename_shadowing refuses a rename whose new name is already a live +// binding in the scope of the renamed declaration, in a `$if` or `$else` branch +// that this build leaves out. V reports a redefinition between two names its +// check reads (see rename_clash_in), so what is left for this check is the one +// it cannot read: the build of the platform that takes that branch would report +// it, where a use of the new name would name the binding instead of the +// renamed declaration. +fn (mut app App) check_rename_shadowing(target RenameTarget, rc RenameCheck) ! { + anchor_path := normalize_overlay_path(uri_to_path(target.anchor.uri)) + anchor_col := app.client_col_to_byte_col(target.anchor.uri, target.anchor.range.start.line, + target.anchor.range.start.char) + anchor_chain := comptime_branch_chain(source_code_lines(app.file_text(target.anchor.uri)), + target.anchor.range.start.line, anchor_col) + for pos in rc.existing { + uri := rc.uris[pos.path] or { continue } + lines := app.file_text(uri).split_into_lines() + if pos.line < 0 || pos.line >= lines.len { + continue + } + code := source_code_lines(lines.join('\n')) + at := Location{ + uri: uri + range: LSPRange{ + start: Position{ + line: pos.line + char: byte_to_encoded_col(lines[pos.line], pos.col, app.position_encoding) + } + } + } + if !app.is_live_binding(target, at, rc.new_name, code) { + continue + } + chain := comptime_branch_chain(code, pos.line, pos.col) + // Two names the check reads it reports itself, so they are left to it. + if anchor_chain.len == 0 && chain.len == 0 { + continue + } + if anchor_path == pos.path && anchor_chain == chain { + continue + } + return error(shadowing_refusal(rc, pos, anchor_chain, chain)) + } +} + +// is_live_binding reports whether `at`, an occurrence of the new name, declares +// a binding that is live where the renamed declaration is: an import of its +// file, a parameter or a local of the function around it, or a declaration its +// module makes at the top level of one of its files. +fn (mut app App) is_live_binding(target RenameTarget, at Location, name string, code []string) bool { + line := at.range.start.line + if line < 0 || line >= code.len { + return false + } + alias := import_alias_on(code[line]) + if alias != '' { + return name == alias + } + // A local or a parameter: what is live where it is, is in the function + // around it. + if app.indexed_declaration_kind(target.anchor) == 0 { + first, last := app.enclosing_fn_lines(target.anchor) or { return false } + if line >= first && line <= last && line_declares_local(code[line], name, line == first) { + return true + } + } + // A name of the module, which a local or a declaration there shadows. A + // file of another module is a program of its own, whatever its directory. + if at.uri != target.anchor.uri && !app.in_module_of(at, target.anchor) { + return false + } + return line_declares_top_level(code[line], name) +} + +// import_alias_on returns the alias that the `import` line declares, or '' when +// `line` imports nothing. +fn import_alias_on(line string) string { + mut rest := line.trim_left(' \t') + if !rest.starts_with('import ') { + return '' + } + rest = rest['import '.len..].trim_left(' \t') + if as_at := rest.index(' as ') { + return leading_identifier(rest[as_at + ' as '.len..]) + } + // `import my.mod` and `import my.mod { X }` are reached as `mod`. + return leading_identifier(rest.all_after_last('.')) +} + +// line_declares_local reports whether `line` of code declares `name` as a +// parameter, a local or a variable of a loop. +fn line_declares_local(line string, name string, is_signature bool) bool { + if name == '' { + return false + } + mut from := 0 + for { + at := line.index_after(name, from) or { return false } + from = at + name.len + if (at > 0 && is_ident_char(line[at - 1])) + || (from < line.len && is_ident_char(line[from])) { + continue + } + before := line[..at].trim_right(' \t') + after := line[from..].trim_left(' \t') + // A parameter of the signature, or of a closure written on this line. + if (before.ends_with('(') || before.ends_with(',')) + && (is_signature || line.contains('fn (')) { + return true + } + // `name :=`, `mut name :=`, `name, other :=`, `for name in`. + if after.starts_with(':=') || before.ends_with('for') || before.ends_with('mut') + || (after.starts_with(',') && after.contains(':=')) + || (before.ends_with(',') && after.starts_with('in ')) { + return true + } + } + return false +} + +// line_declares_top_level reports whether `line` of code declares `name` at the +// top level of a file. +fn line_declares_top_level(line string, name string) bool { + if name == '' { + return false + } + mut rest := line.trim_left(' \t') + if rest.starts_with('pub ') { + rest = rest['pub '.len..].trim_left(' \t') + } + for keyword in top_level_declaration_keywords { + if !rest.starts_with(keyword) { + continue + } + mut after := rest[keyword.len..] + if after.starts_with('(') { + // A method: its receiver comes before the name it declares. + close := after.index(')') or { return false } + after = after[close + 1..].trim_left(' \t') + } + return leading_identifier(after) == name + } + return false +} + +// The keywords a declaration at the top level of a file starts with, `pub ` +// already taken off. +const top_level_declaration_keywords = ['fn ', 'const ', 'struct ', 'enum ', 'interface ', 'type ', + '__global '] + +// leading_identifier returns the identifier `text` starts with, or '' when it +// starts with none. +fn leading_identifier(text string) string { + mut end := 0 + for end < text.len && is_ident_char(text[end]) { + end++ + } + return text[..end] +} + +// shadowing_refusal says why the rename cannot put the new name where it is +// already declared: the check of this build reads the branch it takes, and +// nothing of the other one. +fn shadowing_refusal(rc RenameCheck, pos NamePos, anchor_chain []int, chain []int) string { + mut where := '`${rc.new_name}` is already declared at ${name_pos_text(pos)}' + if anchor_chain.len == 0 { + where += ', in a `$if` branch that this build leaves out' + } else if chain.len == 0 { + where += ', and the declaration this rename writes is in a `$if` branch that this build leaves out' + } else { + where += ', in the `$if` branch that this build leaves out' + } + return '${where}: the compiler cannot see the clash, and the build of the platform that takes that branch would report a redefinition of `${rc.new_name}`' +} + // rename_clash_in returns how the rename `rc` would change the program in // `program_dir`, whose files at `paths` it edits or holds the new name, or // an empty string when it would not, or an error when validation fails. From be1336f1819f5dc56e8e0282dcea9fcb249286b3 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 13:47:13 +0330 Subject: [PATCH 27/83] feat: fill struct literal quickfix A struct literal that omits fields offers a quickfix adding them with their zero values. Existing fields are left untouched, fields are inserted in declaration order, and the action is skipped when the literal is complete or its type is unknown. --- assists.v | 659 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 659 insertions(+) create mode 100644 assists.v diff --git a/assists.v b/assists.v new file mode 100644 index 00000000..e3259043 --- /dev/null +++ b/assists.v @@ -0,0 +1,659 @@ +// Copyright (c) 2025 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// StructLiteralSpan is one struct literal in a buffer: the byte offsets of its +// braces and the type it constructs (`Pixel` in `p := Pixel{x: 1}`). +struct StructLiteralSpan { + open_offset int + close_offset int + type_name string +} + +// StructLiteralField is one field a literal does not set yet, together with the +// zero value to insert for it. +struct StructLiteralField { + name string + value string +} + +// StructDeclaredField is one field a struct declares itself. Promoted members of +// an embedded struct are deliberately absent: V silently ignores them in a +// literal, so filling them would produce code that does nothing. +struct StructDeclaredField { + name string + declared_type string +} + +// StructFieldZeroKind says how a field's declared type becomes a zero value. +enum StructFieldZeroKind { + // `value` is the zero value itself: `''`, `0`, `false`, `[]T{}` ... + literal + // `value` names a type, which the caller asks the index about: `Name{}` + // when the index sees it as a struct, skipped for anything else. + named + // No zero value can be written (optional, reference, enum, unknown type). + unknown +} + +struct StructFieldZero { + kind StructFieldZeroKind + value string +} + +// Field types whose zero value is the integer literal `0`. The float types are +// here too, because a float field accepts an integer literal (`ratio: 0` +// compiles), so one literal covers every numeric field. +const zero_integer_field_types = ['i8', 'i16', 'i32', 'i64', 'i128', 'int', 'isize', 'rune', 'u8', + 'u16', 'u32', 'u64', 'u128', 'usize', 'f32', 'f64']! + +// Words after which a `{` opens a block, a declaration body or a loop rather +// than a struct literal: in `if Cond {` and `fn make() Bar {` the brace belongs +// to the construct, not to `Cond` or `Bar`. +const v_literal_preceding_keywords = ['if', 'for', 'in', 'match', 'fn', 'struct', 'enum', 'interface', + 'type', 'const', 'import', 'module']! + +// build_fill_struct_literal_action returns the "Fill struct literal" quick fix +// for the literal the code-action range points at, or none when the range is +// not on a struct literal, when its type is unknown to the index, or when every +// field that could be given a zero value is already set. +fn (mut app App) build_fill_struct_literal_action(uri string, content string, sel_range LSPRange) ?CodeAction { + span := app.struct_literal_span(content, sel_range) or { return none } + declared := app.struct_declared_fields(uri, content, span.type_name) + if declared.len == 0 { + return none + } + masked := masked_v_code(content) + body := masked[span.open_offset + 1..span.close_offset] + present := struct_literal_field_names(body) + mut missing := []StructLiteralField{} + for field in declared { + if field.declared_type == '' || field.name in present { + continue + } + zero := struct_field_zero_value(field.declared_type) + match zero.kind { + .literal { + missing << StructLiteralField{ + name: field.name + value: zero.value + } + } + .named { + if app.type_declaration(uri, content, zero.value).kind == 'struct' { + missing << StructLiteralField{ + name: field.name + value: zero.value + '{}' + } + } + } + .unknown {} + } + } + if missing.len == 0 { + return none + } + field_indent := if body.trim_space() == '' { + app.first_field_indent(content, span.open_offset) + } else { + body_indent(body) + } + // The new fields follow the last one the literal already sets, so the edit + // starts at the end of that field rather than at the closing brace. + last_field_end := span.open_offset + 1 + body.trim_right(' \t\r\n').len + at := byte_offset_to_position(content, last_field_end, app.position_encoding) + return CodeAction{ + title: 'Fill struct literal' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: at + end: at + } + new_text: fill_struct_literal_insert_text(body, field_indent, missing) + }, + ] + } + } + } +} + +// struct_literal_span finds the struct literal a code-action range points at: +// the literal the selection covers, or the one the range's start is on — its +// type name, its opening brace, or anywhere inside its body. +fn (app &App) struct_literal_span(content string, sel_range LSPRange) ?StructLiteralSpan { + masked := masked_v_code(content) + starts := line_start_offsets(content) + start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if end > start { + if span := selected_literal_span(masked, start, end) { + return span + } + } + brace := literal_brace_at_or_after(masked, start) + if brace >= 0 { + if span := span_for_brace(masked, brace) { + return span + } + } + open := previous_unmatched_open_brace(masked, start) + if open < 0 { + return none + } + return span_for_brace(masked, open) +} + +// selected_literal_span returns the first struct literal inside [start, end), +// which is how a selection covering a whole statement still finds the literal +// in it. +fn selected_literal_span(masked string, start int, end int) ?StructLiteralSpan { + if end <= start || start < 0 || end > masked.len { + return none + } + for i in start .. end { + if masked[i] != `{` { + continue + } + span := span_for_brace(masked, i) or { continue } + if span.close_offset < end { + return span + } + } + return none +} + +// literal_brace_at_or_after returns the offset of the `{` opening the literal +// that `probe` is already on: the byte at `probe`, or the brace after the type +// name the probe sits on. It never crosses a line, so a cursor at the end of a +// line cannot claim a literal on the next one. +fn literal_brace_at_or_after(masked string, probe int) int { + if probe < 0 || probe >= masked.len { + return -1 + } + if masked[probe] == `{` { + return probe + } + mut i := probe + mut square_depth := 0 + for i < masked.len { + c := masked[i] + if is_ident_char(c) || c == `.` { + i++ + continue + } + if c == `[` { + square_depth++ + i++ + continue + } + if c == `]` && square_depth > 0 { + square_depth-- + i++ + continue + } + break + } + if square_depth != 0 { + return -1 + } + for i < masked.len && (masked[i] == ` ` || masked[i] == `\t`) { + i++ + } + if i < masked.len && masked[i] == `{` { + return i + } + return -1 +} + +// span_for_brace recognises the struct literal opening at `open`, or none when +// that brace belongs to a block, a function body or a composite literal. +fn span_for_brace(masked string, open int) ?StructLiteralSpan { + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + sel_start, type_name := literal_type_selector(masked, open) + // `[]Pixel{` and `map[K]V{` are composite literals, not struct literals: the + // selector of the first is `[]Pixel`, which no type name normalises to, and + // that of the second is `map[K]V`, which is not a type name either. + if sel_start < 0 || !is_type_name(normalize_receiver_type(type_name)) { + return none + } + if word_before_is_keyword(masked, sel_start) { + return none + } + // A brace whose text since the last `fn` holds no brace of its own is a + // function body: `fn make() Bar {`, however capital its return type is. + fn_index := last_fn_keyword_index(masked[..open]) + if fn_index >= 0 && !masked[fn_index..open].contains('{') + && !masked[fn_index..open].contains('}') { + return none + } + return StructLiteralSpan{ + open_offset: open + close_offset: close + type_name: type_name + } +} + +// literal_type_selector returns the start offset and the text of the type +// selector ending at `open` (`Pixel`, `clock.Pixel`, `Box[int]`), or (-1, '') +// when nothing type-shaped ends there. +fn literal_type_selector(masked string, open int) (int, string) { + mut end := open + for end > 0 && masked[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + mut start := end + mut square_depth := 0 + for start > 0 { + c := masked[start - 1] + if c == `]` { + square_depth++ + start-- + continue + } + if square_depth > 0 { + if c == `[` { + square_depth-- + } + start-- + continue + } + if is_ident_char(c) || c == `.` { + start-- + continue + } + break + } + if start == end || square_depth != 0 { + return -1, '' + } + return start, masked[start..end] +} + +// word_before_is_keyword reports whether a keyword from +// v_literal_preceding_keywords ends just before `sel_start`. +fn word_before_is_keyword(masked string, sel_start int) bool { + mut end := sel_start + for end > 0 && (masked[end - 1] == ` ` || masked[end - 1] == `\t`) { + end-- + } + mut start := end + for start > 0 && is_ident_char(masked[start - 1]) { + start-- + } + if start == end { + return false + } + return masked[start..end] in v_literal_preceding_keywords +} + +// struct_literal_field_names lists the fields a literal body sets, so they are +// not offered twice. +fn struct_literal_field_names(body string) []string { + mut names := []string{} + for part in struct_literal_field_entries(body) { + entry := part.trim_space() + colon := entry.index(':') or { continue } + if colon <= 0 { + continue + } + name := entry[..colon].trim_space() + if !is_valid_v_identifier_name(name) { + continue + } + names << name + } + return names +} + +// struct_literal_field_entries splits a literal body into its field entries. A +// V literal may separate them with commas, newlines, or both, and the brackets +// of a value — a nested literal, a map, a call — hold the separators they +// contain, so an inner `name:` is never mistaken for a field of this literal. +fn struct_literal_field_entries(body string) []string { + mut parts := []string{} + mut start := 0 + mut round_depth := 0 + mut square_depth := 0 + mut curly_depth := 0 + for idx, c in body { + match c { + `(` { round_depth++ } + `)` { round_depth-- } + `[` { square_depth++ } + `]` { square_depth-- } + `{` { curly_depth++ } + `}` { curly_depth-- } + `,`, `\n` { + if round_depth == 0 && square_depth == 0 && curly_depth == 0 { + parts << body[start..idx] + start = idx + 1 + } + } + else {} + } + } + parts << body[start..] + return parts +} + +// struct_declared_fields lists the fields `type_name` declares, in declaration +// order, resolved through the same scope rules the completion path uses: the +// requesting module for an unqualified name, the imported module for +// `module.Name`, and public fields only when the type comes from elsewhere. +fn (mut app App) struct_declared_fields(uri string, content string, type_name string) []StructDeclaredField { + mut dir, mut short_name, mut require_public, mut expected_module := app.receiver_type_scope(uri, + content, type_name) + if dir == '' || short_name == '' || expected_module == '' || !os.is_dir(dir) { + return []StructDeclaredField{} + } + app.ensure_dir_shallow_indexed(dir) + normalized_dir := normalized_index_path(dir) + for open_uri, _ in app.open_files { + if normalized_index_path(os.dir(uri_to_path(open_uri))) == normalized_dir { + app.reindex_uri(open_uri) + } + } + requesting_path := uri_to_path(uri) + active_test_name := if !require_public && requesting_path.ends_with('_test.v') { + os.file_name(requesting_path) + } else { + '' + } + active_names := app.active_indexed_source_file_names(dir, active_test_name) + mut fields := []StructDeclaredField{} + mut seen := map[string]bool{} + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir + || os.file_name(uri_to_path(indexed_uri)) !in active_names + || entry.module_name != expected_module { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + code_lines := source_code_lines(source) + for symbol in entry.doc_symbols { + if symbol.kind != sym_kind_struct || normalize_receiver_type(symbol.name) != short_name { + continue + } + if symbol.range.start.line >= 0 && symbol.range.start.line < entry.conditional_lines.len + && entry.conditional_lines[symbol.range.start.line] { + continue + } + if require_public && !source_declaration_is_public(indexed_uri, symbol, app) { + continue + } + for field in symbol.children { + if field.kind != sym_kind_field || field.range.start.line < 0 + || field.range.start.line >= code_lines.len { + continue + } + field_line := code_lines[field.range.start.line] + if embedded_struct_type(field_line) != '' { + continue + } + if require_public && !struct_field_is_public(code_lines, symbol, field) { + continue + } + if field.name in seen { + continue + } + fields << StructDeclaredField{ + name: field.name + declared_type: struct_field_source_type(field_line, field.name) + } + seen[field.name] = true + } + } + } + return fields +} + +// struct_field_zero_value decides the zero value for a field of `declared_type`, +// or `unknown` when none can be written safely. +fn struct_field_zero_value(declared_type string) StructFieldZero { + mut t := declared_type.trim_space() + if t == '' { + return StructFieldZero{ + kind: .unknown + } + } + // An optional field is left out of a literal on purpose, a result field + // needs an error value, and a reference field has no literal zero. + if t[0] in [`?`, `!`, `&`] { + return StructFieldZero{ + kind: .unknown + } + } + if t.starts_with('[]') || t.starts_with('map[') || t.starts_with('chan ') { + return StructFieldZero{ + kind: .literal + value: t + '{}' + } + } + if t == 'string' { + return StructFieldZero{ + kind: .literal + value: "''" + } + } + if t == 'bool' { + return StructFieldZero{ + kind: .literal + value: 'false' + } + } + if t in zero_integer_field_types { + return StructFieldZero{ + kind: .literal + value: '0' + } + } + if is_type_name(t) { + return StructFieldZero{ + kind: .named + value: t + } + } + return StructFieldZero{ + kind: .unknown + } +} + +// fill_struct_literal_insert_text builds the text inserted at the end of a +// literal's last field: `, field: zero, ...` for a one-line literal, one field +// per line at `field_indent` for a literal that already spans lines. A closing +// brace sharing a line with the last field moves down to a line of its own. +fn fill_struct_literal_insert_text(body string, field_indent string, missing []StructLiteralField) string { + mut entries := []string{cap: missing.len} + for field in missing { + entries << '${field.name}: ${field.value}' + } + trimmed := body.trim_space() + if !body.contains('\n') { + if trimmed == '' { + return entries.join(', ') + } + separator := if trimmed.ends_with(',') { ' ' } else { ', ' } + return separator + entries.join(', ') + } + prefix := if trimmed == '' || trimmed.ends_with(',') { + '\n' + field_indent + } else { + ',\n' + field_indent + } + mut text := prefix + entries.join(',\n' + field_indent) + last_line := body.all_after_last('\n') + if last_line.trim_space() != '' { + text += '\n' + line_indent(last_line) + } + return text +} + +// body_indent returns the indentation of the last non-blank body line, which is +// where a new field belongs. +fn body_indent(body string) string { + mut indent := '' + for line in body.split('\n') { + if line.trim_space() == '' { + continue + } + indent = line_indent(line) + } + return indent +} + +// first_field_indent returns the indentation a literal's first field takes +// before it has any: one level past the line its opening brace sits on, which is +// how the rest of the file indents the body of a literal there. +fn (app &App) first_field_indent(content string, open_offset int) string { + lines := content.split_into_lines() + line := byte_offset_to_position(content, open_offset, app.position_encoding).line + if line < 0 || line >= lines.len { + return '\t' + } + return line_indent(lines[line]) + '\t' +} + +// line_indent returns the leading whitespace of `line`. +fn line_indent(line string) string { + mut end := 0 + for end < line.len && line[end] in [` `, `\t`] { + end++ + } + return line[..end] +} + +// byte_offset_to_position maps a byte offset in `content` to the LSP position +// it denotes in `enc` units. +fn byte_offset_to_position(content string, offset int, enc PositionEncoding) Position { + starts := line_start_offsets(content) + mut line := starts.len - 1 + for line > 0 && starts[line] > offset { + line-- + } + line_text := line_text_without_terminator(content, starts, line) + mut in_line := offset - starts[line] + if in_line < 0 { + in_line = 0 + } + if in_line > line_text.len { + in_line = line_text.len + } + return Position{ + line: line + char: byte_to_encoded_col(line_text, in_line, enc) + } +} + +// masked_v_code returns `content` with the bodies of its comments and strings +// blanked to spaces, one output byte for every input byte, so an offset into the +// result addresses the same byte of the original text. Brace and selector scans +// therefore cannot be fooled by a `}` inside a string or a comment, while the +// offsets they report stay usable for the edit. The `${...}` of an interpolation +// is blanked with the string holding it: a literal written in one is not +// recognised, and the braces of the interpolation still cancel each other out. +fn masked_v_code(content string) string { + mut out := []u8{cap: content.len} + mut quote := u8(0) + mut raw_string := false + mut line_comment := false + mut block_comment := 0 + mut i := 0 + for i < content.len { + c := content[i] + if line_comment { + if c == `\n` { + line_comment = false + out << c + } else { + out << ` ` + } + i++ + continue + } + if block_comment > 0 { + // A block comment spans lines, and its `*/` is two bytes of it. + out << if c == `\n` { c } else { ` ` } + if c == `*` && i + 1 < content.len && content[i + 1] == `/` { + out << ` ` + block_comment-- + i += 2 + continue + } + i++ + continue + } + if quote != 0 { + if !raw_string && c == `\n` { + // A V string does not span lines, so an unterminated one ends here. + quote = 0 + out << c + i++ + continue + } + if !raw_string && c == `\\` && i + 1 < content.len { + out << ` ` + out << ` ` + i += 2 + continue + } + out << if c == quote { c } else { ` ` } + if c == quote { + quote = 0 + raw_string = false + } + i++ + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `/` { + line_comment = true + out << ` ` + out << ` ` + i += 2 + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `*` { + block_comment = 1 + out << ` ` + out << ` ` + i += 2 + continue + } + // `r'...'` and `r"..."` are raw: no escape is a literal in one. + if c == `r` && i + 1 < content.len && (content[i + 1] == `"` || content[i + 1] == `'`) + && (i == 0 || !is_ident_char(content[i - 1])) { + quote = content[i + 1] + raw_string = true + out << c + out << content[i + 1] + i += 2 + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + raw_string = c == 96 + out << c + i++ + continue + } + out << c + i++ + } + return out.bytestr() +} From 3399c32b6b388fab3f67daf6f4f392e9eef712a8 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 15:17:22 +0330 Subject: [PATCH 28/83] feat: advertise range formatting, inline values, linked editing; fix linked editing All three are implemented and tested: range formatting returns a hunk only inside the requested range, inline values type a := literal binding, and linked editing is now the occurrence set a rename would edit, across files, instead of same-line text matches that had nothing to do with the symbol. --- handlers.v | 77 ++++++++----- handlers_test.v | 299 ++++++++++++++++++++++++++++++++++++++++++++++-- main.v | 22 ++-- 3 files changed, 351 insertions(+), 47 deletions(-) diff --git a/handlers.v b/handlers.v index 471573d3..df32a9b4 100644 --- a/handlers.v +++ b/handlers.v @@ -9088,12 +9088,19 @@ fn (mut app App) handle_inline_value(request Request) Response { } // handle_linked_editing_range handles textDocument/linkedEditingRange. -// Returns ranges for all occurrences of the identifier under the cursor in the -// same line (identifier and its declaration) for linked editing. +// Linked editing rewrites every occurrence of the name under the cursor at +// once, so its set is the set a rename would edit, resolved across the whole +// program: an occurrence in this file may name a declaration in another, and a +// same-named local elsewhere in the program is not it. Asking the rename where +// it would edit cannot produce a set that disagrees with the rename, and every +// refusal of a rename — a name V owns, a name declared outside the project, an +// occurrence the compiler cannot place, a project only partly indexed — +// answers nothing here, which the client reads as "no linked editing" instead +// of an edit that leaves an occurrence behind. fn (mut app App) handle_linked_editing_range(request Request) Response { - params := json2.decode[TextDocumentPositionParams](request.params) or { + params := json2.decode[LinkedEditingRangeParams](request.params) or { $if debug { - log('Failed to decode TextDocumentPositionParams for linkedEditingRange: ${err}') + log('Failed to decode LinkedEditingRangeParams: ${err}') } return Response{ id: request.id @@ -9101,46 +9108,54 @@ fn (mut app App) handle_linked_editing_range(request Request) Response { } } uri := params.text_document.uri - content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { '' } } - lines := content.split_into_lines() - if params.position.line < 0 || params.position.line >= lines.len { + scope := app.index_scope_for_uri(uri) + app.ensure_index_scope(scope) + if !app.index_is_complete_for_scope(scope) { + app.send_log_message('linked editing refused: ${scope.dir} is only partly indexed', 2) return Response{ id: request.id result: 'null' } } - line_text := lines[params.position.line] - start, end := find_word_bounds_at_col(line_text, params.position.char, app.position_encoding) - if start < 0 || end <= start { + mut cache := app.rename_anchor_cache() + defer { + app.keep_rename_anchors(cache) + } + target := app.rename_target(uri, params.position.line, params.position.char, scope, mut cache) or { + return Response{ + id: request.id + result: 'null' + } + } + locations := app.rename_locations(target, scope, request.id, mut cache) or { + app.send_log_message('linked editing refused: ${err.msg()}', 2) return Response{ id: request.id result: 'null' } } - symbol := substr_by_char_bounds(line_text, start, end, app.position_encoding) - // Collect all occurrences of the symbol on this line. mut ranges := []LSPRange{} - mut col := 0 - for col < line_text.len { - idx := line_text[col..].index(symbol) or { break } - abs_idx := col + idx - before_ok := abs_idx == 0 || !is_ident_char(line_text[abs_idx - 1]) - after_ok := abs_idx + symbol.len >= line_text.len || !is_ident_char(line_text[abs_idx + symbol.len]) - if before_ok && after_ok { - sc := byte_to_encoded_col(line_text, abs_idx, app.position_encoding) - ec := byte_to_encoded_col(line_text, abs_idx + symbol.len, app.position_encoding) - ranges << LSPRange{ - start: Position{ - line: params.position.line - char: sc - } - end: Position{ - line: params.position.line - char: ec - } + for loc in locations { + // LinkedEditingRanges carries no document, so a range that belongs to + // another file would be applied to this one: keep this file's ranges, + // from the cross-file set, so that the answer is exact here. + if loc.uri != uri { + continue + } + // The answer for some occurrences carries no end column, so the length + // of the name stands in for it, as it does in a rename. + end_char := if loc.range.end.char > loc.range.start.char { + loc.range.end.char + } else { + loc.range.start.char + byte_to_encoded_col(target.symbol, target.symbol.len, app.position_encoding) + } + ranges << LSPRange{ + start: loc.range.start + end: Position{ + line: loc.range.start.line + char: end_char } } - col = abs_idx + 1 } if ranges.len == 0 { return Response{ diff --git a/handlers_test.v b/handlers_test.v index dfa01a1b..7d2e13ea 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -5,6 +5,7 @@ module main import os import json2 import time +import io fn must_mkdir_all(path string) { os.mkdir_all(path) or { @@ -10104,25 +10105,37 @@ fn test_inline_value_returns_empty_for_no_assignments() { // ── linked editing range ────────────────────────────────────────────────────── fn test_linked_editing_range_returns_ranges_for_identifier() { + // The occurrence set is the one a rename edits, and V1 alone does not tell + // where every name is declared. + if !v3_answers_line_info { + return + } mut app := create_test_app() defer { - cleanup_test_app(app) + cleanup_rename_app(mut app) } - uri := 'file:///tmp/linked.v' - // Line 2: `foo := foo + 1` — "foo" appears twice - content := 'module main\n\nfn main() {\n\tfoo := foo\n}\n' + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_feature') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + test_file := os.join_path(test_dir, 'main.v') + content := 'module main\n\nfn main() {\n\ttotal := 1\n\tprintln(total)\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) app.open_files[uri] = content + app.open_files_versions[uri] = 1 + app.workspace_roots = [test_dir] resp := app.handle_linked_editing_range(Request{ id: 840 method: 'textDocument/linkedEditingRange' - params: json2.encode(TextDocumentPositionParams{ + params: json2.encode(LinkedEditingRangeParams{ text_document: TextDocumentIdentifier{ uri: uri } position: Position{ line: 3 - char: 2 + char: 3 } }, escape_unicode: true @@ -10132,7 +10145,9 @@ fn test_linked_editing_range_returns_ranges_for_identifier() { assert resp.id == 840 assert resp.result is LinkedEditingRanges ler := resp.result as LinkedEditingRanges - assert ler.ranges.len >= 2 + assert ler.ranges.len == 2, 'the declaration and the use, got ${ler.ranges.len}' + assert ler.ranges.any(it.start.line == 3), 'the declaration is linked' + assert ler.ranges.any(it.start.line == 4), 'the use is linked' } fn test_linked_editing_range_returns_null_when_not_on_identifier() { @@ -16784,3 +16799,273 @@ fn test_filter_inlay_hints_by_toggles_keeps_unknown_kinds() { assert all_off.len == 1, 'disabled toggles filter only the known kinds, got ${all_off.len}' assert all_off[0].kind == 99, 'an unknown future kind passes through' } + +// --- advertised capabilities: what initialize says is implemented --- + +// The three capabilities this phase turned on are on the wire, and the stub +// that returns no edits stays off it. +fn test_integration_initialize_advertises_range_formatting_inline_values_linked_editing() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(app.temp_dir, 'phase5_capabilities.txt') + must_write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + initialize := app.captured_output.join('') + + assert initialize.contains('"documentRangeFormattingProvider":true'), 'range formatting is advertised' + assert initialize.contains('"inlineValueProvider":true'), 'inline values are advertised' + assert initialize.contains('"linkedEditingRangeProvider":true'), 'linked editing is advertised' + assert !initialize.contains('documentOnTypeFormattingProvider'), 'on-type formatting is still a stub and stays off the wire' +} + +// --- range formatting: nothing outside the requested range --- + +// v fmt formats whole files, so a document with a change inside and a change +// outside the requested range has one changed hunk that reaches past it. The +// handler then offers no edit at all rather than touch text the client did not +// ask about. +fn test_range_formatting_leaves_a_change_outside_the_requested_range_alone() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'range_format_outside_request') + must_mkdir_all(test_dir) + test_file := os.join_path(test_dir, 'main.v') + // Line 3 is inside the requested range, line 7 is not, and both need + // formatting, so the changed hunk spans both. + content := 'module main\n\nfn main() {\n\tx:=1\n}\n\nfn other() {\n\ty:=2\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) + app.open_files[uri] = content + + response := app.handle_range_formatting(Request{ + id: 911 + method: 'textDocument/rangeFormatting' + params: json2.encode(DocumentRangeFormattingParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 3 + } + end: Position{ + line: 3 + char: 4 + } + } + options: FormattingOptions{ + tab_size: 4 + } + }, + escape_unicode: true + ) + }) + + assert response.id == 911 + assert response.result is []TextEdit + edits := response.result as []TextEdit + assert edits.len == 0, 'a changed hunk reaching past the requested range must not be edited, got ${edits.len}' +} + +// --- inline values: every literal in the range, and nothing else --- + +fn test_inline_value_returns_a_hint_for_each_literal_in_the_range() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///tmp/inlineval_multi.v' + content := 'module main\n\nfn main() {\n\ta := 1\n\tb := "two"\n\tc := true\n\td := 3.5\n}\n' + app.open_files[uri] = content + + resp := app.handle_inline_value(Request{ + id: 832 + method: 'textDocument/inlineValue' + params: json2.encode(InlineValueParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 7 + char: 0 + } + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 832 + assert resp.result is []InlineValueText + values := resp.result as []InlineValueText + assert values.len == 4, 'one hint per literal, got ${values.len}' + for hint in [': int', ': string', ': bool', ': f64'] { + assert values.any(it.text == hint), 'the hint `${hint}` is missing from ${values.map(it.text)}' + } + hint := values[0] + assert hint.range.start.line == 3 && hint.range.start.char == 1, 'the hint sits on the name it belongs to: ${hint.range.start}' + assert hint.text.starts_with(': '), 'the hint is appended to the name: ${hint.text}' +} + +// An expression the compiler has to evaluate is not a value the client can be +// shown, so a call, an arithmetic expression and a composite literal get no hint. +fn test_inline_value_has_no_hint_for_a_non_literal_assignment() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := 'file:///tmp/inlineval_notliteral.v' + content := 'module main\n\nfn one() int {\n\treturn 1\n}\n\nfn main() {\n\tx := one()\n\ty := 1 + 2\n\tz := []int{}\n}\n' + app.open_files[uri] = content + + resp := app.handle_inline_value(Request{ + id: 833 + method: 'textDocument/inlineValue' + params: json2.encode(InlineValueParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 833 + assert resp.result is []InlineValueText + values := resp.result as []InlineValueText + assert values.len == 0, 'a call, a sum and a composite literal are not values: ${values.map(it.text)}' +} + +// --- linked editing: the cross-file set, this document's ranges --- + +// The name under the cursor is declared in another file and used twice here, +// and a local of the same name in a third function is not it. The answer is +// the two uses in this file and no range of the local. +fn test_linked_editing_range_of_a_use_follows_the_declaration_into_another_file() { + // V1 alone does not tell where every name is declared. + if !v3_answers_line_info { + return + } + mut app := create_test_app() + defer { + cleanup_rename_app(mut app) + } + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_cross_file') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + main_content := 'module main\n\nfn main() {\n\tprintln(shared_value())\n\tprintln(shared_value())\n}\n\nfn decoy() int {\n\tshared_value := 5\n\treturn shared_value\n}\n' + other_content := 'module main\n\nfn shared_value() int {\n\treturn 1\n}\n' + main_file := os.join_path(test_dir, 'main.v') + other_file := os.join_path(test_dir, 'other.v') + must_write_file(main_file, main_content) + must_write_file(other_file, other_content) + main_uri := path_to_uri(main_file) + other_uri := path_to_uri(other_file) + app.open_files[main_uri] = main_content + app.open_files_versions[main_uri] = 1 + app.open_files[other_uri] = other_content + app.open_files_versions[other_uri] = 1 + app.workspace_roots = [test_dir] + + resp := app.handle_linked_editing_range(Request{ + id: 842 + method: 'textDocument/linkedEditingRange' + params: json2.encode(LinkedEditingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: main_uri + } + position: Position{ + line: 3 + char: 12 + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 842 + assert resp.result is LinkedEditingRanges + ler := resp.result as LinkedEditingRanges + assert ler.ranges.len == 2, 'the two uses, got ${ler.ranges.len}' + main_lines := main_content.split_into_lines() + for r in ler.ranges { + assert r.start.line in [3, 4], 'the local of another function is not linked: line ${r.start.line}' + assert main_lines[r.start.line][r.start.char..r.end.char] == 'shared_value', 'the range is the name itself' + } +} + +// Two functions of one file each declare a local of the same name. Linked +// editing from one of them returns that one and the uses of that one. +fn test_linked_editing_range_keeps_a_local_apart_from_a_namesake_in_another_function() { + // V1 alone does not tell where every name is declared. + if !v3_answers_line_info { + return + } + mut app := create_test_app() + defer { + cleanup_rename_app(mut app) + } + app.v3_line_info_enabled = v3_answers_line_info + test_dir := os.join_path(app.temp_dir, 'linked_editing_namesake') + must_mkdir_all(test_dir) + must_write_file(os.join_path(test_dir, 'v.mod'), 'Module {}\n') + test_file := os.join_path(test_dir, 'main.v') + content := 'module main\n\nfn first() int {\n\tvalue := 1\n\treturn value\n}\n\nfn second() int {\n\tvalue := 2\n\treturn value\n}\n' + must_write_file(test_file, content) + uri := path_to_uri(test_file) + app.open_files[uri] = content + app.open_files_versions[uri] = 1 + app.workspace_roots = [test_dir] + + resp := app.handle_linked_editing_range(Request{ + id: 843 + method: 'textDocument/linkedEditingRange' + params: json2.encode(LinkedEditingRangeParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: 3 + char: 3 + } + }, + escape_unicode: true + ) + }) + + assert resp.id == 843 + assert resp.result is LinkedEditingRanges + ler := resp.result as LinkedEditingRanges + assert ler.ranges.len == 2, 'the declaration and the use of this local, got ${ler.ranges.len}' + assert ler.ranges.all(it.start.line in [3, 4]), 'the namesake in `second` is not linked: ${ler.ranges.map(it.start.line)}' +} diff --git a/main.v b/main.v index f8dd17d9..fe8cb110 100644 --- a/main.v +++ b/main.v @@ -930,10 +930,9 @@ fn (mut app App) handle_requests[T](mut reader T) { capabilities: Capability{ // NOTE: Placeholder/stub capabilities are intentionally NOT // advertised (P1-07 / Stage 0): on-type formatting (always - // empty), inline values (wrong abstraction), linked editing - // (wrong abstraction), file-operation hooks (no-ops), and - // willSave (never dispatched). Advertising only working - // features gives a better editor experience than broken UI. + // empty), file-operation hooks (no-ops), and willSave + // (never dispatched). Advertising only working features + // gives a better editor experience than broken UI. text_document_sync: TextDocumentSyncOptions{ open_close: true change: 2 // Incremental @@ -967,6 +966,11 @@ fn (mut app App) handle_requests[T](mut reader T) { commands: ['vls.runFile', 'vls.runTests'] } code_lens_provider: CodeLensOptions{} + // Inline values are the type the literal on the right of a + // `:=` gives the variable; linked editing is the occurrence + // set a rename would edit. Neither is a placeholder. + inline_value_provider: true + linked_editing_range_provider: true semantic_tokens_provider: SemanticTokensOptions{ legend: SemanticTokensLegend{ token_types: semantic_token_types() @@ -979,11 +983,11 @@ fn (mut app App) handle_requests[T](mut reader T) { call_hierarchy_provider: true document_highlight_provider: true selection_range_provider: true - // Range formatting is NOT advertised: v fmt only formats whole - // files, so a correct range implementation needs a - // character-accurate, EOL-preserving diff restricted to the - // requested range, which is not yet implemented (P0-08). - document_range_formatting_provider: false + // Range formatting is advertised: `v fmt` only formats whole + // files, so the handler formats the document and returns the + // changed hunk only when it lies entirely inside the requested + // range, and no edit at all otherwise. + document_range_formatting_provider: true position_encoding: position_encoding_string(app.position_encoding) workspace: WorkspaceCapability{ workspace_folders: WorkspaceFoldersServerCapability{ From 6f4dbb2820cb9aebe5159a984fe42672b03606e8 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 15:20:09 +0330 Subject: [PATCH 29/83] docs: record Phase 3 and Phase 5 results --- ROADMAP.md | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 565f3c2c..89c42b5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,20 +106,24 @@ needs the V1 compatibility compiler, which this host cannot build default on, runtime toggle, no restart. - Richer hover (const values, methods, doc links). -## Phase 3 — full refactors - -- Safe rename (shadowing, interface satisfaction). -- Extract function/variable, inline, fillStruct/fillSwitch, - stub-missing-members. Each with before/after doc-tests. - -## Phase 4 — build / config UX - -- Layered config: settings > `vls.json` (+ schema) > - per-project overrides > env. `defines: [...]` - replaces `VFLAGS`-in-shell folklore. -- Fast `v check`-only mode vs full build (zls check-step). - -## Phase 5 — finish or drop stubs - -Implement or unadvertise: range-formatting, on-type -formatting, file-op hooks, inlineValue, linkedEditing. +## Phase 3 — refactors (partial) + +- Safe rename: DONE — refuses names already live in the + enclosing scope (locals, parameters, import aliases, outer + scopes a nested reference would capture) and names that are + members of an interface, naming the conflict. +- Fill struct literal quickfix: DONE (assists.v) — missing + fields added with zero values, existing ones untouched; + skipped for unknown types and complete literals. +- Extract function/variable, inline, stub-missing-members: + NOT started. + +## Phase 5 — finish or drop stubs: DONE + +Advertised now they are implemented and tested: range formatting +(emits a hunk only inside the requested range), inline values +(type of a `:=` literal). Linked editing was same-line text +matching — replaced with the occurrence set a rename would edit, +across files, and advertised. On-type formatting stays +unadvertised by design (would run `v fmt` per keystroke); +file-operation hooks and willSave remain unadvertised no-ops. From d94fbc97a49bfbf11979b63ae47d2720766773d7 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 19:52:31 +0330 Subject: [PATCH 30/83] perf: reserve the symbol index for partial walks too A walk that hits the 20k file or entry limit used to insert what it found one by one until the global cap, keeping the repeated-growth and allocation-failure behavior. Both branches now share one bounded target. --- index.v | 29 ++++++++++++++++++++--------- index_test.v | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/index.v b/index.v index 25b9765c..cbf5e735 100644 --- a/index.v +++ b/index.v @@ -509,6 +509,17 @@ fn collect_v_files(root string, mut acc []string) bool { return collect_v_files_bounded(root, index_max_files, mut acc) } +// index_reserve_target is how many index entries a walk should pre-size for, +// bounded by the global file cap. A partial walk reserves what it found, so +// growth toward that cap stays bounded in both cases; 0 means reserve nothing. +fn index_reserve_target(indexed int, found int) int { + want := indexed + found + if want <= 0 || want > index_max_files { + return 0 + } + return want +} + // collect_v_files_bounded stops at the caller's file limit, and at four times // that many directory entries so trees without V files are bounded too. fn collect_v_files_bounded(root string, max_files int, mut acc []string) bool { @@ -637,15 +648,15 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { walk_complete := collect_v_files(dir, mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true - } else { - // Pre-size once: inserting thousands of entries one by one would - // otherwise double the backing store repeatedly, and a late - // doubling can fail under a fragmented heap (GC_alloc_large abort - // on big trees). Reserve is a no-op for non-empty maps. - want := app.symbol_index.len + files.len - if want > 0 && want <= index_max_files { - app.symbol_index.reserve(u32(want)) - } + } + // Pre-size once: inserting thousands of entries one by one would + // otherwise double the backing store repeatedly, and a late doubling + // can fail under a fragmented heap (GC_alloc_large abort on big trees). + // A partial walk reserves what it found, so growth toward the global + // cap stays bounded in both cases. + target := index_reserve_target(app.symbol_index.len, files.len) + if target > 0 { + app.symbol_index.reserve(u32(target)) } mut present := map[string]bool{} for f in files { diff --git a/index_test.v b/index_test.v index beadb1cf..4144e85f 100644 --- a/index_test.v +++ b/index_test.v @@ -19,6 +19,41 @@ fn index_test_tmpdir(tag string) string { return dir } +fn test_index_reserve_target_covers_partial_walks() { + // A partial walk reserves what it found too, so growth toward the global + // cap is bounded either way: both branches share this target. + assert index_reserve_target(0, 0) == 0 + assert index_reserve_target(0, 50) == 50 + assert index_reserve_target(500, 50) == 550 + assert index_reserve_target(0, index_max_files) == index_max_files + // Already at or above the cap: reserve nothing. + assert index_reserve_target(index_max_files, 1) == 0 + assert index_reserve_target(index_max_files + 100, 1) == 0 +} + +fn test_partial_walk_still_reserves_what_it_found() { + // The reservation is not gated on the walk being complete: an incomplete + // walk indexes what it found, and that count still sizes the map. An + // incomplete scope marked by an earlier walk is cleared when a later one + // completes, as before. + root := index_test_tmpdir('partial_reserve') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 20 { + os.write_file(os.join_path(root, 'p${i}.v'), 'module p${i}\n\nfn p${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + mut app := index_test_app() + app.index_incomplete_scopes['recursive:${root}'] = true + assert index_reserve_target(0, 20) == 20 + app.ensure_dirs_indexed([root]) + assert app.symbol_index.len == 20, 'got ${app.symbol_index.len}' + assert 'recursive:${root}' !in app.index_incomplete_scopes +} + fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { // ensure_dirs_indexed reserves the map before a bulk walk so inserts do // not double the backing store repeatedly under a fragmented heap. From ab2aeeb9f5ed256ed97328794cd6b4b863720d7e Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 19:58:43 +0330 Subject: [PATCH 31/83] feat: layered configuration with defines and per-project vls.json Editor settings win over a vls.json in the nearest v.mod folder, which wins over VLS_DEFINES in the environment. Defines reach the checks only, so code behind \ flag is finally checked without putting -d flag into VFLAGS before starting the editor. The diagnostics fingerprint carries them, so a cached answer is never served across different defines. --- README.md | 34 ++-- handlers.v | 16 ++ index.v | 28 +++- index_test.v | 37 +++++ interop.v | 19 ++- vls_config.v | 414 ++++++++++++++++++++++++++++++++++++++++++++++ vls_config_test.v | 310 ++++++++++++++++++++++++++++++++++ 7 files changed, 834 insertions(+), 24 deletions(-) create mode 100644 vls_config.v create mode 100644 vls_config_test.v diff --git a/README.md b/README.md index bb3b5334..ea8b59f4 100644 --- a/README.md +++ b/README.md @@ -76,23 +76,33 @@ also visualize line coverage; set `v.vls.coverage.enabled` to `false` to turn th ### Code behind a compile-time flag V compiles the code inside `$if flag ? { ... }` only when the program is built with `-d flag`. -VLS runs the compiler without defines, so that code is not checked: it gets no errors, and hover -and go-to-definition find little or nothing in it. V adds the options in the `VFLAGS` environment -variable to every command, so set it where VLS runs to work on such a block. For example, close -the editor and start it again from a shell: +VLS runs the compiler without defines, so that code used to get no errors, and hover and +go-to-definition found little or nothing in it. It now checks that code when the flag is given. -```sh -VFLAGS='-d flag' code . +Set `vls.defines` in the editor's settings for VLS, naming the flag the code uses: + +```json +"vls.defines": ["-d", "flag"] +``` + +The joined spelling, `["-dflag"]`, is accepted too. To keep the flag with the project rather than +in the editor, put a `vls.json` in the folder that holds the nearest `v.mod`: + +```json +{ "defines": ["-d", "flag"] } ``` -In Sublime Text, add `"VFLAGS": "-d flag"` to the `env` of its server configuration. The code -inside `$if !flag ? { ... }` is then the one left unchecked. +A `vls.json` may also set `inlayHints` and `diagnostics`, the two feature switches. The settings +the editor sends win over the project's file, and the file over `VLS_DEFINES` in the environment, +which also works for an editor with no settings of its own. The defines reach the checks only: +`v fmt`, hover, go-to-definition and completion run without them. The code inside +`$if !flag ? { ... }` is then the one left unchecked. ### Features #### Instant errors -image +![image](https://github.com/user-attachments/assets/a842e103-b3c2-427f-956f-fffff07970dc) #### Go to definition @@ -100,15 +110,15 @@ https://github.com/user-attachments/assets/fb4ee6ff-4765-46b7-a21e-267691253d8e #### Autocomplete for module functions -image +![image](https://github.com/user-attachments/assets/0d4e1849-2e6c-47f8-9a45-322fe25d9bef) #### Information about function parameters -image +![image](https://github.com/user-attachments/assets/46cc391b-fcdc-4083-ab62-97edd815ddd9) #### Autocomplete for struct fields and methods -image +![image](https://github.com/user-attachments/assets/478bfd20-201a-476f-88cd-583fad52d6cc) ### Zed diff --git a/handlers.v b/handlers.v index df32a9b4..0e423541 100644 --- a/handlers.v +++ b/handlers.v @@ -7947,6 +7947,17 @@ fn (mut app App) handle_code_action(request Request) Response { } } + // 4. Extract variable — lift a pure expression into a variable of its own. + // 5. Inline variable — fold a local that is written once back into its uses. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + if action := app.build_extract_variable_action(uri, content, params.range) { + actions << action + } + if action := app.build_inline_variable_action(uri, content, params.range) { + actions << action + } + } + return Response{ id: request.id result: actions @@ -8496,6 +8507,11 @@ fn (mut app App) on_did_change_configuration(request Request) { log('VLS: diagnostics_enabled=${enabled}') } } + // Layered configuration: the editor's own settings are the top tier, a + // project's `vls.json` the next, and VLS_DEFINES the last. Storing the + // editor tier here also applies the merged result to the switches above, + // and records the defines the checks of a project run with (vls_config.v). + app.apply_editor_configuration(request.params) } struct ResolvedWorkspaceSettings { diff --git a/index.v b/index.v index fb8f61cd..5f53720d 100644 --- a/index.v +++ b/index.v @@ -511,6 +511,18 @@ fn collect_v_files(root string, mut acc []string) bool { return collect_v_files_bounded(root, index_max_files, mut acc) } +// index_reserve_target is how many index entries a walk should pre-size for, +// bounded by the global file cap. A partial walk reserves what it found, so +// the growth toward that cap stays bounded in both cases; 0 means reserve +// nothing. +fn index_reserve_target(indexed int, found int) int { + want := indexed + found + if want <= 0 || want > index_max_files { + return 0 + } + return want +} + // collect_v_files_bounded stops at the caller's file limit, and at four times // that many directory entries so trees without V files are bounded too. fn collect_v_files_bounded(root string, max_files int, mut acc []string) bool { @@ -639,15 +651,13 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { walk_complete := collect_v_files(dir, mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true - } else { - // Pre-size once: inserting thousands of entries one by one would - // otherwise double the backing store repeatedly, and a late - // doubling can fail under a fragmented heap (GC_alloc_large abort - // on big trees). Reserve is a no-op for non-empty maps. - want := app.symbol_index.len + files.len - if want > 0 && want <= index_max_files { - app.symbol_index.reserve(u32(want)) - } + } + // Pre-size once: inserting thousands of entries one by one would + // otherwise double the backing store repeatedly, and a late doubling + // can fail under a fragmented heap (GC_alloc_large abort on big trees). + target := index_reserve_target(app.symbol_index.len, files.len) + if target > 0 { + app.symbol_index.reserve(u32(target)) } mut present := map[string]bool{} for f in files { diff --git a/index_test.v b/index_test.v index 863bf4e9..c878b0d7 100644 --- a/index_test.v +++ b/index_test.v @@ -19,6 +19,19 @@ fn index_test_tmpdir(tag string) string { return dir } +fn test_index_reserve_target_covers_partial_walks() { + // A partial walk reserves what it found too, so growth toward the global + // cap stays bounded either way: the incomplete branch and the complete + // one share this target. + assert index_reserve_target(0, 0) == 0 + assert index_reserve_target(0, 50) == 50 + assert index_reserve_target(500, 50) == 550 + assert index_reserve_target(0, index_max_files) == index_max_files + // Already at or above the cap: reserve nothing. + assert index_reserve_target(index_max_files, 1) == 0 + assert index_reserve_target(index_max_files + 100, 1) == 0 +} + fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { // ensure_dirs_indexed reserves the map before a bulk walk so inserts do // not double the backing store repeatedly under a fragmented heap. @@ -41,6 +54,30 @@ fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { assert 'file:///old.v' in app.symbol_index } +fn test_partial_walk_still_reserves_what_it_found() { + // The reservation is not gated on the walk being complete: an incomplete + // walk (a 20k-file or directory-entry limit hit) indexes what it found, + // and that count still sizes the map. An incomplete scope marked by an + // earlier walk is cleared when a later one completes, as before. + root := index_test_tmpdir('partial_reserve') + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 20 { + os.write_file(os.join_path(root, 'p${i}.v'), 'module p${i}\n\nfn p${i}() {}\n') or { + assert false, 'write fixture failed: ${err}' + return + } + } + mut app := index_test_app() + app.index_incomplete_scopes['recursive:${root}'] = true + before := app.symbol_index.len + assert index_reserve_target(before, 20) == before + 20 + app.ensure_dirs_indexed([root]) + assert app.symbol_index.len == 20, 'got ${app.symbol_index.len}' + assert 'recursive:${root}' !in app.index_incomplete_scopes +} + fn test_index_workspace_symbols_from_open_buffers() { mut app := index_test_app() app.open_files['file:///tmp/a.v'] = 'module main\n\nfn alpha() {}\n\nstruct Beta {\n\tx int\n}\n' diff --git a/interop.v b/interop.v index 131f1abd..6a999d73 100644 --- a/interop.v +++ b/interop.v @@ -1402,7 +1402,16 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // within the session and across restarts (see diag_cache.v). program_dir := app.program_root(real_path) overlay_root := program_overlay_root(real_path, program_dir) - fingerprint := compiler_fingerprint() + '\n' + app.program_content_fingerprint(overlay_root) + // The defines a check runs with are resolved here, before the fingerprint + // they salt and the arguments they are appended to (see vls_config.v). + defines := app.check_defines(real_path) + mut fingerprint := compiler_fingerprint() + '\n' + app.program_content_fingerprint(overlay_root) + if defines.len > 0 { + // Other defines answer other errors for the same sources, so they are + // part of the fingerprint. An empty set adds nothing, which leaves + // every result cached before defines existed exactly as it was. + fingerprint += '\ndefines:${defines.join(' ')}' + } fp_ms := time.now().unix_milli() - fp_stage_start_ms mut cache_hit := '' if cached := app.diag_cache[path] { @@ -1522,10 +1531,11 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { module_name != '' && module_name != 'main' } if use_multifile { - cmd_args = build_v_check_args_multifile(is_library_module) + cmd_args = check_args_with_defines(build_v_check_args_multifile(is_library_module), defines) log('MULTIFILE CMD - compile_target=${compile_target}): v ${cmd_args.join(' ')}') } else { - cmd_args = build_v_check_args_single(file_to_check, is_library_module) + cmd_args = check_args_with_defines(build_v_check_args_single(file_to_check, is_library_module), + defines) log('SINGLEFILE CMD: v ${cmd_args.join(' ')}') } @@ -2261,6 +2271,9 @@ fn (mut app App) on_did_change_watched_files(request Request) { app.importable_modules_cache = map[string]ImportableModulesCache{} for change in params.changes { app.forget_module_folder(uri_to_path(change.uri)) + // A project's `vls.json` holds the defines its checks run with, so a + // change of it is a change of the configuration (see vls_config.v). + app.forget_config_file(uri_to_path(change.uri)) } open_uris_by_path := app.open_index_uris_by_path() for change in params.changes { diff --git a/vls_config.v b/vls_config.v new file mode 100644 index 00000000..a8bab53a --- /dev/null +++ b/vls_config.v @@ -0,0 +1,414 @@ +// Layered configuration: what a check runs with, and the switches a project may +// set for itself. V only compiles the code inside `$if flag ?` when it is given +// `-d flag`, and VLS used to run the compiler without any define, so that code +// was never checked. The defines now come from three tiers, the first that +// carries a value winning: the editor's own settings, the project's `vls.json`, +// and the VLS_DEFINES environment variable. +// +// The state lives here rather than on App: App is declared in main.v, and a +// configuration cache is not a reason to change it. +module main + +import json2 +import os +import sync +import time + +// vls_config_file_name is the per-project override file. Its folder is the +// project root: the nearest directory above the file that holds a `v.mod`. +const vls_config_file_name = 'vls.json' + +// vls_config_ttl_ms is how long a project's configuration is reused before it is +// read again. A watched change drops it at once; the bound is what keeps a client +// that watches only `.v` files from reading a stale `vls.json` for ever. +const vls_config_ttl_ms = 10_000 + +// vls_defines_env_var is the last tier of the defines. VFLAGS also reaches every +// compiler invocation, and the compiler merges the two, but VLS_DEFINES is the +// variable this file's precedence can reason about. +const vls_defines_env_var = 'VLS_DEFINES' + +// EditorSettings is the top tier: what the editor last sent through +// workspace/didChangeConfiguration. The payload carries the whole settings state, +// so a key it does not send is unset rather than unknown: an empty `defines` +// leaves the decision to the tier below it. +struct EditorSettings { +mut: + defines []string + inlay_hints ?bool + diagnostics ?bool +} + +// EditorDefinesParams is the shape an editor's configuration payload has: the +// defines may sit under `vls.defines` or directly under `defines`, so a client +// that sections its settings and one that does not both work. +struct EditorDefinesParams { + settings EditorDefinesSettings +} + +struct EditorDefinesSettings { + vls EditorDefines + defines ?[]string +} + +struct EditorDefines { + defines ?[]string +} + +// VlsProjectFile is the schema of a project's `vls.json`. Only the keys VLS +// consumes are declared, so an unknown key is dropped instead of rejecting the +// whole file. +struct VlsProjectFile { +mut: + defines []string + inlay_hints ?bool @[json: 'inlayHints'] + diagnostics ?bool +} + +// VlsProjectConfig is the merged result for one project root: the argument pairs +// a check runs with, and the switches the feature handlers apply. +struct VlsProjectConfig { +mut: + defines []string + inlay_hints ?bool + diagnostics ?bool +} + +// VlsConfigCacheEntry is one project root's configuration and when it was read. +struct VlsConfigCacheEntry { + config VlsProjectConfig + at_ms i64 + signature string +} + +// VlsConfigStore is the process-wide state of the layered configuration: the +// editor tier, and the merged configuration of every project root read so far. +// Both are read from the request thread and from the workers that run checks, so +// every access is behind the lock. +struct VlsConfigStore { +mut: + editor EditorSettings + roots map[string]VlsConfigCacheEntry + mu &sync.RwMutex = sync.new_rwmutex() +} + +// vls_config_store returns the store, created on first use. A function-local +// static holds it because the configuration has no field in App to live in; the +// same pattern keeps interop.v's temp path counter out of module scope. +fn vls_config_store() &VlsConfigStore { + unsafe { + mut static store := &VlsConfigStore{} + if store == unsafe { nil } { + store = &VlsConfigStore{} + } + return store + } +} + +// apply_editor_configuration folds a workspace/didChangeConfiguration payload +// into the configuration. The editor's settings are the top tier, so they are +// stored first; the merged result is then applied for every workspace root, and +// a changed defines value drops the cached diagnostics, which were computed with +// the old ones. +fn (mut app App) apply_editor_configuration(params_json string) { + mut store := vls_config_store() + previous := store.editor_settings().defines + mut editor := EditorSettings{ + defines: resolve_editor_defines(params_json) + } + resolved := resolve_workspace_settings(params_json) + if enabled := resolved.inlay_hints { + editor.inlay_hints = enabled + } + if enabled := resolved.diagnostics { + editor.diagnostics = enabled + } + // The editor tier is part of every merge, so the results cached for the + // project roots are the ones computed without it. + store.set_editor_settings(editor) + current := editor.defines + if current != previous { + // A check's fingerprint covers the sources and the compiler, so every + // result cached for any file is the one the old defines produce. + app.diag_cache = map[string]DiagCacheEntry{} + log('VLS: defines=${current}') + } + for root in app.workspace_roots { + app.apply_project_settings(app.project_config_for_root(root)) + } +} + +// project_config_for_path merges the tiers for the project root that owns `path`. +fn (mut app App) project_config_for_path(path string) VlsProjectConfig { + return app.project_config_for_root(project_root_for_path(path)) +} + +// project_config_for_root merges the tiers for one project root, reusing the +// cached result for `vls_config_ttl_ms`. When a re-read finds a different +// configuration, the diagnostics computed with the old one are dropped. +fn (mut app App) project_config_for_root(root string) VlsProjectConfig { + store := vls_config_store() + if cached := store.cached_config(root, time.now().unix_milli()) { + return cached + } + config := merge_config_tiers(store.editor_settings(), read_project_file(root), + env_defines()) + previous := store.store_config(root, config) + if previous != '' && previous != config_signature(config) { + app.forget_project_diagnostics(root) + } + return config +} + +// check_defines returns the defines the check of `path` runs with, and applies +// the switches of the same configuration, so a project's `vls.json` is in step +// even when the client's settings were sent before it was ever read. +fn (mut app App) check_defines(path string) []string { + config := app.project_config_for_path(path) + app.apply_project_settings(config) + return config.defines.clone() +} + +// apply_project_settings writes the switches of `config` into the App fields the +// feature handlers read. A tier that says nothing leaves the field as it is. +fn (mut app App) apply_project_settings(config VlsProjectConfig) { + if enabled := config.inlay_hints { + app.inlay_hints_enabled = enabled + } + if enabled := config.diagnostics { + app.diagnostics_enabled = enabled + if !enabled { + app.cancel_all_scheduled_diagnostics() + } + } +} + +// forget_config_file drops the configuration cached for the project root that +// owns `path` when `path` is that project's `vls.json`, together with the +// diagnostics computed with it, so the next check reads the file again. +fn (mut app App) forget_config_file(path string) { + if os.file_name(path) != vls_config_file_name { + return + } + root := project_root_for_path(path) + mut store := vls_config_store() + store.drop_config(root) + app.forget_project_diagnostics(root) +} + +// forget_project_diagnostics drops the diagnostics cached for the files under +// `root`: they were computed with another configuration. +fn (mut app App) forget_project_diagnostics(root string) { + if root == '' { + return + } + dir := normalize_overlay_path(root) + mut stale := []string{} + for uri, _ in app.diag_cache { + if path_is_within(normalize_overlay_path(uri_to_path(uri)), dir) { + stale << uri + } + } + for uri in stale { + app.diag_cache.delete(uri) + } +} + +// check_args_with_defines appends the defines of a check to its argument vector. +// The compiler takes options anywhere in the vector, so appending them after the +// path is enough. `v fmt` and the `-line-info` questions never see them: a +// formatter must not depend on the flags, and a question answered ahead of a +// check must not change with them. +fn check_args_with_defines(args []string, defines []string) []string { + if defines.len == 0 { + return args + } + mut out := args.clone() + out << defines + return out +} + +// cached_config returns the configuration of `root` when it was read less than +// `vls_config_ttl_ms` ago. +fn (store &VlsConfigStore) cached_config(root string, now i64) ?VlsProjectConfig { + store.mu.rlock() + defer { + store.mu.runlock() + } + if cached := store.roots[config_root_key(root)] { + if now - cached.at_ms < vls_config_ttl_ms { + return cached.config + } + } + return none +} + +// store_config records the configuration of `root` and returns the signature of +// the entry it replaces, or '' when the root had none. +fn (mut store VlsConfigStore) store_config(root string, config VlsProjectConfig) string { + store.mu.lock() + defer { + store.mu.unlock() + } + mut previous := '' + if outdated := store.roots[config_root_key(root)] { + previous = outdated.signature + } + store.roots[config_root_key(root)] = VlsConfigCacheEntry{ + config: config + at_ms: time.now().unix_milli() + signature: config_signature(config) + } + return previous +} + +// drop_config forgets the configuration of `root`. +fn (mut store VlsConfigStore) drop_config(root string) { + store.mu.lock() + defer { + store.mu.unlock() + } + store.roots.delete(config_root_key(root)) +} + +// editor_settings returns the editor tier. +fn (store &VlsConfigStore) editor_settings() EditorSettings { + store.mu.rlock() + defer { + store.mu.runlock() + } + return store.editor +} + +// set_editor_settings replaces the editor tier, and drops the configuration +// cached for every project root, because that tier is part of all of them. +fn (mut store VlsConfigStore) set_editor_settings(settings EditorSettings) { + store.mu.lock() + defer { + store.mu.unlock() + } + store.editor = settings + store.roots = map[string]VlsConfigCacheEntry{} +} + +// project_root_for_path returns the project root that owns `path`: the folder +// with the nearest `v.mod` above it, or its own directory when there is none. +fn project_root_for_path(path string) string { + return project_root_for_dir(os.dir(path)) +} + +// project_root_for_dir returns the project root a directory belongs to. +fn project_root_for_dir(dir string) string { + if dir == '' { + return '' + } + root := find_project_root(dir) + return config_root_key(if root != '' { root } else { dir }) +} + +// config_root_key normalises a project root into the one spelling the cache is +// keyed by. A path from a URI uses forward slashes and one built from the +// filesystem may not, and they must not cache the same project twice. +fn config_root_key(root string) string { + return normalize_overlay_path(root) +} + +// read_project_file reads the project's `vls.json`. A missing, unreadable or +// malformed file contributes nothing: it must never keep a check from running. +fn read_project_file(root string) VlsProjectFile { + if root == '' { + return VlsProjectFile{} + } + path := os.join_path(root, vls_config_file_name) + raw := os.read_file(path) or { return VlsProjectFile{} } + return json2.decode[VlsProjectFile](raw) or { return VlsProjectFile{} } +} + +// env_defines is the last tier: VLS_DEFINES, split with the quoting rules the +// compiler itself reads VFLAGS with. +fn env_defines() []string { + raw := os.getenv_opt(vls_defines_env_var) or { return [] } + return os.split_args(raw) or { return [] } +} + +// merge_config_tiers folds the three tiers. For each key the first tier that +// carries a value wins, so the editor overrides the project and the project +// overrides the environment. A defines value replaces the one below it rather +// than adding to it, so the tiers cannot build up flags nobody asked for. +fn merge_config_tiers(editor EditorSettings, file VlsProjectFile, env []string) VlsProjectConfig { + mut config := VlsProjectConfig{} + if editor.defines.len > 0 { + config.defines = define_args(editor.defines) + } else if file.defines.len > 0 { + config.defines = define_args(file.defines) + } else { + config.defines = define_args(env) + } + if enabled := editor.inlay_hints { + config.inlay_hints = enabled + } else if enabled := file.inlay_hints { + config.inlay_hints = enabled + } + if enabled := editor.diagnostics { + config.diagnostics = enabled + } else if enabled := file.diagnostics { + config.diagnostics = enabled + } + return config +} + +// config_signature identifies a merged configuration, so a re-read can tell +// whether a project's file actually changed. +fn config_signature(config VlsProjectConfig) string { + mut parts := [config.defines.join(' ')] + if enabled := config.inlay_hints { + parts << 'inlayHints:${enabled}' + } + if enabled := config.diagnostics { + parts << 'diagnostics:${enabled}' + } + return parts.join('|') +} + +// define_args turns the entries of a `defines` setting into the argument pairs +// the compiler takes. Both spellings an editor sends are accepted: `-d bespin` as +// two entries, and `-dbespin` as one. Anything that is not a `-d` flag is +// dropped, because this setting carries compile-time defines and nothing else. +fn define_args(defines []string) []string { + mut args := []string{cap: defines.len} + mut i := 0 + for i < defines.len { + entry := defines[i].trim_space() + i++ + if entry == '' { + continue + } + if entry == '-d' { + // The name is the next entry; a lone `-d` at the end carries none. + if i < defines.len { + name := defines[i].trim_space() + if name != '' && !name.starts_with('-') { + args << ['-d', name] + i++ + } + } + continue + } + if entry.starts_with('-d') { + args << ['-d', entry[2..].trim_left('=').trim_space()] + } + } + return args +} + +// resolve_editor_defines returns the defines of a +// workspace/didChangeConfiguration payload, and an empty list when it carries +// none: the payload is the whole settings state, so a missing key is an unset +// one, and the tiers below it decide. +fn resolve_editor_defines(params_json string) []string { + decoded := json2.decode[EditorDefinesParams](params_json) or { return [] } + if defines := decoded.settings.vls.defines { + return defines + } + return decoded.settings.defines or { [] } +} diff --git a/vls_config_test.v b/vls_config_test.v new file mode 100644 index 00000000..b27c04db --- /dev/null +++ b/vls_config_test.v @@ -0,0 +1,310 @@ +// Tests for the layered configuration (vls_config.v): the defines a check runs +// with, and the switches a project may set for itself. +// +// Every helper here is named with a `config_test_` prefix of its own. All +// _test.v files of this program are module main, so a helper sharing a name +// with one in another file would not compile. +module main + +import os +import time + +// config_test_vmod is a `v.mod` a project root needs to be one. Its content does +// not matter to VLS: find_project_root only asks whether the file is there. +const config_test_vmod = "Module {\n\tname: 'configtest'\n\tdescription: ''\n\tversion: '0.0.1'\n\tlicense: 'MIT'\n}\n" + +// config_test_project returns a fresh project root under the system temp dir. +fn config_test_project(tag string) string { + root := os.join_path(os.temp_dir(), 'vls_config_${tag}_${os.getpid()}_${time.now().unix_nano()}') + config_test_must_mkdir_all(root) + config_test_write(os.join_path(root, 'v.mod'), config_test_vmod) + return root +} + +fn config_test_must_mkdir_all(path string) { + os.mkdir_all(path) or { + assert false, 'config test: cannot create ${path}: ${err}' + return + } +} + +fn config_test_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'config test: cannot write ${path}: ${err}' + } +} + +// config_test_app returns an App whose only workspace root is `root`, with the +// configuration store emptied, so no earlier test leaves a tier behind. +fn config_test_app(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + workspace_roots: [root] + } +} + +// config_test_diag_cache_dir points the diagnostics cache at a folder of its +// own and returns the previous value, so a test never writes to the real one. +fn config_test_diag_cache_dir() string { + dir := os.join_path(os.temp_dir(), 'vls_config_diagcache_${os.getpid()}_${time.now().unix_nano()}') + config_test_must_mkdir_all(dir) + previous := os.getenv('VLS_DIAG_CACHE_DIR') + os.setenv('VLS_DIAG_CACHE_DIR', dir, true) + return previous +} + +fn config_test_restore_diag_cache_dir(previous string) { + if previous == '' { + os.unsetenv('VLS_DIAG_CACHE_DIR') + } else { + os.setenv('VLS_DIAG_CACHE_DIR', previous, true) + } +} + +// config_test_forget_env_defines drops VLS_DEFINES for the test and returns +// what it was, so the last tier never leaks into another one. +fn config_test_forget_env_defines() string { + previous := os.getenv_opt(vls_defines_env_var) or { '' } + os.unsetenv(vls_defines_env_var) + return previous +} + +fn config_test_restore_env_defines(previous string) { + if previous == '' { + os.unsetenv(vls_defines_env_var) + } else { + os.setenv(vls_defines_env_var, previous, true) + } +} + +fn test_config_define_args_accepts_every_spelling() { + assert define_args(['-d', 'bespin']) == ['-d', 'bespin'], 'two entries stay a pair' + assert define_args(['-dbespin']) == ['-d', 'bespin'], 'a joined entry splits into a pair' + assert define_args(['-d=bespin']) == ['-d', 'bespin'], 'an `=` between flag and name is dropped' + assert define_args(['-d', 'a', '-db', 'c']) == ['-d', 'a', '-d', 'b'], 'a pair consumes only its own name' + assert define_args(['', '-d', 'x']) == ['-d', 'x'], 'empty entries are dropped' + assert define_args(['-cc', 'gcc']).len == 0, 'a flag that is not a define is dropped' + assert define_args(['-d']).len == 0, 'a lone -d carries no name' + assert define_args([]).len == 0, 'no entries make no pairs' +} + +fn test_config_project_file_is_the_second_tier() { + root := config_test_project('file') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"],"inlayHints":false}') + mut app := config_test_app(root) + config := app.project_config_for_root(root) + assert config.defines == ['-d', 'bespin'], 'the file defines the configuration, got ${config.defines}' + if enabled := config.inlay_hints { + assert !enabled, 'the file inlayHints reaches the configuration' + } else { + assert false, 'the file inlayHints was not read' + } + assert config.diagnostics == none, 'a key the file does not set says nothing' +} + +fn test_config_missing_or_malformed_file_contributes_nothing() { + root := config_test_project('nofile') + defer { + os.rmdir_all(root) or {} + } + mut missing := config_test_app(root) + empty := missing.project_config_for_root(root) + assert empty.defines.len == 0, 'a project without vls.json defines nothing' + broken := config_test_project('malformed') + defer { + os.rmdir_all(broken) or {} + } + config_test_write(os.join_path(broken, 'vls.json'), '{"defines": [') + mut app := config_test_app(broken) + assert app.project_config_for_root(broken).defines.len == 0, 'a malformed vls.json defines nothing' + assert app.project_config_for_root(broken).inlay_hints == none, 'a malformed vls.json says nothing about the switches' +} + +fn test_config_editor_settings_win_over_the_project_file() { + root := config_test_project('editor') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"],"diagnostics":false}') + mut app := config_test_app(root) + // The editor's own settings are the top tier... + app.apply_editor_configuration('{"settings":{"vls":{"defines":["-d","naboo"],"diagnostics":true}}}') + config := app.project_config_for_root(root) + assert config.defines == ['-d', 'naboo'], 'the editor overrides the project file, got ${config.defines}' + assert app.diagnostics_enabled, 'the editor re-enables the switch the file turned off' + // ... and a payload that does not carry the key unset it, which leaves the + // project file to decide what a check runs with. + app.apply_editor_configuration('{"settings":{"vls":{"diagnostics":true}}}') + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'an unset defines falls back to the file' +} + +fn test_config_defines_at_the_top_level_of_the_settings() { + root := config_test_project('flat') + defer { + os.rmdir_all(root) or {} + } + mut app := config_test_app(root) + // Some clients send the settings object without a `vls` section. + app.apply_editor_configuration('{"settings":{"defines":["-dbespin"]}}') + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'the flat defines are read too' +} + +fn test_config_environment_is_the_last_tier() { + root := config_test_project('env') + defer { + os.rmdir_all(root) or {} + } + saved := config_test_forget_env_defines() + defer { + config_test_restore_env_defines(saved) + } + os.setenv(vls_defines_env_var, '-dbespin', true) + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'VLS_DEFINES is the last tier' + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","naboo"]}') + mut store := vls_config_store() + store.drop_config(root) + assert app.project_config_for_root(root).defines == ['-d', 'naboo'], 'the project file beats the environment' +} + +fn test_config_project_file_is_cached_for_the_ttl() { + root := config_test_project('ttl') + defer { + os.rmdir_all(root) or {} + } + mut store := vls_config_store() + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines.len == 0, 'no file defines nothing' + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + assert app.project_config_for_root(root).defines.len == 0, 'the cached configuration is reused inside the ttl' + store.drop_config(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'a dropped entry is read again' +} + +fn test_config_a_changed_file_drops_the_diagnostics_of_its_root() { + root := config_test_project('filechange') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + config_test_write(source, content) + uri := path_to_uri(source) + mut app := config_test_app(root) + assert app.check_defines(source) == ['-d', 'bespin'], 'the file defines the check of its own project' + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'seeded' + errors: [] + } + // The file changed on disk and the configuration with it. + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","naboo"]}') + app.forget_config_file(os.join_path(root, 'vls.json')) + assert uri !in app.diag_cache, 'a changed configuration drops the cached diagnostics' + assert app.check_defines(source) == ['-d', 'naboo'], 'the new file defines the check' +} + +fn test_config_a_watched_file_that_is_not_the_config_is_left_alone() { + root := config_test_project('otherfile') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-d","bespin"]}') + mut app := config_test_app(root) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'the file is read once' + app.forget_config_file(os.join_path(root, 'main.v')) + assert app.project_config_for_root(root).defines == ['-d', 'bespin'], 'a change of another file forgets nothing' +} + +fn test_config_a_changed_editor_defines_value_drops_every_cached_diagnostic() { + root := config_test_project('diag') + defer { + os.rmdir_all(root) or {} + } + other := config_test_project('diag_other') + defer { + os.rmdir_all(other) or {} + } + source := os.join_path(root, 'main.v') + content := 'module main\n\nfn main() {}\n' + config_test_write(source, content) + uri := path_to_uri(source) + mut app := config_test_app(root) + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'seeded' + errors: [] + } + app.apply_editor_configuration('{"settings":{"vls":{"defines":["-d","bespin"]}}}') + assert uri !in app.diag_cache, 'a new defines value drops the cached diagnostics of every file' +} + +fn test_config_the_project_switches_are_applied_to_the_app() { + root := config_test_project('switch') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + mut app := config_test_app(root) + app.apply_editor_configuration('{}') + assert !app.inlay_hints_enabled, 'the project file turns inlay hints off' + assert !app.diagnostics_enabled, 'the project file turns diagnostics off' + // The editor's own settings win again once it says something. + app.apply_editor_configuration('{"settings":{"vls":{"inlayHints":true}}}') + assert app.inlay_hints_enabled, 'the editor turns inlay hints back on' + assert !app.diagnostics_enabled, 'a key the editor did not send keeps the project value' +} + +fn test_config_the_defines_reach_only_the_check_argument_vector() { + base := build_v_check_args_multifile(false) + assert check_args_with_defines(base, []).len == base.len, 'no defines leave the vector alone' + with_defines := check_args_with_defines(base, ['-d', 'bespin']) + mut expected := base.clone() + expected << ['-d', 'bespin'] + assert with_defines == expected, 'the defines are appended to the check vector' + assert with_defines.len == base.len + 2, 'each define is one flag and one name' + assert build_v_fmt_args('x.v').all(it != '-d'), 'the formatter never takes a define' + assert build_v_line_info_args_multifile('x.v', '1:1').all(it != '-d'), 'the questions never take a define' +} + +fn test_config_the_defines_make_the_compiler_check_the_gated_code() { + assert compiler_is_available(), 'a V compiler must be reachable to check the gated fixture' + saved_cache := config_test_diag_cache_dir() + defer { + config_test_restore_diag_cache_dir(saved_cache) + } + saved_defines := config_test_forget_env_defines() + defer { + config_test_restore_env_defines(saved_defines) + } + root := config_test_project('gated') + defer { + os.rmdir_all(root) or {} + } + work := os.join_path(root, 'work') + config_test_must_mkdir_all(work) + content := 'module main\n\nfn main() {\n\t$if bespin ? {\n\t\tundef_fn_bespin_probe()\n\t}\n}\n' + source := os.join_path(root, 'main.v') + config_test_write(source, content) + uri := path_to_uri(source) + mut app := &App{ + temp_dir: work + open_files: { + uri: content + } + workspace_roots: [root] + } + // Without a define the gated body is not compiled, so it is not checked. + assert app.run_v_check(uri, content).len == 0, 'no define checks no gated code' + // The project file supplies the define, and the check then finds the error. + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"]}') + mut store := vls_config_store() + store.drop_config(root) + gated := app.run_v_check(uri, content) + assert gated.any(it.message.contains('undef_fn_bespin_probe')), 'the define made the compiler check the gated code: ${gated}' +} From 8b6e4b28b240c0315b2b35b40c89bd3268cb7fcd Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 19:58:43 +0330 Subject: [PATCH 32/83] feat: extract variable and inline variable refactors Both are pure text refactors offered as quickfixes. Extract replaces a side-effect-free expression with a fresh name and inserts the declaration before the enclosing statement; inline removes a single-assignment local and substitutes its expression at every later use, leaving no declaration when exactly one use remains. --- assists.v | 905 +++++++++++++++++++++++++++++++++++++++++++++++++ assists_test.v | 430 +++++++++++++++++++++++ 2 files changed, 1335 insertions(+) create mode 100644 assists_test.v diff --git a/assists.v b/assists.v index e3259043..b247eb98 100644 --- a/assists.v +++ b/assists.v @@ -657,3 +657,908 @@ fn masked_v_code(content string) string { } return out.bytestr() } + +// --- Extract variable --- + +// AssistExprScan walks a masked expression byte by byte and refuses anything +// that is not one of the pure forms. One level of the scan is one expression: +// a primary, the field, call and index trailers hanging off it, and the base +// identifier they all traverse from. The base of every level that calls or +// indexes is kept, because those are the only receivers the caller can check +// against the buffer — a call on anything but a local is a call the language +// server cannot vouch for. +struct AssistExprScan { + masked string +mut: + pos int + levels []string + receivers []string +} + +// assist_pure_expression reports whether `masked` is a side-effect-free +// expression — an identifier, a literal, a field access, a call or an index on +// a local, or a parenthesised combination of those — and returns the receiver +// of every call and index it holds. Each receiver must be a local before the +// expression is extracted, which the caller verifies against the buffer. +fn assist_pure_expression(masked string) ?[]string { + mut scan := AssistExprScan{ + masked: masked + } + if !scan.parse_expr() { + return none + } + scan.skip_ws() + if scan.pos != masked.len { + return none + } + if !assist_expression_alphabet_is_safe(masked) { + return none + } + return scan.receivers +} + +fn (mut s AssistExprScan) at_end() bool { + return s.pos >= s.masked.len +} + +fn (mut s AssistExprScan) skip_ws() { + for !s.at_end() && s.masked[s.pos] in [` `, `\t`, `\r`, `\n`] { + s.pos++ + } +} + +// parse_expr parses one expression: a primary followed by the field, call and +// index trailers that hang off it. Trailing text the grammar does not reach +// makes it fail, so `x + y` and `x or { 0 }` are both refused. +fn (mut s AssistExprScan) parse_expr() bool { + s.levels << '' + level := s.levels.len - 1 + if !s.parse_primary(level) { + s.drop_level() + return false + } + mut calls_or_indexes := false + for { + s.skip_ws() + if s.at_end() { + break + } + c := s.masked[s.pos] + if c == `.` { + s.pos++ + if !s.parse_ident(level) { + s.drop_level() + return false + } + continue + } + if c == `(` || c == `[` { + closing := if c == `(` { `)` } else { `]` } + if !s.parse_group(c, closing) { + s.drop_level() + return false + } + calls_or_indexes = true + continue + } + // Any other byte ends this expression, and the reader around it decides + // whether that was legal: the top level check that the whole selection + // was consumed, or the bracket that holds this expression. + break + } + base := s.levels[level] + s.drop_level() + // A call or an index runs code the language server cannot read, so the value + // it runs on has to be a local: `s.replace(...)`, `items[0]`, `foo.bar()`. + if calls_or_indexes && base != '' { + s.receivers << base + } + return true +} + +// drop_level closes the level on top of the stack, handing its base to the level +// below when that one has none. A parenthesised primary is the base of the +// expression around it, so `(a).b()` traverses `a` and not `b`. +fn (mut s AssistExprScan) drop_level() { + level := s.levels.len - 1 + base := s.levels[level] + s.levels.delete_last() + if base != '' && s.levels.len > 0 && s.levels[s.levels.len - 1] == '' { + s.levels[s.levels.len - 1] = base + } +} + +// parse_primary parses the value an expression starts from: a parenthesised +// expression, a string or a number, or an identifier. +fn (mut s AssistExprScan) parse_primary(level int) bool { + s.skip_ws() + if s.at_end() { + return false + } + c := s.masked[s.pos] + if c == `(` { + s.pos++ + s.skip_ws() + // An empty group is not an expression, so `()` is never a candidate. + if s.at_end() || s.masked[s.pos] == `)` { + return false + } + if !s.parse_expr() { + return false + } + s.skip_ws() + if s.at_end() || s.masked[s.pos] != `)` { + return false + } + s.pos++ + return true + } + if c == `'` || c == `"` || c == 96 { + return s.parse_string(c) + } + if c >= `0` && c <= `9` { + return s.parse_number() + } + return s.parse_ident(level) +} + +// parse_group parses a call's arguments or an index. An index holds exactly one +// expression; a call may hold any number of them, and every one of those is +// itself held to the same purity by the recursive parse_expr. +fn (mut s AssistExprScan) parse_group(open u8, close u8) bool { + s.pos++ + s.skip_ws() + if !s.at_end() && s.masked[s.pos] == close { + // `x[]` is not an index; `x()` is a call taking nothing. + if open == `[` { + return false + } + s.pos++ + return true + } + mut items := 0 + for { + if !s.parse_expr() { + return false + } + items++ + s.skip_ws() + if s.at_end() { + return false + } + c := s.masked[s.pos] + if c == close { + if close == `]` && items != 1 { + return false + } + s.pos++ + return true + } + if c != `,` { + return false + } + s.pos++ + } + return false +} + +// parse_ident consumes one identifier and remembers it as the base of `level` +// when that level has none yet. It refuses the keywords that only look like a +// value: `fn`, `unsafe`, `or`, `int` and the rest never denote a value on their +// own. +fn (mut s AssistExprScan) parse_ident(level int) bool { + s.skip_ws() + mut start := s.pos + for !s.at_end() && is_ident_char(s.masked[s.pos]) { + s.pos++ + } + if s.pos == start { + return false + } + if s.masked[start..s.pos] in assist_non_value_keywords { + s.pos = start + return false + } + if s.levels[level] == '' { + s.levels[level] = s.masked[start..s.pos] + } + return true +} + +// parse_string consumes a string or a rune literal. The mask has already +// blanked its body, quotes included where they are escaped, so the first +// unblanked quote after the opener is the one that closes it. +fn (mut s AssistExprScan) parse_string(quote u8) bool { + s.pos++ + for !s.at_end() && s.masked[s.pos] != quote { + s.pos++ + } + if s.at_end() { + return false + } + s.pos++ + return true +} + +// parse_number consumes one numeric literal, including its radix prefix, its +// digit separators and the exponent that follows an `e`. +fn (mut s AssistExprScan) parse_number() bool { + for !s.at_end() { + c := s.masked[s.pos] + if is_ident_char(c) || c == `.` || c == `_` { + s.pos++ + continue + } + if (c == `+` || c == `-`) && s.pos > 0 && (s.masked[s.pos - 1] == `e` + || s.masked[s.pos - 1] == `E`) { + s.pos++ + continue + } + break + } + return s.pos > 0 +} + +// assist_expression_alphabet_is_safe reports whether every byte of a candidate +// expression is one a pure form can hold: an identifier, a literal, a bracket, a +// comma or whitespace. A `{`, an `=`, an `&`, a `?`, a `#` or a `$` refuses the +// expression whatever the grammar made of it. +fn assist_expression_alphabet_is_safe(masked string) bool { + for i in 0 .. masked.len { + c := masked[i] + if is_ident_char(c) || c == 96 { + continue + } + match c { + ` `, `\t`, `\r`, `\n`, `.`, `(`, `)`, `[`, `]`, `,`, `'`, `"`, `+`, `-` {} + else { + return false + } + } + } + return true +} + +// Words that never denote a value, so none of them may be the expression a +// variable is extracted from: the control-flow keywords, the type names that +// only look like a value in a declaration, and the module-level words. +const assist_non_value_keywords = ['fn', 'if', 'for', 'match', 'mut', 'unsafe', 'or', 'go', 'spawn', + 'return', 'in', 'is', 'as', 'typeof', 'sizeof', 'dump', 'panic', 'error', 'select', 'lock', + 'rlock', 'shared', 'static', 'atomic', 'defer', 'continue', 'break', 'import', 'module', 'const', + 'struct', 'enum', 'interface', 'type', 'assert', 'nil', 'bool', 'byte', 'charptr', 'f32', 'f64', + 'i8', 'i16', 'i32', 'i64', 'i128', 'int', 'isize', 'rune', 'string', 'u8', 'u16', 'u32', 'u64', + 'u128', 'usize', 'voidptr']! + +// build_extract_variable_action returns the "Extract variable" quick fix for +// the expression the range selects: the selection is replaced by a fresh name +// and that name is bound to the text of the selection in front of the statement +// holding it. It is refused when the selection does not cover exactly one pure +// expression, when it is the target of an assignment, or when the statement it +// sits in cannot be located. +fn (app &App) build_extract_variable_action(uri string, content string, sel_range LSPRange) ?CodeAction { + starts := line_start_offsets(content) + mut start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if end <= start || start < 0 || end > content.len { + return none + } + masked := masked_v_code(content) + for start < end && masked[start] in [` `, `\t`, `\r`, `\n`] { + start++ + } + for end > start && masked[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + if end <= start { + return none + } + stmt_start := assist_statement_start(masked, starts, start) + if stmt_start < 0 || stmt_start > start { + return none + } + // The left of an assignment is not a value: binding `extracted_0 := x` in + // front of `x := f(x)` would read x before it is written. An index inside the + // target is not itself the target: `arr[i] = 1` assigns to arr, not to i. + if assist_selection_is_assignment_target(masked, stmt_start, start, end) { + return none + } + receivers := assist_pure_expression(masked[start..end]) or { return none } + for receiver in receivers { + if !assist_binding_exists_before(masked, receiver, start) { + return none + } + } + // The base of a longer selector is only a value when it is a local: `math` + // in `math.pi` names a module, which no variable can hold. + if end < masked.len && masked[end] == `.` { + base := assist_identifier_at(masked, start) or { return none } + if !assist_binding_exists_before(masked, base, start) { + return none + } + } + name := assist_fresh_extract_name(content, app.position_encoding) + stmt_line := assist_line_of(starts, stmt_start) + insert_at := byte_offset_to_position(content, starts[stmt_line], app.position_encoding) + indent := line_indent(line_text_without_terminator(content, starts, stmt_line)) + return CodeAction{ + title: 'Extract variable' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: insert_at + end: insert_at + } + new_text: indent + 'mut ${name} := ' + content[start..end] + '\n' + }, + TextEdit{ + range: LSPRange{ + start: byte_offset_to_position(content, start, app.position_encoding) + end: byte_offset_to_position(content, end, app.position_encoding) + } + new_text: name + }, + ] + } + } + } +} + +// assist_statement_start returns the byte offset at which the statement holding +// `probe` begins, or -1 when no statement can be located for it. A `;`, a brace +// or a `:` at bracket depth zero opens a later statement on the same line, and +// the lines the statement continues from are absorbed before that. A statement +// that begins in the middle of its line is refused: a declaration cannot be +// written in front of a `case` arm or an `else` without leaving its block. +fn assist_statement_start(masked string, starts []int, probe int) int { + if probe < 0 || probe >= masked.len { + return -1 + } + mut line := assist_line_of(starts, probe) + for line > 0 && assist_line_continues_previous(masked, starts, line - 1) { + line-- + } + mut stmt := starts[line] + line_indent(line_text_without_terminator(masked, starts, line)).len + stop := assist_statement_separator(masked, stmt, probe) + if probe < assist_line_end(masked.len, starts, line) && stop >= 0 { + stmt = stop + } + stmt_line := assist_line_of(starts, stmt) + if stmt != starts[stmt_line] + line_indent(line_text_without_terminator(masked, starts, + stmt_line)).len { + return -1 + } + if assist_line_takes_no_declaration(line_text_without_terminator(masked, starts, stmt_line)) { + return -1 + } + return stmt +} + +// assist_line_continues_previous reports whether `prev_line` is a line the next +// one continues: empty, or ending on a token that needs a right operand. +fn assist_line_continues_previous(masked string, starts []int, prev_line int) bool { + trimmed := line_text_without_terminator(masked, starts, prev_line).trim_space() + if trimmed == '' { + return true + } + return trimmed[trimmed.len - 1] in [`,`, `.`, `(`, `[`, `+`, `-`, `*`, `/`, `%`, `&`, `|`, + `^`, `<`, `>`, `=`, `!`, `?`] +} + +// assist_statement_separator returns the byte offset just past the `;`, brace or +// `:` at bracket depth zero in [from, to), or -1 when there is none. Each of +// those opens a statement that is not the one starting at `from`. +fn assist_statement_separator(masked string, from int, to int) int { + mut depth := 0 + for i in from .. to { + c := masked[i] + if c == `(` || c == `[` { + depth++ + continue + } + if c == `)` || c == `]` { + if depth > 0 { + depth-- + } + continue + } + if c == `}` { + if depth == 0 { + return i + 1 + } + depth-- + continue + } + if (c == `;` || c == `:`) && depth == 0 { + return i + 1 + } + } + return -1 +} + +// assist_line_takes_no_declaration reports whether a declaration can be written +// in front of the statement on `line`: never for a blank line, and never for a +// line beginning with a token that only continues a block — `}`, `else` or +// `case` — because the declaration would then move out of the block that holds +// it. +fn assist_line_takes_no_declaration(line string) bool { + trimmed := line.trim_space() + return trimmed == '' || trimmed.starts_with('}') || trimmed.starts_with('else') + || trimmed.starts_with('case ') +} + +// assist_selection_is_assignment_target reports whether the selection sits on +// the left of an assignment: `x = 1`, `x += 1` and `x := 1` all bind x, so +// extracting x would bind a name to a value that is not in scope yet. The `==` +// of a comparison, and the `<=`, `>=` and `!=` that share its byte, are not +// assignments. An expression inside a bracketed index is not the target either: +// `arr[i] = 1` assigns to arr, not to i. +fn assist_selection_is_assignment_target(masked string, stmt_start int, sel_start int, sel_end int) bool { + mut line_end := stmt_start + for line_end < masked.len && masked[line_end] != `\n` { + line_end++ + } + mut depth := 0 + mut sel_depth := 0 + mut operator := -1 + for i in stmt_start .. line_end { + c := masked[i] + if i == sel_start { + sel_depth = depth + } + if c == `(` || c == `[` || c == `{` { + depth++ + continue + } + if c == `)` || c == `]` || c == `}` { + if depth > 0 { + depth-- + } + continue + } + if depth != 0 || c != `=` { + continue + } + if i + 1 < line_end && masked[i + 1] == `=` { + return false + } + if i > 0 && masked[i - 1] in [`<`, `>`, `!`] { + return false + } + operator = i + break + } + if operator < 0 { + return false + } + return sel_depth == 0 && sel_end <= operator +} + +// assist_fresh_extract_name returns the first `extracted_N` that no identifier +// in `content` already uses, so the new name cannot capture an existing binding. +fn assist_fresh_extract_name(content string, enc PositionEncoding) string { + used := extract_identifier_occurrences(content, enc) + mut n := 0 + for 'extracted_${n}' in used { + n++ + } + return 'extracted_${n}' +} + +// assist_binding_exists_before reports whether `name` is bound before `offset`, +// either by a `name :=` declaration or as a parameter of the enclosing function. +// That is what makes it a *local*, and therefore the only kind of receiver a +// call chain on it can be trusted not to have side effects. +fn assist_binding_exists_before(masked string, name string, offset int) bool { + mut i := 0 + for i < offset { + if i + name.len <= masked.len && masked[i..i + name.len] == name + && (i == 0 || !is_ident_char(masked[i - 1])) + && (i + name.len == masked.len || !is_ident_char(masked[i + name.len])) { + mut k := i + name.len + for k < offset && (masked[k] == ` ` || masked[k] == `\t`) { + k++ + } + if k + 1 < masked.len && masked[k] == `:` && masked[k + 1] == `=` { + return true + } + } + i++ + } + return assist_fn_parameter_before(masked, name, offset) +} + +// assist_fn_parameter_before reports whether `name` is a parameter of the +// function enclosing `offset`. A call chain on a parameter is exactly as pure +// as one on a `:=` local, so refusing it would refuse the commonest case there +// is: `s.len()` inside `fn f(s string)`. +fn assist_fn_parameter_before(masked string, name string, offset int) bool { + fn_index := last_fn_keyword_index(masked[..offset]) + if fn_index < 0 { + return false + } + mut open := fn_index + for open < offset && masked[open] != `(` { + open++ + } + if open >= offset { + return false + } + close := matching_delimiter(masked, open, `(`, `)`) + if close < 0 || close >= offset { + return false + } + for param in masked[open + 1..close].split(',') { + mut token := param.trim_space() + if token.starts_with('mut ') { + token = token[4..].trim_space() + } + if token.all_before(' ').all_before('\t') == name { + return true + } + } + return false +} + +// assist_identifier_at returns the identifier covering byte `offset`, or none +// when that byte is not inside one. +fn assist_identifier_at(text string, offset int) ?string { + if offset < 0 || offset >= text.len { + return none + } + mut end := offset + for end < text.len && is_ident_char(text[end]) { + end++ + } + mut start := offset + for start > 0 && is_ident_char(text[start - 1]) { + start-- + } + if start == end { + return none + } + name := text[start..end] + if !is_valid_v_identifier_name(name) { + return none + } + return name +} + +// assist_line_of returns the index of the line holding byte `offset`. +fn assist_line_of(starts []int, offset int) int { + mut line := 0 + for line + 1 < starts.len && starts[line + 1] <= offset { + line++ + } + return line +} + +// assist_line_end returns the byte offset at which the terminator of `line` +// begins, which is also where the next line starts. +fn assist_line_end(text_len int, starts []int, line int) int { + return if line + 1 < starts.len { starts[line + 1] } else { text_len } +} + +// assist_block_end_line returns the first line after `decl_line` whose +// indentation comes back in above the declaration's, or the line count when +// nothing brings it back. That closing line is the brace of the block the local +// was declared in, so everything from it on belongs to another scope. +fn assist_block_end_line(content string, starts []int, decl_line int, decl_indent int) int { + mut i := decl_line + 1 + for i < starts.len { + if line_indent(line_text_without_terminator(content, starts, i)).len < decl_indent { + return i + } + i++ + } + return starts.len +} + +// --- Inline variable --- + +// AssistInline is a local that can be folded back into its uses: the name it +// binds, the expression it was bound to, the line declaring it, and every use +// the expression replaces. +struct AssistInline { + name string + expr string + decl_line int + uses []TokenOccurrence +} + +// build_inline_variable_action returns the "Inline variable" quick fix for the +// local the cursor or range sits on: the declaration line is removed and every +// later use of the name is replaced by the expression it was bound to. It is +// refused unless the name is declared `name := expr` on a line of its own and is +// never assigned or rebound afterwards, since a second writing of the +// identifier would have to be reordered to inline it. +fn (app &App) build_inline_variable_action(uri string, content string, sel_range LSPRange) ?CodeAction { + inline := app.inline_variable_span(content, sel_range) or { return none } + enc := app.position_encoding + starts := line_start_offsets(content) + decl_end := assist_line_end(content.len, starts, inline.decl_line) + mut edits := []TextEdit{} + // The whole line goes, terminator included, so no blank line is left where + // the declaration was. The final line of a file without a terminator is + // ended by the last character instead of the start of a line that does not + // exist, which some clients reject as an out-of-range edit. + edits << TextEdit{ + range: LSPRange{ + start: Position{ + line: inline.decl_line + char: 0 + } + end: if inline.decl_line + 1 < starts.len { + Position{ + line: inline.decl_line + 1 + char: 0 + } + } else { + byte_offset_to_position(content, decl_end, enc) + } + } + new_text: '' + } + for use in inline.uses { + line_text := line_text_without_terminator(content, starts, use.line) + start := starts[use.line] + encoded_col_to_byte(line_text, use.start_char, enc) + end := starts[use.line] + encoded_col_to_byte(line_text, use.end_char, enc) + edits << TextEdit{ + range: LSPRange{ + start: byte_offset_to_position(content, start, enc) + end: byte_offset_to_position(content, end, enc) + } + new_text: inline.expr + } + } + return CodeAction{ + title: 'Inline variable' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: edits + } + } + } +} + +// inline_variable_span resolves the local under `sel_range` into the +// declaration, the uses and the reassignment verdict, or none when the cursor is +// not on a local that can be folded back into its uses. +fn (app &App) inline_variable_span(content string, sel_range LSPRange) ?AssistInline { + starts := line_start_offsets(content) + start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut end := start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + masked := masked_v_code(content) + mut selected := '' + if end > start { + candidate := content[start..end].trim_space() + if is_valid_v_identifier_name(candidate) { + selected = candidate + } + } + name := if selected != '' { + selected + } else { + assist_identifier_at(masked, start) or { return none } + } + if name in assist_non_value_keywords { + return none + } + decl_line := assist_declaration_line(content, masked, starts, name, + assist_line_of(starts, start)) + if decl_line < 0 { + return none + } + expr := assist_line_declaration(content, masked, starts, decl_line, name) or { return none } + // An expression that mentions the name being removed would keep a reference + // to a binding that no longer exists. + if name in extract_identifier_occurrences(expr, app.position_encoding) { + return none + } + mut uses := []TokenOccurrence{} + // A local lives inside the block that declares it, and in a Go-shaped file + // that block ends where the indentation comes back in. A same-named local + // further down the file belongs to another scope, so it keeps its name. + decl_indent := line_indent(line_text_without_terminator(content, starts, decl_line)).len + block_end := assist_block_end_line(content, starts, decl_line, decl_indent) + for occurrence in extract_identifier_occurrences(content, app.position_encoding)[name] or { + return none + } { + if occurrence.line <= decl_line || occurrence.line >= block_end { + continue + } + line_text := line_text_without_terminator(content, starts, occurrence.line) + use_start := starts[occurrence.line] + encoded_col_to_byte(line_text, occurrence.start_char, + app.position_encoding) + use_end := starts[occurrence.line] + encoded_col_to_byte(line_text, occurrence.end_char, + app.position_encoding) + if assist_is_reassignment(masked, use_end) { + return none + } + // A field of another object (`obj.x`), a struct field initialiser or a + // label (`x:`), and a declaration of some other `x` (`x int` in a struct + // body or a parameter list) are not uses of this local, and inlining + // them would rewrite a different symbol. + if !assist_is_use(masked, use_start, use_end) { + continue + } + uses << occurrence + } + if uses.len == 0 { + return none + } + return AssistInline{ + name: name + expr: expr + decl_line: decl_line + uses: uses + } +} + +// assist_declaration_line returns the line of the `name := expr` declaration +// the cursor's `line` can see, searching upwards from it, or -1 when it has +// none. The first match wins, so a use further down a file inlines the nearest +// declaration above it. +fn assist_declaration_line(content string, masked string, starts []int, name string, line int) int { + mut i := line + for i >= 0 { + if _ := assist_line_declaration(content, masked, starts, i, name) { + return i + } + i-- + } + return -1 +} + +// assist_line_declaration returns the expression `name` is bound to by the +// `name := expr` declaration that is the whole code of `line`, or none when that +// line does not declare it. A line shared with other code, a multi-target +// declaration, a re-binding with `=` and a trailing comment are all refused: a +// deletion of that line would take the other code, or the comment, with it. +fn assist_line_declaration(content string, masked string, starts []int, line int, name string) ?string { + masked_line := line_text_without_terminator(masked, starts, line) + mut pos := line_indent(masked_line).len + if word := assist_word_at(masked_line, pos) { + if word == 'mut' { + pos += word.len + for pos < masked_line.len && masked_line[pos] in [` `, `\t`] { + pos++ + } + } + } + bound := assist_word_at(masked_line, pos) or { return none } + if bound != name { + return none + } + pos += bound.len + for pos < masked_line.len && masked_line[pos] in [` `, `\t`] { + pos++ + } + if pos + 1 >= masked_line.len || masked_line[pos] != `:` || masked_line[pos + 1] != `=` { + return none + } + pos += 2 + // The expression is read from the unmasked line: the mask blanks string + // bodies, and the binding would then carry a blank string instead of the + // value the file holds. With no comment on the line the two lines agree on + // where the expression ends, because a mask only ever turns bytes into + // spaces. + expr := line_text_without_terminator(content, starts, line)[pos..].trim_space() + if expr == '' { + return none + } + if assist_comment_offset(line_text_without_terminator(content, starts, line)) >= 0 { + return none + } + return expr +} + +// assist_word_at returns the identifier starting at byte `pos`, or none when +// none starts there. +fn assist_word_at(text string, pos int) ?string { + mut i := pos + for i < text.len && is_ident_char(text[i]) { + i++ + } + if i == pos { + return none + } + return text[pos..i] +} + +// assist_comment_offset returns the offset of the `//` that starts a comment on +// `text`, or -1 when the line has none. String bodies are skipped, so a `//` in +// a literal is not mistaken for the start of one. +fn assist_comment_offset(text string) int { + mut quote := u8(0) + mut i := 0 + for i < text.len { + c := text[i] + if quote != 0 { + if c == `\\` { + i += 2 + continue + } + if c == quote { + quote = 0 + } + i++ + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + i++ + continue + } + if c == `/` && i + 1 < text.len && text[i + 1] == `/` { + return i + } + i++ + } + return -1 +} + +// assist_is_reassignment reports whether the identifier ending at `end` is +// followed by an assignment: `x = 1`, `x += 1` and `x := 1` all write x, and a +// use that writes the name being inlined would have to be reordered with the +// declaration the edit removes. A `==` compares, and the `<=`, `>=`, `!=`, +// `<<=` and `>>=` that share its operator bytes are told apart by what follows +// the pair. +fn assist_is_reassignment(masked string, end int) bool { + mut k := end + for k < masked.len && masked[k] in [` `, `\t`] { + k++ + } + if k >= masked.len { + return false + } + c := masked[k] + if c == `=` { + return !(k + 1 < masked.len && masked[k + 1] == `=`) + } + if c == `:` { + return k + 1 < masked.len && masked[k + 1] == `=` + } + if c == `<` || c == `>` { + return k + 1 < masked.len && masked[k + 1] == c + } + return c in [`+`, `-`, `*`, `/`, `%`, `&`, `|`, `^`] + && k + 1 < masked.len && masked[k + 1] == `=` +} + +// assist_is_use reports whether the identifier occupying [start, end) is a value +// use of it rather than the same name belonging to something else: a field +// access (`obj.x`), a field initialiser or a label (`x:`), and a declaration of +// a different `x` (`x int` in a struct body or a parameter list) all keep the +// name and are left alone. +fn assist_is_use(masked string, start int, end int) bool { + if start > 0 && (is_ident_char(masked[start - 1]) || masked[start - 1] == `.`) { + return false + } + if end < masked.len { + if is_ident_char(masked[end]) || masked[end] == `:` { + return false + } + mut k := end + for k < masked.len && masked[k] in [` `, `\t`] { + k++ + } + if k < masked.len && is_ident_char(masked[k]) { + return false + } + } + return true +} diff --git a/assists_test.v b/assists_test.v new file mode 100644 index 00000000..2e13f719 --- /dev/null +++ b/assists_test.v @@ -0,0 +1,430 @@ +// Tests for the two refactors in assists.v: extracting a pure expression into a +// variable of its own, and inlining a local that is written once. Every helper +// below is named for them, because all `_test.v` files of this module share one +// namespace and a colliding name would break the build. +module main + +import json2 + +// AssistTestSpan is one edit, resolved to the byte range it applies to. +struct AssistTestSpan { + start int + end int + new_text string +} + +// assists_test_app returns an App with nothing open, which is all the refactors +// read: each one works on the buffer it is handed. +fn assists_test_app() &App { + return &App{ + text: '' + open_files: map[string]string{} + } +} + +// assists_test_position returns where the first `needle` of `content` sits. +fn assists_test_position(content string, needle string) Position { + return assists_test_position_nth(content, needle, 0) +} + +// assists_test_position_nth returns the position of the `nth` occurrence of +// `needle`, counting from zero, so a test can reach the second `math` in a file +// whose first is an import line, or the last `x` in a file that also declares a +// field called `x`. +fn assists_test_position_nth(content string, needle string, nth int) Position { + mut seen := 0 + for line, text in content.split_into_lines() { + mut from := 0 + for { + col := text.index_after(needle, from) or { break } + if seen == nth { + return Position{ + line: line + char: col + } + } + seen++ + from = col + needle.len + } + } + assert false, 'the fixture holds no occurrence ${nth} of ${needle}' + return Position{} +} + +// assists_test_range returns the range covering the first `needle` of `content`, +// what an editor reports for a selected token. +fn assists_test_range(content string, needle string) LSPRange { + return assists_test_range_nth(content, needle, 0) +} + +// assists_test_range_nth returns the range covering the `nth` occurrence of +// `needle`. +fn assists_test_range_nth(content string, needle string, nth int) LSPRange { + at := assists_test_position_nth(content, needle, nth) + return LSPRange{ + start: at + end: Position{ + line: at.line + char: at.char + needle.len + } + } +} + +// assists_test_span returns the range from the start of `from` to the end of +// `to`, what an editor reports when a selection is dragged across both. +fn assists_test_span(content string, from string, to string) LSPRange { + start := assists_test_position(content, from) + end := assists_test_position(content, to) + return LSPRange{ + start: start + end: Position{ + line: end.line + char: end.char + to.len + } + } +} + +// assists_test_cursor returns the empty range an editor reports for a cursor +// sitting on the first `needle` of `content`. +fn assists_test_cursor(content string, needle string) LSPRange { + at := assists_test_position(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// assists_test_cursor_nth returns the empty range of a cursor on the `nth` +// occurrence of `needle`. +fn assists_test_cursor_nth(content string, needle string, nth int) LSPRange { + at := assists_test_position_nth(content, needle, nth) + return LSPRange{ + start: at + end: at + } +} + +// assists_test_offset maps a position back to a byte offset, so a test can +// check an edit against the text it addresses. +fn assists_test_offset(content string, pos Position) int { + return position_to_byte_offset(content, line_start_offsets(content), pos.line, pos.char, + PositionEncoding.utf16) +} + +// assists_test_apply applies the edits an action carries to `content`, from the +// last edit back, so the result is the document a client ends up with once the +// quick fix is taken. +fn assists_test_apply(content string, action CodeAction) string { + edit := action.edit or { + assert false, 'the action must carry an edit' + return content + } + mut spans := []AssistTestSpan{} + for _, edits in edit.changes { + for one in edits { + spans << AssistTestSpan{ + start: assists_test_offset(content, one.range.start) + end: assists_test_offset(content, one.range.end) + new_text: one.new_text + } + } + } + spans.sort_with_compare(fn (a &AssistTestSpan, b &AssistTestSpan) int { + return b.start - a.start + }) + mut out := content + for span in spans { + assert span.start <= span.end, 'an edit must not end before it starts' + assert span.start >= 0 && span.end <= out.len, 'an edit must stay inside the document' + out = out[..span.start] + span.new_text + out[span.end..] + } + return out +} + +// assists_test_action runs a codeAction request over `sel` and returns the +// action titled `title`, or none when the server offers no such action. It goes +// through the real request handler, so a test also pins the registration and the +// kind filter. +fn assists_test_action(mut app App, uri string, content string, sel LSPRange, only []string, title string) ?CodeAction { + app.open_files[uri] = content + resp := app.handle_code_action(Request{ + id: 1 + params: json2.encode(CodeActionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: sel + context: CodeActionContext{ + only: only + } + }, + escape_unicode: true + ) + }) + if resp.result is []CodeAction { + for action in resp.result as []CodeAction { + if action.title == title { + return action + } + } + } + return none +} + +// --- Extract variable --- + +// The commonest case: an expression that reads a local and passes it on. The +// selection becomes a fresh name, and the value it stood for is bound to that +// name in front of the statement that held it. +fn test_extract_variable_lifts_an_index_expression_out_of_a_call() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'items[0]')) or { + assert false, 'an index on a local must offer "Extract variable"' + return + } + assert action.kind == code_action_kind_quickfix, 'the extraction is offered as a quick fix' + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tmut extracted_0 := items[0]\n\tprintln(extracted_0)\n}\n', 'the declaration is written above the statement and the selection is replaced' +} + +// A call chain rooted in a local is pure, and so is the field access that leads +// to it: the method name is not the receiver, `name` is. +fn test_extract_variable_lifts_a_call_chain_on_a_local() { + content := "module main\n\nfn main() {\n\tname := 'med'\n\tprintln(name.len())\n}\n" + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'name.len()')) or { + assert false, 'a call chain on a local must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == "module main\n\nfn main() {\n\tname := 'med'\n\tmut extracted_0 := name.len()\n\tprintln(extracted_0)\n}\n", 'the chain is bound to the new name as it was written' +} + +// A call chain on a parameter of the enclosing function counts as a local: a +// refusal there would refuse the commonest extraction of all. +fn test_extract_variable_accepts_a_chain_on_a_function_parameter() { + content := "module main\n\nfn main() {\n\ttext := 'med'\n\tprintln(trim(text))\n}\n\nfn trim(input string) string {\n\treturn input.len()\n}\n" + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'input.len()')) or { + assert false, 'a chain on a parameter must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == "module main\n\nfn main() {\n\ttext := 'med'\n\tprintln(trim(text))\n}\n\nfn trim(input string) string {\n\tmut extracted_0 := input.len()\n\treturn extracted_0\n}\n", 'the parameter chain is lifted into its own statement' +} + +// A parenthesised expression of a pure form is pure, and a literal always is. +fn test_extract_variable_lifts_a_parenthesized_expression() { + content := 'module main\n\nfn main() {\n\twidth := 8\n\tprintln((width))\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + '(width)')) or { + assert false, 'a parenthesised identifier must offer "Extract variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\twidth := 8\n\tmut extracted_0 := (width)\n\tprintln(extracted_0)\n}\n', 'the parenthesised form is bound as it was written' +} + +// The fresh name skips the bindings the buffer already holds, so the new name +// cannot capture an existing one. +fn test_extract_variable_takes_the_next_free_name() { + content := 'module main\n\nfn main() {\n\textracted_0 := 1\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + action := app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'items[0]')) or { + assert false, 'a buffer already using the name must still offer the extraction' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\textracted_0 := 1\n\titems := [1, 2, 3]\n\tmut extracted_1 := items[0]\n\tprintln(extracted_1)\n}\n', 'the new name is the first one the buffer does not use' +} + +// A selection dragged across two statements holds two expressions and a line +// break: lifting it would rebuild the program it came from. +fn test_extract_variable_refuses_a_selection_that_spans_two_statements() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n\tprintln(items)\n}\n' + mut app := assists_test_app() + sel := assists_test_span(content, 'println(items[0])', 'println(items)') + assert app.build_extract_variable_action('file:///main.v', content, sel) == none, 'a selection spanning two statements must not be lifted' +} + +// A cursor selects nothing, so there is nothing to bind. +fn test_extract_variable_refuses_an_empty_selection() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_cursor(content, + 'items[0]')) == none, 'a cursor must not offer an extraction' +} + +// A call whose receiver is not a local runs code nobody can vouch for, and so +// does an expression built from an operator: neither is one of the pure forms. +fn test_extract_variable_refuses_an_expression_it_cannot_vouch_for() { + content := 'module main\n\nfn main() {\n\ta := 1\n\tb := 2\n\tprintln(a + b)\n\tprintln(C.sqrt(2.0))\n\tprintln(a > b)\n}\n' + mut app := assists_test_app() + for needle in ['println(a + b)', 'C.sqrt(2.0)', 'a > b'] { + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + needle)) == none, '`${needle}` is not a side-effect-free expression' + } +} + +// The left of an assignment is not a value: `mut extracted_0 := value` written +// in front of `value := f(value)` would read it before it is written. +fn test_extract_variable_refuses_the_left_of_an_assignment() { + content := 'module main\n\nfn main() {\n\tvalue := 3\n\tprintln(value)\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'value')) == none, 'the target of a declaration must not be lifted' +} + +// A statement that does not begin its own line cannot have a declaration +// inserted in front of it: the only place to write one is inside the enclosing +// block, which V has no syntax for. +fn test_extract_variable_refuses_a_statement_that_does_not_begin_its_line() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tif items[0] == 1 { println(items[0]) }\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range(content, + 'println(items[0])')) == none, 'a statement opening inside braces must not be lifted' +} + +// The base of a longer selector is a value only when it is a local: `math` names +// a module, and no variable holds one. +fn test_extract_variable_refuses_a_selector_whose_base_is_a_module() { + content := 'module main\n\nimport math\n\nfn main() {\n\tx := math.pi\n\tprintln(x)\n}\n' + mut app := assists_test_app() + assert app.build_extract_variable_action('file:///main.v', content, assists_test_range_nth(content, + 'math', 1)) == none, 'the module half of `math.pi` must not be lifted' +} + +// Through the request handler the action is registered as a quick fix, and a +// client asking only for some other kind is not sent it. +fn test_extract_variable_is_offered_as_a_quickfix_only() { + content := 'module main\n\nfn main() {\n\titems := [1, 2, 3]\n\tprintln(items[0])\n}\n' + mut app := assists_test_app() + uri := 'file:///main.v' + sel := assists_test_range(content, 'items[0]') + assists_test_action(mut app, uri, content, sel, ['quickfix'], 'Extract variable') or { + assert false, 'a client asking for quick fixes must see the extraction' + return + } + assert assists_test_action(mut app, uri, content, sel, ['refactor'], 'Extract variable') == none, 'a client asking only for refactors must not be sent a quick fix' +} + +// --- Inline variable --- + +// A local written once folds back into its uses: the declaration line goes and +// the expression takes its place at every use. +fn test_inline_variable_folds_every_use_of_a_local() { + content := 'module main\n\nfn main() {\n\ttotal := 3\n\tprintln(total)\n\tprintln(total + 1)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'total')) or { + assert false, 'a local written once must offer "Inline variable"' + return + } + assert action.kind == code_action_kind_quickfix, 'the inlining is offered as a quick fix' + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(3)\n\tprintln(3 + 1)\n}\n', 'the declaration line is removed and each use becomes the expression' +} + +// Exactly one use is the smallest case: the same two edits, and no declaration +// left behind. +fn test_inline_variable_replaces_a_single_use() { + content := 'module main\n\nfn main() {\n\tlimit := 10\n\tprintln(limit)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'limit')) or { + assert false, 'a local with one use must offer "Inline variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(10)\n}\n', 'a single use is replaced and no declaration is left' +} + +// A second writing of the name cannot be reordered past the declaration the +// edit removes, so the action is refused. +fn test_inline_variable_refuses_a_later_assignment() { + content := 'module main\n\nfn main() {\n\tcount := 1\n\tcount = 2\n\tprintln(count)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'count')) == none, 'a local that is assigned again must not be inlined' +} + +// A name that was never declared with `:=` is not this refactor's subject. +fn test_inline_variable_refuses_a_name_that_is_never_declared() { + content := 'module main\n\nfn main() {\n\tprintln(missing)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'missing')) == none, 'a name with no declaration must not be inlined' +} + +// `a, b := 1, 2` does not bind `a` alone, so there is no expression to fold in. +fn test_inline_variable_refuses_a_multi_target_declaration() { + content := 'module main\n\nfn main() {\n\ta, b := 1, 2\n\tprintln(a)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'a')) == none, 'a declaration with several targets must not be inlined' +} + +// An expression that mentions the name being removed would keep a reference to a +// binding that no longer exists. +fn test_inline_variable_refuses_an_expression_that_names_itself() { + content := 'module main\n\nfn main() {\n\tn := 2\n\tn := n * 2\n\tprintln(n)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'n')) == none, 'a declaration naming itself must not be inlined' +} + +// The whole line is deleted, so a comment on it would be deleted with it. +fn test_inline_variable_refuses_a_declaration_with_a_trailing_comment() { + content := 'module main\n\nfn main() {\n\tcount := 1 // how many\n\tprintln(count)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'count')) == none, 'a declaration carrying a comment must not be inlined' +} + +// A local nothing uses has nothing to fold into. +fn test_inline_variable_refuses_a_local_nothing_uses() { + content := 'module main\n\nfn main() {\n\tunused := 1\n\tprintln(2)\n}\n' + mut app := assists_test_app() + assert app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'unused')) == none, 'a local with no use must not be inlined' +} + +// The same name elsewhere in the file belongs to something else: a field of +// another object, a field initialiser, and a local of another function all keep +// their name. +fn test_inline_variable_leaves_a_field_and_another_scope_alone() { + content := 'module main\n\nstruct Box {\n\twidth int\n}\n\nfn main() {\n\twidth := 5\n\tbox := Box{width: 1}\n\tprintln(box.width)\n\tprintln(width)\n}\n\nfn other() {\n\tprintln(width)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor_nth(content, + 'width', 4)) or { + assert false, 'a local beside a field of the same name must still be inlined' + return + } + assert assists_test_apply(content, action) == 'module main\n\nstruct Box {\n\twidth int\n}\n\nfn main() {\n\tbox := Box{width: 1}\n\tprintln(box.width)\n\tprintln(5)\n}\n\nfn other() {\n\tprintln(width)\n}\n', 'the field, the initialiser and the other scope keep the name' +} + +// A same-named local in another function is another scope: the block of the +// declaration ends at its closing brace, so a use below that isn't the local's. +fn test_inline_variable_stays_inside_the_block_that_declared_it() { + content := 'module main\n\nfn main() {\n\ttotal := 3\n\tprintln(total)\n}\n\nfn other() {\n\tprintln(total)\n}\n' + mut app := assists_test_app() + action := app.build_inline_variable_action('file:///main.v', content, assists_test_cursor(content, + 'total')) or { + assert false, 'a local must offer "Inline variable"' + return + } + assert assists_test_apply(content, action) == 'module main\n\nfn main() {\n\tprintln(3)\n}\n\nfn other() {\n\tprintln(total)\n}\n', 'a use outside the declaring block is left alone' +} + +// Through the request handler the action is registered as a quick fix, and a +// client asking only for some other kind is not sent it. +fn test_inline_variable_is_offered_as_a_quickfix_only() { + content := 'module main\n\nfn main() {\n\tlimit := 10\n\tprintln(limit)\n}\n' + mut app := assists_test_app() + uri := 'file:///main.v' + sel := assists_test_cursor(content, 'limit') + assists_test_action(mut app, uri, content, sel, ['quickfix'], 'Inline variable') or { + assert false, 'a client asking for quick fixes must see the inlining' + return + } + assert assists_test_action(mut app, uri, content, sel, ['refactor'], 'Inline variable') == none, 'a client asking only for refactors must not be sent a quick fix' +} From 9fea537ac8fd7ca6d5eeb9ef794dd55a5a632c31 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 20:01:39 +0330 Subject: [PATCH 33/83] docs: record Phase 3 and Phase 4 completion --- ROADMAP.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 89c42b5e..566d3a1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,7 +106,7 @@ needs the V1 compatibility compiler, which this host cannot build default on, runtime toggle, no restart. - Richer hover (const values, methods, doc links). -## Phase 3 — refactors (partial) +## Phase 3 — refactors - Safe rename: DONE — refuses names already live in the enclosing scope (locals, parameters, import aliases, outer @@ -115,8 +115,21 @@ needs the V1 compatibility compiler, which this host cannot build - Fill struct literal quickfix: DONE (assists.v) — missing fields added with zero values, existing ones untouched; skipped for unknown types and complete literals. -- Extract function/variable, inline, stub-missing-members: - NOT started. +- Extract variable / inline variable: DONE (assists.v) — both + pure text edits with purity gates; inline requires a + single-assignment local. +- stub-missing-members: NOT started. + +## Phase 4 — build / config UX: DONE + +`defines` reaches the check path (editor settings > project +`vls.json` > `VLS_DEFINES`), so code behind `$if flag` is checked +without starting the editor with `VFLAGS=-d flag`. Defines are +salted into the diagnostics fingerprint so a cached answer never +survives a change of defines. `v fmt`, hover and completion run +without them, as before. `vls.json` is cached per project root, +dropped on a watched change, and drops that project's cached +diagnostics. ## Phase 5 — finish or drop stubs: DONE From c939163f74e518d353cff1e57d6cca0f3e332bbc Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 02:46:30 +0330 Subject: [PATCH 34/83] feat: extract function, add import, implement missing members assists Three code actions from the lightbulb: lift whole statements into a new function with only the reads it needs as parameters, add the import line for a module the file already uses, and stub the missing members of the one interface a struct is for. All are quickfixes, all are offered only when they can be complete and correct. --- extract_function.v | 630 ++++++++++++++++++++++++++++++++++++++++ extract_function_test.v | 177 +++++++++++ handlers.v | 12 + 3 files changed, 819 insertions(+) create mode 100644 extract_function.v create mode 100644 extract_function_test.v diff --git a/extract_function.v b/extract_function.v new file mode 100644 index 00000000..124e1510 --- /dev/null +++ b/extract_function.v @@ -0,0 +1,630 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Extract function: lift whole statements out of a function into a new one. +// +// The refactor is textual, over the same masked buffer the fill-struct and +// extract-variable assists use: comments and string bodies are blanked, so no +// scan steps on a brace or an identifier inside them, and every offset it +// reports addresses the original bytes. It knows nothing about types beyond +// what a declaration line in the file states, and it refuses rather than +// guessing — a wrong parameter list is worse than no action. + +// ByteRange is a half-open byte range of the buffer. +struct ByteRange { + start int + end int +} + +// ExtractFnFrame is the function that encloses a selection: where its +// declaration starts, the indentation of its body, and the names it declares +// with the types their declarations state. +struct ExtractFnFrame { + decl_line int + body_indent string + params map[string]string + locals map[string]string +} + +// extract_fn_frame returns the function whose body holds the byte `offset`, +// or none when the offset is outside every function body. Braces are walked +// over the masked buffer, so a brace inside a string never shifts the depth. +fn (app &App) extract_fn_frame(content string, masked string, starts []int, offset int) ?ExtractFnFrame { + mut stack := []int{} + mut line := 0 + for i := 0; i < masked.len && i < offset; i++ { + if masked[i] == `\n` { + line++ + continue + } + if masked[i] == `{` { + stack << i + } else if masked[i] == `}` { + if stack.len > 0 { + stack.pop() + } + } + } + if stack.len == 0 { + return none + } + open_off := stack.last() + open_line := byte_to_line_number(starts, open_off) + open_text := line_text_of(content, starts, open_line).trim_space() + decl_line := if open_text.starts_with('fn ') { + open_line + } else { + above := declaration_line_above(content, starts, open_line) or { return none } + above + } + sel_line := byte_to_line_number(starts, offset) + if sel_line == decl_line { + return none + } + return app.parse_extract_fn_frame(content, decl_line) +} + +// parse_extract_fn_frame reads the declaration line's parameters and the +// locals its body declares, the only types this refactor trusts. +fn (app &App) parse_extract_fn_frame(content string, decl_line int) ?ExtractFnFrame { + lines := content.split_into_lines() + signature := lines[decl_line] + mut params := map[string]string{} + popen := signature.index('(') or { -1 } + if popen >= 0 { + params = extract_signature_params(signature[popen + 1..]) + } + return ExtractFnFrame{ + decl_line: decl_line + body_indent: line_indent(signature) + '\t' + params: params + locals: extract_fn_locals(lines, decl_line) + } +} + +// extract_signature_params reads `mut name Type, other bool` into a map of +// name to type text. A parameter without a type is recorded with an empty +// type, which refuses the refactor later. +fn extract_signature_params(text string) map[string]string { + mut params := map[string]string{} + mut depth := 0 + mut start := 0 + bytes := text.bytes() + for idx := 0; idx < bytes.len; idx++ { + c := bytes[idx] + if c == `(` || c == `[` || c == `{` { + depth++ + } else if c == `)` { + if depth == 0 { + insert_param(mut params, text[start..idx]) + break + } + depth-- + } else if c == `]` || c == `}` { + depth-- + } else if c == `,` && depth == 0 { + insert_param(mut params, text[start..idx]) + start = idx + 1 + } + } + return params +} + +// insert_param records one parameter of the form `mut name Type` or `name`. +fn insert_param(mut params map[string]string, text string) { + words := text.all_before('//').trim_space().fields() + if words.len == 0 { + return + } + mut idx := 0 + if words[0] in ['mut', 'shared', 'static'] { + idx = 1 + } + if idx >= words.len { + return + } + params[words[idx]] = words[idx + 1..].join(' ') +} + +// extract_fn_locals collects the locals a function body declares, with the +// type the declaration states. Only the forms whose type is written down are +// kept; a name this cannot type is absent, which refuses the refactor. +fn extract_fn_locals(lines []string, decl_line int) map[string]string { + mut locals := map[string]string{} + decl_indent := line_indent(lines[decl_line]) + for idx := decl_line + 1; idx < lines.len; idx++ { + text := lines[idx] + trimmed := text.trim_space() + if trimmed.starts_with('}') && line_indent(text) == decl_indent { + break + } + if trimmed == '' { + continue + } + // `name := expr` and `mut name := expr` + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if is_plain_identifier(lhs) { + ltype := extract_inferred_type(trimmed[op + 4..].trim_space()) + if ltype != '' { + locals[lhs] = ltype + } + } + continue + } + // `mut name Type` and `mut name = expr` + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 { + lhs := rest[..space] + tail := rest[space + 1..].trim_space() + if is_plain_identifier(lhs) { + if tail.starts_with('=') { + stype := extract_inferred_type(tail[1..].trim_space()) + if stype != '' { + locals[lhs] = stype + } + } else if is_type_text(tail) { + locals[lhs] = tail + } + } + } + } + } + return locals +} + +// extract_inferred_type names the type of a simple right-hand side, or '' when +// the type is not written on the line. +fn extract_inferred_type(rhs string) string { + r := rhs.trim_space() + if r == '' { + return '' + } + first := r[0] + if first == `'` || first == `"` || first == `$` { + return 'string' + } + if r.starts_with('r\'') || r.starts_with('r\"') { + return 'string' + } + if r == 'true' || r == 'false' { + return 'bool' + } + if r.starts_with('[]') && r.contains('{') { + return r.all_before('{').trim_space() + } + if r.starts_with('%{') { + return 'string' + } + digits := r.replace('.', '') + if decimal_text_is_valid(r) { + return 'int' + } + if r.contains('.') && decimal_text_is_valid(digits) { + return 'f64' + } + return '' +} + +// is_type_text reports whether `text` reads as a type expression: words, +// brackets and dots, with no call or operator in it. +fn is_type_text(text string) bool { + t := text.trim_space() + if t == '' { + return false + } + for c in t { + if is_ident_char(c) || c == ` ` || c == `[` || c == `]` || c == `.` || c == `&` { + continue + } + return false + } + return is_ident_char(t[0]) +} + +// is_plain_identifier reports whether `name` is a single V identifier. +fn is_plain_identifier(name string) bool { + if name == '' { + return false + } + if !is_ident_char(name[0]) || (name[0] >= `0` && name[0] <= `9`) { + return false + } + for c in name { + if !is_ident_char(c) { + return false + } + } + return true +} + +// line_text_of returns one line of `content`. +fn line_text_of(content string, starts []int, line int) string { + if line < 0 || line >= starts.len { + return '' + } + from := starts[line] + to := if line + 1 < starts.len { starts[line + 1] } else { content.len } + return content[from..to] +} + +// declaration_line_above finds the `fn` declaration line directly above the +// body-opening line. +fn declaration_line_above(content string, starts []int, open_line int) ?int { + for idx := open_line - 1; idx >= 0 && idx >= open_line - 2; idx-- { + if line_text_of(content, starts, idx).trim_space().starts_with('fn ') { + return idx + } + } + return none +} + +// byte_to_line_number maps a byte offset to its line. +fn byte_to_line_number(starts []int, offset int) int { + mut lo := 0 + mut hi := starts.len - 1 + if hi < 0 { + return 0 + } + for lo < hi { + mid := (lo + hi + 1) / 2 + if starts[mid] <= offset { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +// extract_identifiers returns every identifier in masked[start:end), as byte +// ranges of the original buffer. A number is not an identifier, even though +// its digits are identifier characters. +fn extract_identifiers(masked string, start int, end int) []ByteRange { + mut out := []ByteRange{} + mut i := start + for i < end { + if !is_ident_char(masked[i]) { + i++ + continue + } + s := i + for i < end && is_ident_char(masked[i]) { + i++ + } + if masked[s] >= `0` && masked[s] <= `9` { + continue + } + out << ByteRange{ + start: s + end: i + } + } + return out +} + +// extract_prev_nonspace returns the last non-space byte at or before `i`. +fn extract_prev_nonspace(s string, i int) u8 { + mut j := i + for j >= 0 { + if s[j] != ` ` && s[j] != `\t` { + return s[j] + } + j-- + } + return u8(0) +} + +// extract_next_nonspace returns the first non-space byte at or after `i`. +fn extract_next_nonspace(s string, i int) u8 { + for j := i; j < s.len; j++ { + if s[j] != ` ` && s[j] != `\t` { + return s[j] + } + } + return u8(0) +} + +// extract_is_assignment reports whether the identifier ending at `end` is the +// target of an assignment rather than a comparison or a compound operator. +fn extract_is_assignment(masked string, end int) bool { + mut j := end + for j < masked.len && (masked[j] == ` ` || masked[j] == `\t`) { + j++ + } + if j >= masked.len || masked[j] != `=` { + return false + } + if j + 1 < masked.len && masked[j + 1] == `=` { + return false + } + if j > 0 && (masked[j - 1] == `=` || masked[j - 1] == `!` || masked[j - 1] == `<` + || masked[j - 1] == `>` || masked[j - 1] == `+` || masked[j - 1] == `-` + || masked[j - 1] == `*` || masked[j - 1] == `/` || masked[j - 1] == `%`) { + return false + } + return true +} + +// build_extract_function_action is the "Extract function" quick fix for the +// statements the range covers. It returns none unless the selection is one or +// more whole statements inside one function body, every name the new function +// needs has a type written in the file, at most one enclosing local is +// reassigned (which becomes the return value), and the selection holds no +// `return` of the enclosing function. +fn (mut app App) build_extract_function_action(uri string, content string, sel_range LSPRange) ?CodeAction { + // `uri` keeps the signature the other assists share; the range and the + // authoritative buffer passed in are what decide. + _ = uri + masked := masked_v_code(content) + starts := line_start_offsets(content) + byte_start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut byte_end := byte_start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + byte_end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if byte_end <= byte_start || byte_start < 0 || byte_end > content.len { + return none + } + sel_start_line := byte_to_line_number(starts, byte_start) + sel_end_line := byte_to_line_number(starts, byte_end) + if sel_end_line < sel_start_line { + return none + } + lines := content.split_into_lines() + // Whole lines only: nothing but indentation before the selection on its + // first line, nothing but whitespace after it on the last one. + head := line_text_of(content, starts, sel_start_line) + head_text := head[..byte_start - starts[sel_start_line]] + if head_text.trim_space() != '' { + return none + } + tail := line_text_of(content, starts, sel_end_line) + tail_text := tail[byte_end - starts[sel_end_line]..] + if tail_text.trim_space() != '' { + return none + } + for cand_idx in sel_start_line .. sel_end_line + 1 { + trimmed := lines[cand_idx].trim_space() + if trimmed == '' { + continue + } + if trimmed.starts_with('}') || trimmed.starts_with('else') || trimmed.starts_with('case ') + || trimmed.starts_with('default:') { + return none + } + } + frame := app.extract_fn_frame(content, masked, starts, byte_start)? + if frame.decl_line >= sel_start_line { + return none + } + if sel_end_line > extract_body_end_line(masked, starts, byte_start, byte_end) { + return none + } + ids := extract_identifiers(masked, byte_start, byte_end) + declared_inside, assigned_inside := extract_bindings_in_selection(content, lines, starts, ids) + mut params := map[string]string{} + mut param_order := []string{} + mut assigned_outside := []string{} + for id in ids { + name := content[id.start..id.end] + if name in declared_inside { + continue + } + prev := extract_prev_nonspace(masked, id.start - 1) + next := extract_next_nonspace(masked, id.end) + if prev == `.` { + continue + } + in_frame := name in frame.params || name in frame.locals + if !in_frame && (next == `(` || next == `[`) { + // A call or an index on a name this file does not declare: a + // function or type of the module, visible to both. + continue + } + if name in frame.params { + if name !in params { + params[name] = frame.params[name] + param_order << name + } + continue + } + if name in frame.locals { + // A local the selection reassigns is a return value, not an input: + // passing it in and returning it would say the same thing twice. + if name in assigned_inside && name !in assigned_outside { + assigned_outside << name + } else if name !in params { + params[name] = frame.locals[name] + param_order << name + } + continue + } + // A name the file does not declare with a type: nothing trustworthy + // to pass. + return none + } + for _, ptype in params { + if ptype == '' { + return none + } + } + // A parameter of the enclosing function reassigned inside the selection + // cannot be lifted: the caller would keep its own value. + for name, _ in assigned_inside { + if name in frame.params { + return none + } + } + if assigned_outside.len > 1 { + return none + } + mut return_type := '' + if assigned_outside.len == 1 { + name := assigned_outside[0] + return_type = frame.locals[name] + if return_type == '' { + return none + } + } + // A `return` that targets the enclosing function cannot be lifted. + for id in ids { + if content[id.start..id.end] == 'return' { + return none + } + } + fn_name := fresh_extracted_fn_name(content) + mut signature := []string{} + for name in param_order { + signature << '${name} ${params[name]}' + } + body := extract_selected_lines(content, starts, sel_start_line, sel_end_line, frame.body_indent) + mut fn_text := 'fn ${fn_name}(' + fn_text += signature.join(', ') + fn_text += ')' + if return_type != '' { + fn_text += ' ${return_type}' + } + fn_text += ' {\n' + fn_text += body + if return_type != '' { + fn_text += '${frame.body_indent}return ${assigned_outside[0]}\n' + } + fn_text += '}\n\n' + call := '${fn_name}(' + param_order.join(', ') + ')' + return CodeAction{ + title: 'Extract function' + kind: code_action_kind_quickfix + is_preferred: true + edit: WorkspaceEdit{ + changes: { + '': [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: frame.decl_line + char: 0 + } + end: Position{ + line: frame.decl_line + char: 0 + } + } + new_text: fn_text + }, + TextEdit{ + range: LSPRange{ + start: encoded_position(content, starts, byte_start, app.position_encoding) + end: encoded_position(content, starts, byte_end, app.position_encoding) + } + new_text: call + }, + ] + } + } + } +} + +// extract_bindings_in_selection returns the names the selection declares +// itself and the names it assigns, both found on declaration lines rather +// than from a scope analysis. +fn extract_bindings_in_selection(content string, lines []string, starts []int, ids []ByteRange) (map[string]bool, map[string]bool) { + mut declared := map[string]bool{} + mut assigned := map[string]bool{} + for id in ids { + name := content[id.start..id.end] + line_idx := byte_to_line_number(starts, id.start) + text := lines[line_idx] + trimmed := text.trim_space() + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if lhs == name { + declared[name] = true + continue + } + } + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 && rest[..space] == name { + declared[name] = true + continue + } + } + if extract_is_assignment(content, id.end) { + assigned[name] = true + } + } + return declared, assigned +} + +// extract_body_end_line returns the line the enclosing body closes on, so a +// selection reaching past it is refused. +fn extract_body_end_line(masked string, starts []int, byte_start int, byte_end int) int { + mut line := byte_to_line_number(starts, byte_start) + mut depth := 0 + for i := byte_start; i < masked.len && i <= byte_end; i++ { + if masked[i] == `{` { + depth++ + } else if masked[i] == `}` { + if depth == 0 { + return line + } + depth-- + } else if masked[i] == `\n` { + line++ + } + } + return line +} + +// extract_selected_lines returns the selected statements, indented one level +// under `indent`. +fn extract_selected_lines(content string, starts []int, from_line int, to_line int, indent string) string { + mut out := []string{} + for line_idx in from_line .. to_line + 1 { + text := line_text_of(content, starts, line_idx) + if text.trim_space() == '' { + continue + } + own := line_indent(text) + stripped := text[own.len..] + mut line := '${indent}${stripped}' + if !line.ends_with('\n') { + line += '\n' + } + out << line + } + return out.join('') +} + +// fresh_extracted_fn_name returns the first `extracted_N` the file does not +// use. +fn fresh_extracted_fn_name(content string) string { + for i in 0 .. 10000 { + name := 'extracted_${i}' + if !content.contains('fn ${name}(') { + return name + } + } + return 'extracted' +} + +// encoded_position converts a byte offset into an LSP position in the +// negotiated encoding. +fn encoded_position(content string, starts []int, offset int, enc PositionEncoding) Position { + line := byte_to_line_number(starts, offset) + line_start := starts[line] + column := byte_to_encoded_col(content[line_start..offset], offset - line_start, enc) + return Position{ + line: line + char: column + } +} diff --git a/extract_function_test.v b/extract_function_test.v new file mode 100644 index 00000000..23c23d37 --- /dev/null +++ b/extract_function_test.v @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// Tests for the "Extract function" quiet fix. Every _test.v file of this +// project shares module main, so the helpers here carry the xtr_ prefix. + +fn xtr_dir(tag string) string { + dir := os.join_path(os.temp_dir(), 'vls_xtr_${tag}_${os.getpid()}') + os.mkdir_all(dir) or { assert false, 'mkdir failed: ${err}' } + return dir +} + +// xtr_app opens `content` in its own project directory. +fn xtr_app(tag string, content string) (&App, string) { + dir := xtr_dir(tag) + uri := path_to_uri(os.join_path(dir, 'main.v')) + os.write_file(uri_to_path(uri), content) or { assert false, 'write failed: ${err}' } + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// xtr_range returns a range covering whole lines [from, to]. +fn xtr_range(content string, from int, to int) LSPRange { + lines := content.split_into_lines() + mut last := 0 + for idx in 0 .. to + 1 { + if idx < lines.len { + last = lines[idx].len + } + } + return LSPRange{ + start: Position{ + line: from + char: 0 + } + end: Position{ + line: to + char: last + } + } +} + +// xtr_texts returns the new_text of the action's edits, in order. +fn xtr_texts(action CodeAction) []string { + mut texts := []string{} + edit := action.edit or { return texts } + for change in edit.changes[''] or { []TextEdit{} } { + texts << change.new_text + } + return texts +} + +// xtr_run builds the action for whole lines [from, to] and returns its texts. +fn xtr_run(tag string, content string, from int, to int) []string { + mut app, uri := xtr_app(tag, content) + action := app.build_extract_function_action(uri, content, xtr_range(content, from, + to)) or { return []string{} } + return xtr_texts(action) +} + +fn xtr_offered(tag string, content string, sel_range LSPRange) bool { + mut app, uri := xtr_app(tag, content) + return app.build_extract_function_action(uri, content, sel_range) != none +} + +const xtr_simple_main = 'module main\n\nfn compute(a int, b int) int {\n\ttwice := a * 2\n\tprintln(b)\n\treturn twice\n}\n\nfn main() {\n\tprintln(compute(2, 3))\n}\n' + +fn test_extract_function_lifts_statements_and_passes_reads() { + // Lines 3 and 4 (`twice := ...`, `println(b)`) become a function that + // takes `a` and `b`, and the selection is replaced by its call. + texts := xtr_run('happy', xtr_simple_main, 3, 4) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(a int, b int) {'), texts[0] + assert texts[0].contains('twice := a * 2'), texts[0] + assert texts[0].contains('println(b)'), texts[0] + assert texts[1] == 'extracted_0(a, b)', texts[1] +} + +fn test_extract_function_returns_the_single_assigned_local() { + // `total` is a local of the enclosing function that the selection + // reassigns, so it becomes the return value and the call site keeps its + // assignment shape. + content := 'module main\n\nfn run(input int) int {\n\tmut total := 0\n\tdouble(input)\n\ttotal = input * 2\n\treturn total\n}\n\nfn double(v int) {\n\tprintln(v)\n}\n' + texts := xtr_run('ret', content, 5, 5) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(input int) int {'), texts[0] + assert texts[0].contains('return total'), texts[0] + assert texts[1] == 'extracted_0(input)', texts[1] +} + +fn test_extract_function_refuses_two_assigned_locals() { + content := 'module main\n\nfn run(input int) {\n\tmut a := 0\n\tmut b := 0\n\ta = input\n\tb = input\n\tprintln(a, b)\n}\n' + mut app, uri := xtr_app('two', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 5)) == none, + 'two reassigned locals must not become two return values' +} + +fn test_extract_function_refuses_a_return_of_the_enclosing_function() { + content := 'module main\n\nfn run(a int) {\n\tif a > 0 {\n\t\treturn\n\t}\n\tprintln(a)\n}\n' + mut app, uri := xtr_app('ret2', content) + // Selecting the `return` itself: lifting it would change where the + // function ends. + assert app.build_extract_function_action(uri, content, xtr_range(content, 3, 3)) == none +} + +fn test_extract_function_refuses_a_partial_line() { + partial := LSPRange{ + start: Position{ + line: 3 + char: 2 + } + end: Position{ + line: 3 + char: 6 + } + } + assert !xtr_offered('partial', xtr_simple_main, partial) +} + +fn test_extract_function_refuses_a_selection_across_blocks() { + // From a statement inside the body to the closing brace of the fn. + mut app, uri := xtr_app('blocks', xtr_simple_main) + assert app.build_extract_function_action(uri, xtr_simple_main, xtr_range(xtr_simple_main, + 3, 5)) == none +} + +fn test_extract_function_refuses_an_untyped_name() { + // `value` is a local of the enclosing function whose declaration gives no + // type, so there is nothing trustworthy to pass. The declaration itself + // (line 3) is not selected: a name the selection declares needs no + // parameter. + content := 'module main\n\nfn run() {\n\tvalue := helper()\n\tprintln(value)\n}\n\nfn helper() {}\n' + mut app, uri := xtr_app('untyped', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 4)) == none +} + +fn test_extract_function_refuses_an_empty_selection() { + empty := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 0 + } + } + assert !xtr_offered('empty', xtr_simple_main, empty) +} + +fn test_extract_function_refuses_outside_a_function() { + content := 'module main\n\nstruct Point {\n\tx int\n}\n' + mut app, uri := xtr_app('outside', content) + assert app.build_extract_function_action(uri, content, xtr_range(content, 2, 3)) == none +} + +fn test_extract_function_numbers_the_new_name() { + content := 'module main\n\nfn extracted_0() {}\n\nfn run(a int) {\n\tprintln(a)\n}\n' + texts := xtr_run('numbered', content, 5, 5) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_1('), texts[0] +} + +fn test_extract_function_skips_braces_and_comments() { + // A brace inside a comment must not shift the enclosing depth scan. + content := "module main\n\nfn run(a int) {\n\t// if a { } else { }\n\tprintln(a)\n}\n" + texts := xtr_run('braces', content, 4, 4) + assert texts.len == 2, texts.str() + assert texts[0].starts_with('fn extracted_0(a int) {'), texts[0] +} diff --git a/handlers.v b/handlers.v index 0e423541..90da2276 100644 --- a/handlers.v +++ b/handlers.v @@ -7949,6 +7949,9 @@ fn (mut app App) handle_code_action(request Request) Response { // 4. Extract variable — lift a pure expression into a variable of its own. // 5. Inline variable — fold a local that is written once back into its uses. + // 6. Extract function — lift whole statements into a function of their own. + // 7. Add import — the inverse of removing one, for a module the file uses. + // 8. Implement missing members — stub the interface this struct is for. if code_action_kind_wanted(only, code_action_kind_quickfix) { if action := app.build_extract_variable_action(uri, content, params.range) { actions << action @@ -7956,6 +7959,15 @@ fn (mut app App) handle_code_action(request Request) Response { if action := app.build_inline_variable_action(uri, content, params.range) { actions << action } + if action := app.build_extract_function_action(uri, content, params.range) { + actions << action + } + if action := app.build_add_import_action(uri, content, params.range) { + actions << action + } + if action := app.build_implement_members_action(uri, content, params.range) { + actions << action + } } return Response{ From e4e3fdbf587f144d28fff1097476b3bd32926134 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 02:49:19 +0330 Subject: [PATCH 35/83] feat: extract function, add import, implement missing members assists Three code actions from the lightbulb: lift whole statements into a new function with only the reads it needs as parameters, add the import line for a module the file already uses, and stub the missing members of the one interface a struct is for. All are quickfixes, all are offered only when they can be complete and correct. --- add_import.v | 201 ++++++++++++++++ add_import_test.v | 262 +++++++++++++++++++++ extract_function.v | 14 +- extract_function_test.v | 7 +- implement_members.v | 470 ++++++++++++++++++++++++++++++++++++++ implement_members_test.v | 479 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 1422 insertions(+), 11 deletions(-) create mode 100644 add_import.v create mode 100644 add_import_test.v create mode 100644 implement_members.v create mode 100644 implement_members_test.v diff --git a/add_import.v b/add_import.v new file mode 100644 index 00000000..60d4837d --- /dev/null +++ b/add_import.v @@ -0,0 +1,201 @@ +module main + +// The "Add import" quick fix, the inverse of "Remove unknown import": a buffer +// that writes `os.join_path(...)` without an `import os` is one line short, and +// the lightbulb over the name offers to write it. Everything here reads the +// buffer it is handed and the modules its file can import, so it works on an +// unsaved edit exactly as it does on the file on disk. + +// build_add_import_action returns the quick fix that imports the module the +// identifier under `rng` is used as, or none when there is nothing to import: +// the name is a keyword or a builtin, the file already imports that module, the +// module is not one this file can import, the buffer never writes the name as a +// module prefix, or the file declares the name itself. +fn (mut app App) build_add_import_action(uri string, content string, rng LSPRange) ?CodeAction { + if content == '' { + return none + } + masked := masked_v_code(content) + starts := line_start_offsets(content) + at := position_to_byte_offset(content, starts, rng.start.line, rng.start.char, + app.position_encoding) + name := aimp_module_name_at(masked, at) or { return none } + if name in v_keywords || name in v_builtins || name in v_builtin_types { + return none + } + for binding in parse_import_bindings(content) { + if binding.module_path == name || binding.alias == name { + return none + } + } + offered := app.aimp_offered_module(uri_to_path(uri), name) or { return none } + if !aimp_used_as_module_prefix(masked, name) { + return none + } + if app.aimp_is_declared_name(uri, content, name) { + return none + } + return CodeAction{ + title: 'Add import `${name}`' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [import_spot(content).edit(offered.path)] + } + } + } +} + +// aimp_module_name_at returns the module name a new import should name for the +// identifier the cursor sits on: the qualifier of `name.member`, the callee of a +// bare `name(`, or — with the cursor on the member of a qualified expression, +// `join` of `os.join` — the qualifier the member is written after. A bare +// identifier that is neither followed by a `.` nor called is not a use of a +// module, so it returns none. The member comes first, because a method of a +// module is a bare call too, and it is the module it is called on that the +// import has to name. +fn aimp_module_name_at(masked string, at int) ?string { + if at < 0 || at >= masked.len || !is_ident_char(masked[at]) { + return none + } + mut end := at + for end < masked.len && is_ident_char(masked[end]) { + end++ + } + mut start := at + for start > 0 && is_ident_char(masked[start - 1]) { + start-- + } + if start == end { + return none + } + word := masked[start..end] + if !is_valid_v_identifier_name(word) { + return none + } + if masked[end..].starts_with('.') { + return word + } + if masked[..start].trim_right(' \t').ends_with('.') { + return aimp_qualifier_before(masked, start) + } + if masked[end..].starts_with('(') { + return word + } + return none +} + +// aimp_qualifier_before returns the identifier the selector chain ending at the +// `.` in front of `start` begins with: `os` for `os.join`, and the base of a +// longer chain, `a` for the `.` in front of `c` of `a.b.c`, since only the base +// can be the module. +fn aimp_qualifier_before(masked string, start int) ?string { + mut dot := start - 1 + for { + if dot < 0 || masked[dot] != `.` { + return none + } + mut begin := dot + for begin > 0 && is_ident_char(masked[begin - 1]) { + begin-- + } + if begin == dot { + return none + } + word := masked[begin..dot] + if begin == 0 || masked[begin - 1] != `.` { + return word + } + dot = begin - 1 + } +} + +// aimp_used_as_module_prefix reports whether the buffer writes `name` as the +// qualifier of a member (`name.field`) or as the callee of a bare call +// (`name(...)`), which is what a use of a module looks like. A bare mention of +// the name — in a declaration, or as a value of its own — is not, and neither +// is a member of something else, `other.name.field`: only the base of a chain +// can name a module. +fn aimp_used_as_module_prefix(masked string, name string) bool { + mut from := 0 + for { + at := masked.index_after(name, from) or { return false } + from = at + name.len + if at > 0 && (is_ident_char(masked[at - 1]) || masked[at - 1] == `.`) { + continue + } + if from < masked.len && is_ident_char(masked[from]) { + continue + } + if masked[from..].starts_with('.') || masked[from..].starts_with('(') { + return true + } + } +} + +// aimp_offered_module returns the module the file at `file_path` can import that +// its code reaches as `name`, preferring the one whose import path is the name +// itself when two modules share it. Only an `offered` module is returned, so +// vlib's `builtin`, which every file has anyway, the internals of a vlib module +// and a module V refuses to build a program with are left out. +fn (mut app App) aimp_offered_module(file_path string, name string) ?ImportableModule { + mut chosen := ImportableModule{} + mut found := false + for m in app.importable_modules(file_path) { + if !m.offered || m.name != name { + continue + } + if m.path == name { + return m + } + if !found { + chosen = m + found = true + } + } + if found { + return chosen + } + return none +} + +// aimp_is_declared_name reports whether `name` is already a name of the file: +// one its index entry declares, or a local, a parameter or a loop variable its +// code binds. A module is shadowed by any of them, so the import would name +// something the file never uses. +fn (mut app App) aimp_is_declared_name(uri string, content string, name string) bool { + app.reindex_uri(uri) + if aimp_entry_declares(app.index_doc_symbols(uri), name) { + return true + } + for line in source_code_lines(content) { + if line_declares_top_level(line, name) { + return true + } + if line_declares_local(line, name, aimp_is_signature_line(line)) { + return true + } + } + return false +} + +// aimp_entry_declares reports whether the symbols of an index entry include +// `name`, at the top level of the file or as a member of a struct or an enum. +fn aimp_entry_declares(symbols []DocumentSymbol, name string) bool { + for symbol in symbols { + if symbol.name == name || symbol.name.all_before('[') == name { + return true + } + if aimp_entry_declares(symbol.children, name) { + return true + } + } + return false +} + +// aimp_is_signature_line reports whether `line` is the signature of a function, +// whose parameters it declares between its parentheses. +fn aimp_is_signature_line(line string) bool { + rest := line.trim_left(' \t') + return rest.starts_with('fn ') || rest.starts_with('pub fn ') +} diff --git a/add_import_test.v b/add_import_test.v new file mode 100644 index 00000000..0abff4cb --- /dev/null +++ b/add_import_test.v @@ -0,0 +1,262 @@ +module main + +import os + +// Tests for the "Add import" quick fix of add_import.v: which name it offers an +// import for, where it writes the line, and the cases it refuses. Every helper +// below is named for these tests, because the files of this module share one +// namespace and a colliding name would break the build. + +// aimp_mkdir_all creates `path` and its parents. +fn aimp_mkdir_all(path string) { + os.mkdir_all(path) or { + assert false, 'Failed to create directory ${path}: ${err}' + return + } +} + +// aimp_write_file writes `content` to `path`, creating its directory. +fn aimp_write_file(path string, content string) { + aimp_mkdir_all(os.dir(path)) + os.write_file(path, content) or { + assert false, 'Failed to write file ${path}: ${err}' + return + } +} + +// aimp_app returns an App holding nothing open, with a temporary directory of +// its own for the projects the tests write. The modules a file can import are +// read from disk, so every project is written before it is asked about. +fn aimp_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_aimp_${os.getpid()}') + aimp_mkdir_all(temp_dir) + return &App{ + text: '' + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +// aimp_project writes the project of a test below `root`: the buffer at +// `main.v`, and one folder per entry of `modules`, whose source is the file that +// declares the module. It returns the path of the buffer and its document URI. +fn aimp_project(root string, modules map[string]string, main_content string) (string, string) { + aimp_mkdir_all(root) + for name, source in modules { + dir := os.join_path(root, name) + aimp_mkdir_all(dir) + aimp_write_file(os.join_path(dir, '${name}.v'), source) + } + main_path := os.join_path(root, 'main.v') + aimp_write_file(main_path, main_content) + return main_path, path_to_uri(main_path) +} + +// aimp_cursor returns the empty range of a cursor on the `nth` occurrence of +// `needle`, counting from zero, which is what an editor reports for a caret. +fn aimp_cursor(content string, needle string, nth int) LSPRange { + mut seen := 0 + for line, text in content.split_into_lines() { + mut from := 0 + for { + col := text.index_after(needle, from) or { break } + if seen == nth { + return LSPRange{ + start: Position{ + line: line + char: col + } + end: Position{ + line: line + char: col + } + } + } + seen++ + from = col + needle.len + } + } + assert false, 'the fixture holds no occurrence ${nth} of ${needle}' + return LSPRange{} +} + +// aimp_action returns the action the quick fix offers for the buffer at `uri` +// with the cursor at `sel`, and fails the test when it offers none. +fn aimp_action(mut app App, uri string, content string, sel LSPRange) CodeAction { + app.open_files[uri] = content + return app.build_add_import_action(uri, content, sel) or { + assert false, 'the quick fix must offer an action' + return CodeAction{} + } +} + +// aimp_refuses reports whether the quick fix offers nothing for the buffer at +// `uri` with the cursor at `sel`. +fn aimp_refuses(mut app App, uri string, content string, sel LSPRange) bool { + app.open_files[uri] = content + return app.build_add_import_action(uri, content, sel) == none +} + +// aimp_edit returns the single edit the action carries, and fails the test when +// it carries none or more than one: a new import line is one insert. +fn aimp_edit(action CodeAction) TextEdit { + edits := action.edit or { + assert false, 'the action must carry an edit' + return TextEdit{} + } + mut all := []TextEdit{} + for _, uri_edits in edits.changes { + all << uri_edits + } + assert all.len == 1, 'one edit writes one import line' + return all[0] +} + +// aimp_inserted applies the insert of `action` to `content`, which is the buffer +// a client ends up with once the quick fix is taken. +fn aimp_inserted(content string, action CodeAction) string { + edit := aimp_edit(action) + assert edit.range.start == edit.range.end, 'an insert is an empty range' + assert edit.range.start.char == 0, 'an import line starts at column 0' + starts := line_start_offsets(content) + at := if edit.range.start.line < starts.len { + starts[edit.range.start.line] + } else { + content.len + } + return content[..at] + edit.new_text + content[at..] +} + +// The commonest case: a buffer that writes `os.join_path(...)` and has no +// `import os`. The action is offered over the qualifier, and the line goes in +// its own paragraph after the module line. +fn test_add_import_offers_the_module_of_a_qualified_use() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_qualified') + content := "module main\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'os.', 0)) + assert action.title == 'Add import `os`', 'the title names the module' + assert action.kind == code_action_kind_quickfix, 'the action is a quick fix' + edit := aimp_edit(action) + assert edit.range.start.line == 1, 'the import goes after the module line' + assert edit.range.start.char == 0, 'the import line starts at column 0' + assert edit.new_text == '\nimport os\n', 'the import line is written' + assert aimp_inserted(content, action) == "module main\n\nimport os\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n", 'the buffer holds the import once the fix is taken' +} + +// A module the file already imports is not offered again: the line to add is +// already there. +fn test_add_import_refuses_a_module_the_file_already_imports() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_imported') + content := "module main\n\nimport os\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'os.', 0)), 'a module the file imports is not offered' +} + +// A local that shadows the module is what the file uses at the name, so an +// import would name something the buffer never reaches. The same project is +// offered the import in test_add_import_offers_a_module_of_the_project, without +// the local. +fn test_add_import_refuses_a_local_that_shadows_the_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_shadow') + modules := { + 'aimplib': "module aimplib\n\npub fn make() string {\n\treturn ''\n}\n" + } + content := "module main\n\nfn main() {\n\taimplib := ''\n\tprintln(aimplib.make())\n}\n" + _, uri := aimp_project(root, modules, content) + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'aimplib.', 0)), 'a module a local shadows is not offered' +} + +// The control for the local above: the same buffer without it does offer the +// module of the project, so the local was the reason it was refused. +fn test_add_import_offers_a_module_of_the_project() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_project') + modules := { + 'aimplib': "module aimplib\n\npub fn make() string {\n\treturn ''\n}\n" + } + content := 'module main\n\nfn main() {\n\tprintln(aimplib.make())\n}\n' + main_path, uri := aimp_project(root, modules, content) + assert app.importable_modules(main_path).any(it.path == 'aimplib' && it.origin == .project), 'the project module is offered to import' + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'aimplib.', 0)) + assert action.title == 'Add import `aimplib`', 'the title names the project module' + assert aimp_edit(action).new_text == '\nimport aimplib\n', 'the project module is imported by its path' +} + +// The modules of vlib are importable from any file, so a use of one of them is +// offered the same import. +fn test_add_import_offers_a_vlib_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_vlib') + content := 'module main\n\nfn main() {\n\tm := math.max(1, 2)\n\tprintln(m)\n}\n' + main_path, uri := aimp_project(root, map[string]string{}, content) + assert app.importable_modules(main_path).any(it.path == 'math' && it.origin == .vlib), 'math is offered as a module of vlib' + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'math.', 0)) + assert action.title == 'Add import `math`', 'the title names the module of vlib' + edit := aimp_edit(action) + assert edit.range.start.line == 1, 'the import goes after the module line' + assert edit.new_text == '\nimport math\n', 'the module of vlib is imported by its path' +} + +// A name that is only mentioned — as a value, or standing on its own — is not a +// use of a module, even when a module carries that name. +fn test_add_import_refuses_a_bare_identifier_that_names_a_module() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_bare') + value := 'module main\n\nfn main() {\n\tprintln(math)\n}\n' + _, uri := aimp_project(root, map[string]string{}, value) + assert aimp_refuses(mut app, uri, value, aimp_cursor(value, 'math', 0)), 'a module passed as a value is not offered' + standalone := 'module main\n\nfn main() {\n\tx := 1\n\tmath\n}\n' + assert aimp_refuses(mut app, uri, standalone, aimp_cursor(standalone, 'math', 0)), 'a bare mention is not a use of a module' +} + +// With the cursor on the member of a qualified expression, the import offered is +// the module of that member: `join` of `os.join` is offered an `import os`. +fn test_add_import_offers_the_module_of_the_member_under_the_cursor() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_member') + content := "module main\n\nfn main() {\n\tp := os.join_path('a', 'b')\n\tprintln(p)\n}\n" + _, uri := aimp_project(root, map[string]string{}, content) + action := aimp_action(mut app, uri, content, aimp_cursor(content, 'join_path', 0)) + assert action.title == 'Add import `os`', 'the import is the module of the member' + assert aimp_edit(action).new_text == '\nimport os\n', 'the import line is written' +} + +// Only the base of a chain can be a module: the member of `cfg.time.now` is not +// `time`, which is a module of vlib and would be offered if the name were taken +// from the member instead of from the base. +fn test_add_import_refuses_the_member_of_a_local_chain() { + mut app := aimp_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + root := os.join_path(app.temp_dir, 'aimp_chain') + content := "module main\n\nfn main() {\n\tcfg := ''\n\tprintln(cfg.time.now)\n}\n" + main_path, uri := aimp_project(root, map[string]string{}, content) + assert app.importable_modules(main_path).any(it.path == 'time'), 'time is a module this file could import' + assert aimp_refuses(mut app, uri, content, aimp_cursor(content, 'now', 0)), 'the name is the base of the chain, not the member' +} diff --git a/extract_function.v b/extract_function.v index 124e1510..154779d0 100644 --- a/extract_function.v +++ b/extract_function.v @@ -162,12 +162,12 @@ fn extract_fn_locals(lines []string, decl_line int) map[string]string { lhs := rest[..space] tail := rest[space + 1..].trim_space() if is_plain_identifier(lhs) { - if tail.starts_with('=') { - stype := extract_inferred_type(tail[1..].trim_space()) - if stype != '' { - locals[lhs] = stype - } - } else if is_type_text(tail) { + if tail.starts_with('=') { + stype := extract_inferred_type(tail[1..].trim_space()) + if stype != '' { + locals[lhs] = stype + } + } else if is_type_text(tail) { locals[lhs] = tail } } @@ -188,7 +188,7 @@ fn extract_inferred_type(rhs string) string { if first == `'` || first == `"` || first == `$` { return 'string' } - if r.starts_with('r\'') || r.starts_with('r\"') { + if r.starts_with("r'") || r.starts_with('r\"') { return 'string' } if r == 'true' || r == 'false' { diff --git a/extract_function_test.v b/extract_function_test.v index 23c23d37..78840b4a 100644 --- a/extract_function_test.v +++ b/extract_function_test.v @@ -22,7 +22,7 @@ fn xtr_app(tag string, content string) (&App, string) { open_files: { uri: content } - temp_dir: dir + temp_dir: dir }, uri } @@ -98,8 +98,7 @@ fn test_extract_function_returns_the_single_assigned_local() { fn test_extract_function_refuses_two_assigned_locals() { content := 'module main\n\nfn run(input int) {\n\tmut a := 0\n\tmut b := 0\n\ta = input\n\tb = input\n\tprintln(a, b)\n}\n' mut app, uri := xtr_app('two', content) - assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 5)) == none, - 'two reassigned locals must not become two return values' + assert app.build_extract_function_action(uri, content, xtr_range(content, 4, 5)) == none, 'two reassigned locals must not become two return values' } fn test_extract_function_refuses_a_return_of_the_enclosing_function() { @@ -170,7 +169,7 @@ fn test_extract_function_numbers_the_new_name() { fn test_extract_function_skips_braces_and_comments() { // A brace inside a comment must not shift the enclosing depth scan. - content := "module main\n\nfn run(a int) {\n\t// if a { } else { }\n\tprintln(a)\n}\n" + content := 'module main\n\nfn run(a int) {\n\t// if a { } else { }\n\tprintln(a)\n}\n' texts := xtr_run('braces', content, 4, 4) assert texts.len == 2, texts.str() assert texts[0].starts_with('fn extracted_0(a int) {'), texts[0] diff --git a/implement_members.v b/implement_members.v new file mode 100644 index 00000000..2b28ba7b --- /dev/null +++ b/implement_members.v @@ -0,0 +1,470 @@ +// Copyright (c) 2025 Alexander Medavednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// --- Implement missing members --- + +// V matches an interface by shape, not by declaration: nothing says +// `struct Dog implements Speaker`, so a struct either has the methods an +// interface names or it does not. This offer reads the interfaces of the program +// the struct belongs to and writes the methods the struct is still missing, when +// exactly one of them fits. +// +// An interface fits when the struct declares no method the interface does not +// name, because a struct that carries a method of its own is implementing some +// other type's interface, and when the interface names at least one method the +// struct does not have, because otherwise there would be nothing to write. Two +// interfaces that fit are left alone: the names cannot say which one the author +// meant, and stubs for the wrong one are worse than none. +// +// The stubs are written after the closing brace of the struct rather than inside +// its body, because a V struct body holds fields and nothing else: a method +// declared between those braces is an `unexpected token }` where the closing +// brace should be. + +// ImplMember is one method an interface declares, in the words the interface +// declares it in: the parameter list and the return type written after its name, +// and the value a stub for it returns. +struct ImplMember { + name string + params string + return_type string + zero string // what the stub returns; '' when the member returns nothing +} + +// ImplCandidate is an interface a struct can implement, and the members of it +// the struct is still missing. +struct ImplCandidate { + iface string + missing []ImplMember +} + +// ImplStruct is the struct a cursor sits on: the name it is declared under with +// its type parameters, the methods and fields it already has, the name its own +// receivers use, and where the stubs are written. +struct ImplStruct { + decl_line int + name string + type_params string // `[T]`, or '' when the struct takes no parameters + insert int // byte offset the stubs are written at + nl string // the line terminator the closing brace uses + ends_file bool // the closing brace is the last byte of the file + indent string // the indentation the struct is declared at +mut: + methods []string + fields []string + recv_name string + body_indent string // the indentation the body of a stub is written at +} + +// build_implement_members_action returns the "Implement missing members" quick +// fix for the struct whose declaration line `rng` sits on, or none when the +// range is on no struct declaration, when no interface of the program fits the +// struct, or when more than one does. +fn (mut app App) build_implement_members_action(uri string, content string, rng LSPRange) ?CodeAction { + mut st := impl_struct_declaration(content, rng) or { return none } + dir := os.dir(uri_to_path(uri)).replace('\\', '/').trim_right('/') + if dir != '' && dir != '/' { + app.ensure_dir_shallow_indexed(dir) + } + if uri in app.open_files { + app.reindex_uri(uri) + } + app.impl_read_struct_members(uri, content, dir, mut st) + candidates := app.impl_interface_candidates(uri, content, dir, st) + if candidates.len != 1 { + return none + } + chosen := candidates[0] + at := byte_offset_to_position(content, st.insert, app.position_encoding) + return CodeAction{ + title: 'Implement missing members of `${chosen.iface}`' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: at + end: at + } + new_text: impl_stub_text(st, chosen.missing) + }, + ] + } + } + } +} + +// impl_struct_declaration returns the struct whose declaration line `rng` sits +// on, with where the stubs for it are written, or none when the range is on no +// such line. +fn impl_struct_declaration(content string, rng LSPRange) ?ImplStruct { + lines := content.split_into_lines() + code := source_code_lines(content) + if rng.start.line < 0 || rng.start.line >= lines.len || rng.start.line >= code.len { + return none + } + decl_line := rng.start.line + mut header := code[decl_line].trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + // The declaration line is the one that opens the body, `struct Name {`. A + // struct whose brace sits on a later line is left alone, because the members + // are written at a brace this does not have. + if !header.starts_with('struct ') || !header.contains('{') { + return none + } + declared := first_word(header[7..].trim_space()) + if declared == '' || !declared[0].is_capital() { + return none + } + masked := masked_v_code(content) + starts := line_start_offsets(content) + open := impl_brace_offset(masked, starts, decl_line, `{`) + if open < 0 { + return none + } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + close_line := assist_line_of(starts, close) + name := declared.all_before('[') + decl_indent := line_indent(lines[decl_line]) + return ImplStruct{ + decl_line: decl_line + name: name + type_params: declared[name.len..] + insert: close + 1 + nl: line_ending_after_line(content, close_line) + ends_file: close + 1 >= content.len + indent: decl_indent + body_indent: decl_indent + '\t' + } +} + +// impl_read_struct_members records what the struct already has: the names of its +// fields, from the index of the file it is declared in, the names of the methods +// the program declares on it, and the name the first of those receivers uses. +fn (mut app App) impl_read_struct_members(uri string, content string, dir string, mut st ImplStruct) { + for sym in app.index_doc_symbols(uri) { + if sym.kind != sym_kind_struct || sym.range.start.line != st.decl_line { + continue + } + for child in sym.children { + if child.kind == sym_kind_field && child.name !in st.fields { + st.fields << child.name + } + } + break + } + for text in app.impl_program_files(uri, content, dir) { + for line in source_code_lines(text) { + recv, typ, method := impl_method_receiver(line) + if recv == '' || normalize_receiver_type(typ) != st.name { + continue + } + if method !in st.methods { + st.methods << method + } + if st.recv_name == '' { + st.recv_name = recv + } + } + } +} + +// impl_program_files returns the text of the file being edited first, then the +// text of every other indexed file in the same module directory, in a stable +// order. A V module occupies one directory, so those files are the program the +// struct belongs to. +fn (mut app App) impl_program_files(uri string, content string, dir string) []string { + mut files := [content] + if dir == '' || dir == '/' { + return files + } + mut others := []string{} + want_key := impl_dir_key(dir) + for other, _ in app.symbol_index { + if other == uri || impl_dir_key(os.dir(uri_to_path(other))) != want_key { + continue + } + others << other + } + others.sort() + for other in others { + files << app.file_text(other) + } + return files +} + +// impl_interface_candidates returns every interface of the program that `st` can +// implement: one that names every method the struct declares and at least one +// member it does not, and whose members are all methods with a signature that +// fits on one line. +fn (mut app App) impl_interface_candidates(uri string, content string, dir string, st ImplStruct) []ImplCandidate { + mut candidates := []ImplCandidate{} + for text in app.impl_program_files(uri, content, dir) { + masked := masked_v_code(text) + starts := line_start_offsets(text) + code := source_code_lines(text) + for line_idx, source_line in code { + mut header := source_line.trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + if !header.starts_with('interface ') { + continue + } + declared := first_word(header[10..].trim_space()) + if declared == '' || !declared[0].is_capital() { + continue + } + open := impl_brace_offset(masked, starts, line_idx, `{`) + if open < 0 { + continue + } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + continue + } + members := impl_interface_members(code, line_idx, assist_line_of(starts, close)) or { + continue + } + candidate := impl_candidate_for(declared.all_before('['), members, st) or { + continue + } + candidates << candidate + } + } + return candidates +} + +// impl_interface_members parses the members of the interface declared on +// `decl_line` of a file whose masked code is `code`, whose body ends on +// `close_line`. It returns none when the interface declares anything that cannot +// be stubbed as a method: an embedded interface, a `mut:` section, a field, or a +// signature that does not fit on one line. +fn impl_interface_members(code []string, decl_line int, close_line int) ?[]ImplMember { + mut members := []ImplMember{} + for i in decl_line + 1 .. close_line { + if i < 0 || i >= code.len { + break + } + raw := code[i].trim_space() + if raw == '' { + continue + } + mut end := 0 + for end < raw.len && is_ident_char(raw[end]) { + end++ + } + name := raw[..end] + // An embedded interface is a type, so it is capitalized, and `mut:` opens + // a section: neither is a member this can stub. A field is a name followed + // by its type rather than by a parameter list. + if name == '' || name[0].is_capital() || !raw[end..].trim_left(' \t').starts_with('(') { + return none + } + rest := raw[end..].trim_left(' \t') + open_paren := end + (raw.len - end - rest.len) + close_paren := matching_delimiter(raw, open_paren, `(`, `)`) + if close_paren < 0 { + return none + } + if name in members.map(it.name) { + continue + } + members << ImplMember{ + name: name + params: raw[open_paren + 1..close_paren].trim_space() + return_type: raw[close_paren + 1..].trim_space() + } + } + return members +} + +// impl_candidate_for returns the candidate `members` makes for `st`, or none +// when the struct declares a method the interface does not name, when it already +// holds a field a missing member would take the name of, when a missing member +// returns a type no value can be written for, or when nothing is missing at all. +fn impl_candidate_for(iface string, members []ImplMember, st ImplStruct) ?ImplCandidate { + mut names := []string{cap: members.len} + for member in members { + names << member.name + } + for method in st.methods { + if method !in names { + return none + } + } + mut missing := []ImplMember{} + for member in members { + if member.name in st.methods { + continue + } + if member.name in st.fields { + return none + } + zero := impl_member_zero_value(member.return_type) or { return none } + missing << ImplMember{ + name: member.name + params: member.params + return_type: member.return_type + zero: zero + } + } + if missing.len == 0 { + return none + } + return ImplCandidate{ + iface: iface + missing: missing + } +} + +// impl_member_zero_value returns the value a stub for a member returns: `''` for +// a string, `0` for a number, `false` for a bool, an empty composite for a slice +// or a map, and the empty literal of a named type. A member that returns nothing +// has the empty zero. A return type no value can be written for — an optional, a +// result, a reference, or anything this does not recognise — has none, which +// refuses the interface rather than write a stub that will not compile. +fn impl_member_zero_value(return_type string) ?string { + mut t := return_type.trim_space() + if t == '' { + return '' + } + if t[0] in [`?`, `!`, `&`] || t.contains(' ') || t.contains('\t') { + return none + } + if t.starts_with('[]') || t.starts_with('map[') { + return t + '{}' + } + if t == 'string' { + return "''" + } + if t == 'bool' { + return 'false' + } + if t in zero_integer_field_types { + return '0' + } + if is_type_name(t) { + return t + '{}' + } + return none +} + +// impl_stub_text builds the members written after the struct's closing brace: +// one stub per missing member, each carrying the signature the interface +// declares and the zero value of its return type. The receiver is named after +// the struct's own receivers, so the stubs read like the methods already there. +fn impl_stub_text(st ImplStruct, missing []ImplMember) string { + recv := if st.recv_name != '' { st.recv_name } else { 's' } + mut lines := []string{cap: missing.len * 4} + for member in missing { + lines << '${st.indent}// ${member.name} does ...' + mut header := '${st.indent}fn (mut ${recv} ${st.name}${st.type_params}) ${member.name}(${member.params})' + if member.return_type != '' { + header += ' ' + member.return_type + } + header += ' {' + lines << header + if member.zero != '' { + lines << st.body_indent + 'return ' + member.zero + } + lines << st.indent + '}' + } + if lines.len == 0 { + return '' + } + // The brace the stubs follow is still on its line, so a blank line separates + // the struct from the methods that implement it, and the text that follows it + // already ends that brace's line unless it is the last byte of the file. The + // stubs take the terminator of the file they are written into. + nl := st.nl + mut text := nl + nl + lines.join(nl) + if st.ends_file { + text += nl + } + return text +} + +// impl_method_receiver returns the name of the receiver of the method `line` +// declares, the type it is declared on, and the name of the method itself, or +// three empty strings when the line declares no method. +fn impl_method_receiver(line string) (string, string, string) { + mut header := line.trim_space() + if header.starts_with('pub ') { + header = header[4..].trim_space() + } + if !header.starts_with('fn ') { + return '', '', '' + } + rest := header[3..].trim_space() + // A free function holds its parameters where a method holds its receiver, so + // the first `(` is the receiver's only when it opens the line. + open := rest.index('(') or { return '', '', '' } + if open != 0 { + return '', '', '' + } + close := matching_delimiter(rest, 0, `(`, `)`) + if close < 0 { + return '', '', '' + } + words := rest[1..close].trim_space().fields() + if words.len < 2 { + return '', '', '' + } + mut recv := words[0] + mut typ := words[1] + if recv == 'mut' { + if words.len < 3 { + return '', '', '' + } + recv = words[1] + typ = words[2] + } + after := rest[close + 1..].trim_space() + mut end := 0 + for end < after.len && is_ident_char(after[end]) { + end++ + } + method := after[..end] + if method == '' || !after[end..].trim_left(' \t').starts_with('(') { + return '', '', '' + } + return recv, typ, method +} + +// impl_brace_offset returns the offset of the first `brace` on `line` of the +// masked text, or -1 when that line holds none. +fn impl_brace_offset(masked string, starts []int, line int, brace u8) int { + if line < 0 || line >= starts.len { + return -1 + } + mut i := starts[line] + end := if line + 1 < starts.len { starts[line + 1] } else { masked.len } + for i < end { + if masked[i] == brace { + return i + } + i++ + } + return -1 +} + +// impl_dir_key returns a comparable key for the directory of a file. The index +// holds URIs whose spellings of one directory agree, and on Windows they differ +// in case. +fn impl_dir_key(dir string) string { + mut key := dir.replace('\\', '/').trim_right('/') + $if windows { + key = key.to_lower() + } + return key +} diff --git a/implement_members_test.v b/implement_members_test.v new file mode 100644 index 00000000..27a3f94b --- /dev/null +++ b/implement_members_test.v @@ -0,0 +1,479 @@ +// Copyright (c) 2025 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Tests for the "Implement missing members" quick fix. Every helper below is +// named with an `impl_` prefix, because all `_test.v` files of this module share +// one namespace and a colliding name would break the build. + +// ImplSpan is one edit, resolved to the byte range it applies to. +struct ImplSpan { + start int + end int + new_text string +} + +// impl_test_app returns an App that holds `content` as the open buffer of `uri` +// and has indexed it, which is the state the quick fix reads: the text the +// editor holds, and the declarations the index parses out of it. +fn impl_test_app(content string, uri string) App { + mut app := App{} + app.open_files[uri] = content + app.symbol_index[uri] = build_index_entry(content, app.position_encoding) + return app +} + +// impl_position returns where the first `needle` of `content` sits, which is both +// where a cursor on it lands and where a range selecting it starts. +fn impl_position(content string, needle string) Position { + for line, text in content.split_into_lines() { + if col := text.index(needle) { + return Position{ + line: line + char: col + } + } + } + assert false, 'the fixture holds no ${needle}' + return Position{} +} + +// impl_cursor returns the empty range an editor reports for a cursor sitting on +// the first `needle` of `content`. +fn impl_cursor(content string, needle string) LSPRange { + at := impl_position(content, needle) + return LSPRange{ + start: at + end: at + } +} + +// impl_apply applies the edits an action carries to `content`, from the last one +// back, so a test can assert what the document looks like once the quick fix is +// taken. Every fixture here is ASCII, so a UTF-16 unit is a byte. +fn impl_apply(content string, action CodeAction) string { + edit := action.edit or { + assert false, 'the action must carry an edit' + return content + } + starts := line_start_offsets(content) + mut spans := []ImplSpan{} + for _, edits in edit.changes { + for one in edits { + spans << ImplSpan{ + start: position_to_byte_offset(content, starts, one.range.start.line, one.range.start.char, PositionEncoding.utf16) + end: position_to_byte_offset(content, starts, one.range.end.line, one.range.end.char, PositionEncoding.utf16) + new_text: one.new_text + } + } + } + spans.sort_with_compare(fn (a &ImplSpan, b &ImplSpan) int { + return b.start - a.start + }) + mut out := content + for span in spans { + assert span.start >= 0 && span.start <= span.end, 'an edit must not end before it starts' + assert span.end <= out.len, 'an edit must stay inside the document' + out = out[..span.start] + span.new_text + out[span.end..] + } + return out +} + +// --- Implement missing members --- + +// The commonest case: the struct already has one of the two methods the only +// fitting interface declares, so only the missing one is written, and the +// receiver is named after the one the struct already uses. +fn test_implement_members_happy_path_inserts_one_stub_per_missing_member() { + uri := 'file:///vls-impl-mod/main.v' + content := "module main + +interface Speaker { + speak() string + volume() int +} + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} +" + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) or { + assert false, 'a struct missing one member of exactly one interface must be offered the action' + return + } + assert action.kind == code_action_kind_quickfix, 'the implement members action is a quick fix' + assert action.title == 'Implement missing members of `Speaker`', 'the title names the interface the stubs come from' + expected := "module main + +interface Speaker { + speak() string + volume() int +} + +struct Dog { + name string +} + +// volume does ... +fn (mut d Dog) volume() int { + return 0 +} + +fn (d Dog) speak() string { + return 'woof' +} +" + assert impl_apply(content, action) == expected, 'the missing member is written after the body of the struct' +} + +// A member that declares no return type is stubbed with an empty body, because +// there is no value to return. +fn test_implement_members_stubs_a_void_member_with_an_empty_body() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Barker { + bark() +} + +struct Puppy { +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Puppy')) or { + assert false, 'a struct missing a void member must be offered the action' + return + } + expected := 'module main + +interface Barker { + bark() +} + +struct Puppy { +} + +// bark does ... +fn (mut s Puppy) bark() { +} +' + assert impl_apply(content, action) == expected, 'a void member takes an empty body' +} + +// Each declared return type has a zero value of its own shape, and the receiver +// falls back to `s` for a struct that declares no method of its own. +fn test_implement_members_zero_value_per_return_type() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Everything { + text() string + count() int + ratio() f64 + flag() bool + tags() []string + scores() map[string]int + point() Point +} + +struct Sink { +} + +struct Point { + x int +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Sink')) or { + assert false, 'a struct missing every member of one interface must be offered the action' + return + } + expected := "module main + +interface Everything { + text() string + count() int + ratio() f64 + flag() bool + tags() []string + scores() map[string]int + point() Point +} + +struct Sink { +} + +// text does ... +fn (mut s Sink) text() string { + return '' +} +// count does ... +fn (mut s Sink) count() int { + return 0 +} +// ratio does ... +fn (mut s Sink) ratio() f64 { + return 0 +} +// flag does ... +fn (mut s Sink) flag() bool { + return false +} +// tags does ... +fn (mut s Sink) tags() []string { + return []string{} +} +// scores does ... +fn (mut s Sink) scores() map[string]int { + return map[string]int{} +} +// point does ... +fn (mut s Sink) point() Point { + return Point{} +} + +struct Point { + x int +} +" + assert impl_apply(content, action) == expected, 'every return type takes the zero value of its own shape' +} + +// A document written with CRLF terminators gets stubs that end their lines the +// same way, so taking the quick fix never rewrites the line endings of the file. +fn test_implement_members_keeps_the_line_endings_of_the_document() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main\r\n\r\ninterface Speaker {\r\n\tspeak() string\r\n\tvolume() int\r\n}\r\n\r\nstruct Dog {\r\n}\r\n' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) or { + assert false, 'a CRLF document missing one member must be offered the action' + return + } + expected := "module main\r\n\r\ninterface Speaker {\r\n\tspeak() string\r\n\tvolume() int\r\n}\r\n\r\nstruct Dog {\r\n}\r\n\r\n// speak does ...\r\nfn (mut s Dog) speak() string {\r\n\treturn ''\r\n}\r\n// volume does ...\r\nfn (mut s Dog) volume() int {\r\n\treturn 0\r\n}\r\n" + assert impl_apply(content, action) == expected, 'the stubs end their lines the way the document does' +} + +// The interface usually lives in another file of the module, and the index is +// where it is found, so the modules that split a type from its interface are +// covered by the same offer. +fn test_implement_members_reads_the_interface_from_another_file_of_the_module() { + uri := 'file:///vls-impl-mod/dog.v' + other := 'file:///vls-impl-mod/speaker.v' + dog := 'module main + +struct Dog { + name string +} +' + speaker := 'module main + +interface Speaker { + speak() string + volume() int +} +' + mut app := impl_test_app(dog, uri) + app.open_files[other] = speaker + app.symbol_index[other] = build_index_entry(speaker, app.position_encoding) + action := app.build_implement_members_action(uri, dog, impl_cursor(dog, 'struct Dog')) or { + assert false, 'an interface in a sibling file of the module must be offered too' + return + } + assert action.title == 'Implement missing members of `Speaker`', 'the title names the interface from the other file' + expected := "module main + +struct Dog { + name string +} + +// speak does ... +fn (mut s Dog) speak() string { + return '' +} +// volume does ... +fn (mut s Dog) volume() int { + return 0 +} +" + assert impl_apply(dog, action) == expected, 'both members come from the interface in the sibling file' +} + +// A struct that already has every member of the one interface that fits is left +// alone: there is nothing to write. +fn test_implement_members_refuses_a_struct_that_already_implements() { + uri := 'file:///vls-impl-mod/main.v' + content := "module main + +interface Speaker { + speak() string +} + +struct Dog { +} + +fn (d Dog) speak() string { + return 'woof' +} +" + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) + assert action == none, 'a struct that already implements the only fitting interface must be left alone' +} + +// Nothing is offered when every interface of the program declares a method the +// struct does not have, and none is offered when the program declares no +// interface at all. +fn test_implement_members_refuses_when_no_interface_fits() { + uri := 'file:///vls-impl-mod/main.v' + mismatch := 'module main + +interface Speaker { + speak() string +} + +struct Rock { +} + +fn (r Rock) roll() int { + return 0 +} +' + mut app := impl_test_app(mismatch, uri) + action := app.build_implement_members_action(uri, mismatch, impl_cursor(mismatch, 'struct Rock')) + assert action == none, 'an interface that does not name the method the struct has is not the one it implements' + + no_interface := 'module main + +struct Rock { +} + +fn (r Rock) roll() int { + return 0 +} +' + mut bare_app := impl_test_app(no_interface, uri) + bare_action := bare_app.build_implement_members_action(uri, no_interface, impl_cursor(no_interface, + 'struct Rock')) + assert bare_action == none, 'a program that declares no interface has nothing to implement' +} + +// Two interfaces that both fit are left alone: the names cannot say which one the +// author meant, and stubs for the wrong one are worse than none. +fn test_implement_members_refuses_when_two_interfaces_match() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Speaker { + speak() string +} + +interface Barker { + speak() string + bark() +} + +struct Dog { +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Dog')) + assert action == none, 'two interfaces that both fit must not both be stubbed' +} + +// The action is offered from the line that declares the struct, and from nowhere +// else in the file. +fn test_implement_members_refuses_a_cursor_that_is_not_on_the_struct_name_line() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Speaker { + speak() string +} + +struct Dog { + name string +} +' + mut app := impl_test_app(content, uri) + for needle in ['module main', 'interface Speaker', 'name string'] { + action := app.build_implement_members_action(uri, content, impl_cursor(content, needle)) + assert action == none, 'a cursor on `${needle}` is not on the name line of a struct' + } +} + +// An interface that declares something this cannot stub as a method — an +// embedded interface, or a field — is not a candidate however well the struct +// fits it, because writing a `fn` for one of those is not what it declares. +fn test_implement_members_refuses_an_interface_it_cannot_stub_every_member_of() { + uri := 'file:///vls-impl-mod/main.v' + embedded := 'module main + +interface Animal { + Named + speak() string +} + +struct Dog { +} +' + mut embedded_app := impl_test_app(embedded, uri) + embedded_action := embedded_app.build_implement_members_action(uri, embedded, impl_cursor(embedded, + 'struct Dog')) + assert embedded_action == none, 'an interface that embeds another interface is not stubbed' + + field := 'module main + +interface Sized { + count int + speak() string +} + +struct Dog { +} +' + mut field_app := impl_test_app(field, uri) + field_action := field_app.build_implement_members_action(uri, field, impl_cursor(field, 'struct Dog')) + assert field_action == none, 'an interface that declares a field is not stubbed' +} + +// A generic struct is stubbed with its type parameters in the receiver, the way +// its own methods would declare them. +fn test_implement_members_stubs_a_generic_struct_with_its_type_parameters() { + uri := 'file:///vls-impl-mod/main.v' + content := 'module main + +interface Getter { + get() int +} + +struct Crate[T] { + item T +} +' + mut app := impl_test_app(content, uri) + action := app.build_implement_members_action(uri, content, impl_cursor(content, 'struct Crate')) or { + assert false, 'a generic struct missing one member must be offered the action' + return + } + expected := 'module main + +interface Getter { + get() int +} + +struct Crate[T] { + item T +} + +// get does ... +fn (mut s Crate[T]) get() int { + return 0 +} +' + assert impl_apply(content, action) == expected, 'the receiver repeats the type parameters of the struct' +} From 219c38225bfc41f4ebbdfe2b629a01c67280e18b Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 13:07:08 +0330 Subject: [PATCH 36/83] perf: memoize the project listing, gate hashes on file metadata, bound the disk cache Three things made one keystroke on a large tree cost about 0.7s of serial work: the fingerprint re-walked and re-read every file, looked the open buffers up per candidate file, and cloned the job snapshot twice per affected URI. The listing is now reused for two seconds and dropped by a create or delete watcher event or an open, an unchanged file is re-hashed only when its size, inode and mtime move, and the mutation clones once. The disk cache holds one state per program instead of one entry per file, so a 40-file project no longer outgrows its byte cap and silently stops being cached. --- diag_cache.v | 219 +++++++++++++++++++++++++++++++--------- diagnostics_scheduler.v | 9 +- handlers.v | 4 + interop.v | 8 +- interop_test.v | 116 +++++++++++++++++++-- main.v | 2 + 6 files changed, 297 insertions(+), 61 deletions(-) diff --git a/diag_cache.v b/diag_cache.v index ed6d6fe6..4bf159e4 100644 --- a/diag_cache.v +++ b/diag_cache.v @@ -9,13 +9,38 @@ import json2 import os import time -// diag_disk_cache_version versions the on-disk format. -const diag_disk_cache_version = 1 -// diag_disk_cache_max_bytes bounds one program's cache file; larger caches -// are neither loaded nor written. -const diag_disk_cache_max_bytes = 2 * 1024 * 1024 -// diag_disk_cache_max_entries bounds the entries kept per program. -const diag_disk_cache_max_entries = 1024 +// diag_disk_cache_version versions the on-disk format. Version 2 stores one +// state per program instead of one entry per file: a 40-file program's +// fingerprints no longer reach the byte cap, which used to make the cache +// silently stop writing entirely. +const diag_disk_cache_version = 2 +// diag_disk_cache_max_bytes bounds one program's cache file; a larger state +// is neither loaded nor written. +const diag_disk_cache_max_bytes = 4 * 1024 * 1024 +// project_files_ttl_ms is how long a directory listing is reused. A create or +// a delete drops it at once; a change does not, because the content memo +// gates on size, inode and mtime. +const project_files_ttl_ms = 2000 + +// ContentMemo is a file's content hash with the metadata it was read at. +// Rehashing is skipped while the three move together, which is the common +// case between two edits in a large project. +struct ContentMemo { + valid bool + hash int + len int + inode u64 + size i64 + mtime i64 +} + +// FileListEntry is one project's `.v` file list, with the time it was taken. +struct FileListEntry { + files []string + at_ms i64 + complete bool + valid bool +} // compiler_fingerprint identifies the compiler a check ran with: its path, // size and modification time. A compiler upgrade changes the binary, so @@ -26,28 +51,110 @@ fn compiler_fingerprint() string { return '${exe}:${info.size}:${info.mtime}' } +// forget_project_files drops the cached listing of every project, which a +// file appearing or disappearing under a watched directory invalidates. +fn (mut app App) forget_project_files() { + app.file_list_cache.clear() +} + +// cached_project_files returns the project's `.v` files, walking at most once +// per `project_files_ttl_ms`. The listing is the expensive half of the +// fingerprint on a large tree, and it does not change between two edits. +fn (mut app App) cached_project_files(root string) ([]string, bool) { + now := time.now().unix_milli() + if cached := app.file_list_cache[root] { + if cached.valid && now - cached.at_ms < project_files_ttl_ms { + return cached.files, cached.complete + } + } + mut files := []string{} + complete := collect_v_files_bounded(root, project_disk_max_files, mut files) + files.sort() + app.file_list_cache[root] = FileListEntry{ + files: files.clone() + at_ms: now + complete: complete + valid: true + } + return files, complete +} + // program_content_fingerprint is what the `.v` files under `root` hold: // their paths and content hashes, with the editor's open buffers winning // over disk. It shares the walk bounds and exclusions of the workspace // index; an incomplete walk salts the fingerprint so it never matches. -fn (app &App) program_content_fingerprint(root string) string { +// +// Three things keep it off the critical path on a large tree: the listing is +// cached, the open buffers are looked up in one map instead of a scan per +// candidate file, and an unchanged file on disk is not re-read — its size, +// inode and mtime gate the hash. +fn (mut app App) program_content_fingerprint(root string) string { if root == '' || !os.is_dir(root) { return 'noroot:${time.now().unix_nano()}' } - mut files := []string{} - mut complete := collect_v_files_bounded(root, project_disk_max_files, mut files) - files.sort() + files, mut complete := app.cached_project_files(root) + mut open_by_path := map[string]string{} + for uri, content in app.open_files { + open_by_path[normalize_overlay_path(uri_to_path(uri))] = content + } mut parts := []string{cap: files.len} mut bytes_read := u64(0) for path in files { - content := app.source_text(path) + if open_content := open_by_path[normalize_overlay_path(path)] { + if u64(open_content.len) > index_max_file_bytes + || u64(open_content.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + bytes_read += u64(open_content.len) + parts << '${path}:${open_content.hash()}' + continue + } + key := normalize_overlay_path(path) + if memo := app.content_memo[key] { + if memo.valid { + info := os.stat(path) or { + app.content_memo.delete(key) + complete = false + continue + } + if memo.size == info.size && memo.mtime == info.mtime + && memo.inode == info.inode { + if u64(memo.len) > index_max_file_bytes + || u64(memo.len) > project_disk_max_bytes - bytes_read { + complete = false + continue + } + bytes_read += u64(memo.len) + parts << '${path}:${memo.hash}' + continue + } + } + } + content := os.read_file(path) or { + complete = false + continue + } if u64(content.len) > index_max_file_bytes || u64(content.len) > project_disk_max_bytes - bytes_read { complete = false continue } + info := os.stat(path) or { + complete = false + continue + } bytes_read += u64(content.len) - parts << '${path}:${content.hash()}' + hash := content.hash() + app.content_memo[key] = ContentMemo{ + valid: true + hash: hash + len: content.len + inode: info.inode + size: info.size + mtime: info.mtime + } + parts << '${path}:${hash}' } if !complete { parts << 'incomplete:${time.now().unix_nano()}' @@ -69,59 +176,70 @@ fn diag_cache_file(program_root string) string { return os.join_path(diag_cache_base_dir(), 'prog_${program_root.hash().hex()}.json') } -// DiagDiskCache is the on-disk format: a version, the compiler that wrote -// it, and the cached results by file path. Entries are DiagCacheEntry, -// shared with the session cache, so disk results merge without conversion. +// DiagDiskState is one checked state of a program: the fingerprint it was +// checked at, and the errors of each file the check answered for. +struct DiagDiskState { + fingerprint string + files map[string][]JsonError +} + +// DiagDiskCache is the on-disk format: a version, the compiler that wrote it, +// and the program's last checked state. One state instead of one entry per +// file keeps the file small, so a 200-file project no longer outgrows the +// byte cap and silently stops being cached. struct DiagDiskCache { version int compiler string - entries map[string]DiagCacheEntry + state DiagDiskState } -// load_diag_disk_cache reads the program's cache file; anything unreadable, +// load_diag_disk_cache reads the program's stored state; anything unreadable, // oversized, versioned otherwise, or written by another compiler is dropped. -fn load_diag_disk_cache(program_root string) map[string]DiagCacheEntry { +fn load_diag_disk_cache(program_root string) ?DiagDiskState { if program_root == '' { - return {} + return none } - raw := os.read_file(diag_cache_file(program_root)) or { return {} } + raw := os.read_file(diag_cache_file(program_root)) or { return none } if raw.len > diag_disk_cache_max_bytes { - return {} + return none } - stored := json2.decode[DiagDiskCache](raw) or { return {} } + stored := json2.decode[DiagDiskCache](raw) or { return none } if stored.version != diag_disk_cache_version || stored.compiler != compiler_fingerprint() { - return {} + return none + } + if stored.state.fingerprint == '' { + return none } - return stored.entries + return stored.state } -// save_diag_disk_entry records one file result, keeping what is already -// cached for the program. Writes are best effort: a cache must never fail -// a check. +// save_diag_disk_entry records one file result into the program's stored +// state, keeping the files of the same check together and dropping the +// results of a state that has been superseded. Writes are best effort: a +// cache must never fail a check. fn save_diag_disk_entry(program_root string, path string, entry DiagCacheEntry) { if program_root == '' { return } file := diag_cache_file(program_root) - mut entries := map[string]DiagCacheEntry{} - if raw := os.read_file(file) { - if raw.len <= diag_disk_cache_max_bytes { - if stored := json2.decode[DiagDiskCache](raw) { - if stored.version == diag_disk_cache_version - && stored.compiler == compiler_fingerprint() { - entries = stored.entries.clone() - } - } + mut state := load_diag_disk_cache(program_root) or { + DiagDiskState{ + fingerprint: entry.fingerprint + files: map[string][]JsonError{} } } - entries[path] = entry - if entries.len > diag_disk_cache_max_entries { - return + mut state_files := map[string][]JsonError{} + if state.fingerprint == entry.fingerprint { + state_files = state.files.clone() } + state_files[path] = entry.errors encoded := json2.encode(DiagDiskCache{ version: diag_disk_cache_version compiler: compiler_fingerprint() - entries: entries + state: DiagDiskState{ + fingerprint: entry.fingerprint + files: state_files + } }) if encoded.len > diag_disk_cache_max_bytes { return @@ -130,19 +248,24 @@ fn save_diag_disk_entry(program_root string, path string, entry DiagCacheEntry) os.write_file(file, encoded) or {} } -// ensure_diag_disk_cache merges the program's disk results into the -// session cache once; later lookups hit memory. The fingerprint each entry -// carries still decides every hit. -fn (mut app App) ensure_diag_disk_cache(program_root string) { +// ensure_diag_disk_cache merges the program's stored state into the session +// cache once; later lookups hit memory. Only a state matching the +// fingerprint of this check is merged, so a reused answer always describes +// the same bytes. +fn (mut app App) ensure_diag_disk_cache(program_root string, fingerprint string) { if program_root == '' || program_root in app.diag_disk_roots { return } app.diag_disk_roots[program_root] = true - for path, disk in load_diag_disk_cache(program_root) { + state := load_diag_disk_cache(program_root) or { return } + if state.fingerprint != fingerprint { + return + } + for path, errors in state.files { if path !in app.diag_cache { app.diag_cache[path] = DiagCacheEntry{ - fingerprint: disk.fingerprint - errors: disk.errors + fingerprint: state.fingerprint + errors: errors } } } diff --git a/diagnostics_scheduler.v b/diagnostics_scheduler.v index 77e1343d..dc51c564 100644 --- a/diagnostics_scheduler.v +++ b/diagnostics_scheduler.v @@ -375,6 +375,11 @@ fn (mut app App) finish_diagnostics_project_mutation(mutation DiagnosticsProject fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, tickets []DiagnosticsTicket, project_key string, changed_uri string, changed_content string, excluded_uri string) { now := time.now().unix_milli() mut should_start := false + // Every job of one mutation sees the same buffers, so the snapshot is + // cloned once. Cloning per ticket made a mutation cost two clones per + // affected URI, which is where the keystroke time went on a wide project. + job_open_files := app.open_files.clone() + job_generations := app.project_generations.clone() for ticket in tickets { if ticket.uri == excluded_uri { continue @@ -402,8 +407,8 @@ fn (mut app App) enqueue_diagnostics_tickets(mut scheduler DiagnosticsScheduler, project_key: project_key project_generation: ticket.project_generation position_encoding: app.position_encoding - open_files: app.open_files.clone() - project_generations: app.project_generations.clone() + open_files: job_open_files + project_generations: job_generations write_mutex: app.write_mutex tcp_conn: app.tcp_conn global_generation: ticket.global_generation diff --git a/handlers.v b/handlers.v index 90da2276..f9775683 100644 --- a/handlers.v +++ b/handlers.v @@ -4517,6 +4517,10 @@ fn (mut app App) on_did_open(request Request) bool { } diagnostics_mutation := app.begin_diagnostics_project_schedule(uri) app.open_files[uri] = content + // A file appearing is what the fingerprint's listing memo cannot see by + // itself, so an opened file retires it (create/delete watcher events do + // the same for the ones no editor ever opens). + app.forget_project_files() if version := params.text_document.version { app.open_files_versions[uri] = version } diff --git a/interop.v b/interop.v index 6a999d73..0bde5165 100644 --- a/interop.v +++ b/interop.v @@ -1420,7 +1420,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } } if cache_hit == '' { - app.ensure_diag_disk_cache(program_dir) + app.ensure_diag_disk_cache(program_dir, fingerprint) if cached := app.diag_cache[path] { if cached.fingerprint == fingerprint { cache_hit = 'disk' @@ -2274,6 +2274,12 @@ fn (mut app App) on_did_change_watched_files(request Request) { // A project's `vls.json` holds the defines its checks run with, so a // change of it is a change of the configuration (see vls_config.v). app.forget_config_file(uri_to_path(change.uri)) + // A file appearing or disappearing changes the listing a fingerprint + // walks; a file only changing does not, because the content memo gates + // on its size, inode and mtime. + if change.event_type == 1 || change.event_type == 3 { + app.forget_project_files() + } } open_uris_by_path := app.open_index_uris_by_path() for change in params.changes { diff --git a/interop_test.v b/interop_test.v index d64ac58e..e680541b 100644 --- a/interop_test.v +++ b/interop_test.v @@ -1110,7 +1110,7 @@ fn test_program_content_fingerprint_tracks_buffers_and_siblings() { interop_test_must_write_file(main_file, 'module main\n\nfn main() {}\n') interop_test_must_write_file(other_file, 'module main\n\nfn helper() {}\n') main_uri := path_to_uri(main_file) - app := App{ + mut app := App{ open_files: { main_uri: 'module main\n\nfn main() {}\n' } @@ -1131,6 +1131,48 @@ fn test_program_content_fingerprint_tracks_buffers_and_siblings() { os.rmdir_all(root) or {} } +fn test_program_content_fingerprint_reuses_the_listing_and_the_hashes() { + // The listing and the content hashes are memoised between edits; the same + // bytes must give the same fingerprint, and a new file must change it. + root := os.join_path(os.temp_dir(), 'vls_fpmemo_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + for i in 0 .. 4 { + interop_test_must_write_file(os.join_path(root, 'f${i}.v'), 'module m${i}\n\nfn f${i}() {}\n') + } + mut app := App{} + first := app.program_content_fingerprint(root) + // A second call within the TTL reuses the listing and the hashes: identical. + assert app.program_content_fingerprint(root) == first + // The listing cache really holds: dropping it must not change the answer. + app.forget_project_files() + assert app.program_content_fingerprint(root) == first + // A file appearing changes it the way production learns about it: a + // create watcher event, or the file being opened (on_did_open drops the + // listing for exactly this reason). Without that, a listing memo would + // hide a new file for its TTL. + interop_test_must_write_file(os.join_path(root, 'added.v'), 'module added\n\nfn added() {}\n') + assert app.program_content_fingerprint(root) == first, 'the memo still hides the new file' + app.forget_project_files() + assert app.program_content_fingerprint(root) != first, 'a new file must change the fingerprint' +} + +fn test_program_content_fingerprint_drops_the_listing_on_create_and_delete() { + root := os.join_path(os.temp_dir(), 'vls_fpdrop_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + interop_test_must_write_file(os.join_path(root, 'main.v'), 'module main\n\nfn main() {}\n') + mut app := App{} + assert app.program_content_fingerprint(root) != '' + assert root in app.file_list_cache + app.forget_project_files() + assert app.file_list_cache.len == 0 +} + fn test_diag_disk_cache_round_trip_and_rejections() { previous := with_temp_diag_cache_dir('roundtrip') defer { @@ -1152,23 +1194,74 @@ fn test_diag_disk_cache_round_trip_and_rejections() { ] } save_diag_disk_entry(root, 'file:///main.v', entry) - loaded := load_diag_disk_cache(root) - assert loaded['file:///main.v'].fingerprint == 'fp1' - assert loaded['file:///main.v'].errors.len == 1 - assert loaded['file:///main.v'].errors[0].message == 'seeded error' - assert loaded['file:///main.v'].errors[0].line_nr == 2 + loaded := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert loaded.fingerprint == 'fp1' + assert loaded.files['file:///main.v'].len == 1 + assert loaded.files['file:///main.v'][0].message == 'seeded error' + assert loaded.files['file:///main.v'][0].line_nr == 2 // Unreadable content is dropped, not fatal. os.write_file(diag_cache_file(root), 'not json') or { panic(err) } - assert load_diag_disk_cache(root).len == 0 + assert load_diag_disk_cache(root) == none // Results written by another compiler are dropped. save_diag_disk_entry(root, 'file:///main.v', entry) previous_command := os.getenv('VLS_V_COMMAND') os.setenv('VLS_V_COMMAND', os.join_path(root, 'no-such-compiler'), true) - assert load_diag_disk_cache(root).len == 0 + assert load_diag_disk_cache(root) == none restore_v_command(previous_command) os.rmdir_all(root) or {} } +fn test_diag_disk_state_keeps_only_the_state_its_fingerprint_names() { + // A second file of the same check joins the state; a result from a + // different fingerprint replaces it, so a program is never answered from + // bytes it was not checked at. + previous := with_temp_diag_cache_dir('states') + defer { + restore_diag_cache_dir(previous) + } + root := os.join_path(os.temp_dir(), 'vls_dcstates_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + defer { + os.rmdir_all(root) or {} + } + first := DiagCacheEntry{ + fingerprint: 'fp-a' + errors: [] + } + second := DiagCacheEntry{ + fingerprint: 'fp-a' + errors: [JsonError{ + message: 'only b' + line_nr: 7 + level: 'error' + }] + } + other := DiagCacheEntry{ + fingerprint: 'fp-b' + errors: [] + } + save_diag_disk_entry(root, 'file:///a.v', first) + save_diag_disk_entry(root, 'file:///b.v', second) + state := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert state.fingerprint == 'fp-a' + assert 'file:///a.v' in state.files + assert 'file:///b.v' in state.files + // The old state's results go with it. + save_diag_disk_entry(root, 'file:///a.v', other) + replaced := load_diag_disk_cache(root) or { + assert false, 'expected a stored state' + return + } + assert replaced.fingerprint == 'fp-b' + assert 'file:///b.v' !in replaced.files, 'the superseded state was kept' +} + fn test_diag_disk_cache_serves_a_later_session() { previous := with_temp_diag_cache_dir('twosession') defer { @@ -1210,13 +1303,16 @@ fn test_diag_disk_cache_serves_a_later_session() { uri: content } } - second.ensure_diag_disk_cache(second.program_root(real_path)) - assert uri in second.diag_cache, 'disk results did not merge' refingerprint := compiler_fingerprint() + '\n' + second.program_content_fingerprint(program_overlay_root(real_path, second.program_root(real_path))) + second.ensure_diag_disk_cache(second.program_root(real_path), refingerprint) + assert uri in second.diag_cache, 'disk results did not merge' assert second.diag_cache[uri].fingerprint == refingerprint, 'fingerprint moved between sessions' assert second.diag_cache[uri].errors[0].message == 'persisted diagnostic' + // A state that does not describe these bytes merges nothing. + second.ensure_diag_disk_cache(second.program_root(real_path), 'some other fingerprint') + assert second.diag_cache.len == 1, 'an unrelated state was merged' os.rmdir_all(root) or {} } diff --git a/main.v b/main.v index fe8cb110..f9a38178 100644 --- a/main.v +++ b/main.v @@ -33,6 +33,8 @@ mut: diagnostics_enabled bool = true // toggled via workspace/didChangeConfiguration diag_cache map[string]DiagCacheEntry // Per-URI cached diagnostics diag_disk_roots map[string]bool // Program roots whose disk cache merged this session + file_list_cache map[string]FileListEntry // Per-project .v listings, so a fingerprint does not re-walk the tree + content_memo map[string]ContentMemo // Per-file content hash with the size, inode and mtime it was read at open_files_generation int // Incremented on every workspace file mutation project_generations map[string]int // Per-project-dir revision, for scoped cache invalidation cancelled_requests map[int]bool // Request ids cancelled via $/cancelRequest From c6a1ba2d5429c3b6546d5eeda0a9ecb4b8229dc9 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 16:09:38 +0330 Subject: [PATCH 37/83] feat: answer interactive requests while a check is in flight Two changes to stop a user waiting on their own keystroke. rush_pending_diagnostics pulls a waiting slow check forward when the worker is idle and no fast job of that project is due later, so hover and definition see the buffer the user is typing rather than last keystroke's overlay. And hover plus the definition family now answer immediately from the index and follow up with the compiler's answer on the same request id, instead of blocking behind a check that is already running; a cheap compiler answer is still waited for, a cancelled request suppresses the follow-up, and completion is deliberately untouched. --- answer_budget.v | 112 +++++++++++++++ diagnostics_scheduler.v | 74 ++++++++++ handlers.v | 100 ++++++++++---- handlers_test.v | 299 ++++++++++++++++++++++++++++++++++++++++ integration_test.v | 2 +- interop.v | 4 + interop_test.v | 256 ++++++++++++++++++++++++++++++++++ main.v | 65 ++++++--- 8 files changed, 865 insertions(+), 47 deletions(-) create mode 100644 answer_budget.v diff --git a/answer_budget.v b/answer_budget.v new file mode 100644 index 00000000..46a8dafd --- /dev/null +++ b/answer_budget.v @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time + +// answer_budget_ms is what a compiler answer may cost before answering a request +// twice is what a held check makes worthwhile. gopls answers a request from what +// it already knows when the authoritative answer would take longer than a budget, +// and replaces that answer when it arrives. Below the budget the wait is shorter +// than a second popup, so the compiler's answer is waited for instead. +const answer_budget_ms = 50 + +// AnswerFollowup is the answer a held compiler owes a request the index has +// already answered. +struct AnswerFollowup { + id int + raw_id string + method Method + uri string + position Position + line_info string +} + +// answer_budget_key names a measured answer: one document and one method, since +// an answer that is quick in one file is not thereby quick in another. +fn answer_budget_key(uri string, method Method) string { + return '${uri}\x00${method}' +} + +// schedule_answer_followup defers the compiler's answer to a request that the +// index has already answered, when that answer is held: a diagnostics check for +// the document waits or runs, and the last answer measured for it was not quick +// enough to make asking twice cheaper than waiting. Nothing is scheduled +// otherwise, so a request the compiler answers in time is answered once. +// Completion is never deferred: its index answer and the compiler's differ in +// ways that make a stale list confusing. +fn (mut app App) schedule_answer_followup(method Method, request Request, uri string, position Position, line_info string) { + if !app.compiler_answer_is_held(uri, method) { + return + } + // The session outlives the request, so the thread shares it: the pooled + // compilers it asks through and the transport it writes on are the ones the + // request itself would have used. + mut session := &app + app.answer_followups.add(1) + spawn run_answer_followup(mut session, AnswerFollowup{ + id: request.id + raw_id: app.current_request_raw_id + method: method + uri: uri + position: position + line_info: line_info + }) +} + +// run_answer_followup asks the compiler for the answer the index gave and writes +// it in place of that one, on the same request id. It runs on a thread of its own +// so the request it belongs to is not held by the compiler the check holds. +fn run_answer_followup(mut app App, followup AnswerFollowup) { + defer { + app.answer_followups.done() + } + started_ms := time.now().unix_milli() + result := app.compiler_answer(followup.method, followup.uri, followup.position, + followup.line_info) + app.note_compiler_answer(followup.uri, followup.method, time.now().unix_milli() - started_ms) + app.write_deferred_response(followup.id, followup.raw_id, Response{ + id: followup.id + result: result + }) +} + +// compiler_answer_is_held reports whether the compiler's answer for the document +// at `uri` is held: a diagnostics check for it waits for its debounce or already +// runs, and the last answer measured for it and this method was not quick enough +// to make asking twice cheaper than waiting. +fn (app &App) compiler_answer_is_held(uri string, method Method) bool { + if !app.diagnostics_enabled { + return false + } + scheduler := app.diagnostics_scheduler or { return false } + if !scheduler.check_in_flight(uri) { + return false + } + return !app.compiler_answer_is_quick(uri, method) +} + +// compiler_answer_is_quick reports whether the compiler answered this document +// and this method in under the budget the last time it was asked: then its +// answer costs no more than the index's and must not be doubled. +fn (app &App) compiler_answer_is_quick(uri string, method Method) bool { + elapsed_ms := app.compiler_answer_ms[answer_budget_key(uri, method)] or { return false } + return elapsed_ms in 0 .. answer_budget_ms +} + +// note_compiler_answer records what the compiler's answer for the document at +// `uri` cost, so the next question about it knows whether to answer twice. +fn (mut app App) note_compiler_answer(uri string, method Method, elapsed_ms i64) { + if elapsed_ms < 0 { + return + } + app.compiler_answer_ms[answer_budget_key(uri, method)] = elapsed_ms +} + +// wait_answer_followups joins the threads that replace an index answer with the +// compiler's. They ask through the pooled compilers and write on the transport, +// so both must outlive the request loop that scheduled them and be joined +// before the session stops them. +fn (mut app App) wait_answer_followups() { + app.answer_followups.wait() +} diff --git a/diagnostics_scheduler.v b/diagnostics_scheduler.v index dc51c564..f9988e92 100644 --- a/diagnostics_scheduler.v +++ b/diagnostics_scheduler.v @@ -258,6 +258,43 @@ fn (mut scheduler DiagnosticsScheduler) take_ready_jobs(now i64) ([]DiagnosticsJ return ready, should_stop } +// rush_pending_slow_job moves the deadline of the pending slow job of `uri` to +// now, so the check a read waits for starts on the worker's next poll instead of +// waiting out its debounce: a question about the program — a hover, a definition, +// a signature — is answered against what the editor already shows, and until the +// slow check publishes that is last-keystroke's overlay (clangd's debounce +// policy "read"). A busy worker is never jumped, and a job a newer change +// superseded is left waiting, because the generation checks decide what runs. +fn (mut scheduler DiagnosticsScheduler) rush_pending_slow_job(uri string) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.active_uri != '' { + return + } + slow_key := diagnostics_job_key(uri, .slow) + job := scheduler.pending_jobs[slow_key] or { return } + if !scheduler.is_job_current_locked(job) { + return + } + // The fast tier publishes first and the slow answer supersedes it, so a fast + // job of the same project still waiting out its own debounce holds this one + // back: flushed past it, the two would publish the wrong way round. One that + // is already due is taken on the poll before this one, so the order holds + // there too. + now := time.now().unix_milli() + for _, pending in scheduler.pending_jobs { + if pending.kind == .fast && pending.project_key == job.project_key && pending.ready_at > now { + return + } + } + scheduler.pending_jobs[slow_key] = DiagnosticsJob{ + ...job + ready_at: now + } +} + fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { scheduler.mutex.lock() if scheduler.active_uri == job.uri && scheduler.active_generation == job.generation { @@ -268,6 +305,26 @@ fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { scheduler.mutex.unlock() } +// check_in_flight reports whether a check for the document at `uri` waits for +// its debounce deadline or already runs. Either holds the compilers of the +// buffers it checks, so a question about the same files asked alongside it is +// answered late (see answer_budget.v). +fn (mut scheduler DiagnosticsScheduler) check_in_flight(uri string) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.active_uri == uri { + return true + } + for key in [diagnostics_job_key(uri, .slow), diagnostics_job_key(uri, .fast)] { + if key in scheduler.pending_jobs { + return true + } + } + return false +} + fn (mut scheduler DiagnosticsScheduler) cancel(uri string) { scheduler.mutex.lock() scheduler.generations[uri] = scheduler.generations[uri] + 1 @@ -437,6 +494,23 @@ fn (mut app App) cancel_all_scheduled_diagnostics() { } } +// rush_pending_diagnostics starts the slow check of `uri` at once, when one is +// pending and the worker is idle, so a request answered while a check waits does +// not read last-keystroke's overlay: the compiler that answers the question +// checks the same program the check is about to, which warms the check into the +// bargain (clangd's debounce policy "read"). It asks the scheduler and nothing +// more — no job is queued, none is started — and it asks nothing when +// diagnostics are off, since a check is either pending for a reason or none at +// all, which makes it safe on every request path. +fn (mut app App) rush_pending_diagnostics(uri string) { + if !app.diagnostics_enabled { + return + } + if mut scheduler := app.diagnostics_scheduler { + scheduler.rush_pending_slow_job(uri) + } +} + fn run_diagnostics_worker(mut scheduler DiagnosticsScheduler) { scheduler.mutex.lock() done := scheduler.worker_done diff --git a/handlers.v b/handlers.v index f9775683..1aee6f9c 100644 --- a/handlers.v +++ b/handlers.v @@ -422,6 +422,9 @@ fn (mut app App) hover_with_written_declaration(uri string, position Position, r // documentation of its own declaration. `line_info` asks the hover. fn (mut app App) hover_result(uri string, position Position, line_info string) ResponseResult { real_path := uri_to_path(uri) + // A read flushes the debounce: the slow check runs now, so the overlay this + // hover is read against is the one the pause was going to publish anyway. + app.rush_pending_diagnostics(uri) mut located := false if answer := app.v3_hover(uri, real_path, line_info) { if location := answer.declared_at { @@ -1151,6 +1154,24 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // before building the -line-info string (P0-01). byte_col := app.client_col_to_byte_col(path, params.position.line, col) + // The `-line-info` question each method asks the compiler. It names no state, + // so the answers the index gives below carry it: a question the compiler is + // held for is asked again afterwards, on a thread of its own. + line_info := match method { + .hover { + '${line_nr}:hv^${byte_col}' + } + .signature_help { + '${line_nr}:fn^${byte_col}' + } + .definition, .declaration, .type_definition, .implementation { + '${line_nr}:gd^${byte_col}' + } + else { + '' + } + } + // Completion is served from the incremental source index. Starting a fresh V // compiler process here used to cost hundreds of milliseconds on every request, // even when the compiler returned no completion payload for an incomplete file. @@ -1213,6 +1234,7 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // and aware of unsaved files. if method in [.definition, .declaration, .type_definition, .implementation] { if location := app.resolve_indexed_definition(path, params.position) { + app.schedule_answer_followup(method, request, path, params.position, line_info) return Response{ id: request.id result: location @@ -1234,6 +1256,7 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { } } } + app.schedule_answer_followup(.hover, request, path, params.position, line_info) return Response{ id: request.id result: binding @@ -1244,51 +1267,62 @@ fn (mut app App) operation_at_pos(method Method, request Request) Response { // declaration from the index waits until after the compiler, which brings // the documentation with its answer. if member := app.member_selector_hover(path, params.position) { + app.schedule_answer_followup(.hover, request, path, params.position, line_info) return Response{ id: request.id result: member } } + // Nothing the index answers on its own was found, so this question falls + // through to the compiler. The declaration the index resolves is still an + // answer, and a check that holds the compilers makes it worth sending now: + // the compiler's answer follows, on the same id (answer_budget.v). + if app.compiler_answer_is_held(path, .hover) { + if declaration := app.source_hover_fallback(path, params.position) { + app.schedule_answer_followup(.hover, request, path, params.position, line_info) + return Response{ + id: request.id + result: declaration + } + } + } } - line_info := match method { - .hover { - '${line_nr}:hv^${byte_col}' - } - .signature_help { - '${line_nr}:fn^${byte_col}' - } - .definition, .declaration, .type_definition, .implementation { - '${line_nr}:gd^${byte_col}' - } - else { - '' - } + started_ms := time.now().unix_milli() + mut result := app.compiler_answer(method, path, params.position, line_info) + app.note_compiler_answer(path, method, time.now().unix_milli() - started_ms) + $if debug { + log(result.str()) + } + return Response{ + id: request.id + result: result } +} +// compiler_answer asks the compiler what the index could not say: what the name +// under the cursor is, or where it is declared, with the fallbacks applied to an +// answer the compiler has none for. The deferred answer of a request the index +// already answered ends here too (see answer_budget.v), so the two cannot drift +// apart. +fn (mut app App) compiler_answer(method Method, uri string, position Position, line_info string) ResponseResult { mut result := if method == .hover { - app.hover_result(path, params.position, line_info) + app.hover_result(uri, position, line_info) } else { - app.run_v_line_info(method, path, line_info) + app.run_v_line_info(method, uri, line_info) } if result is string && result == 'null' { if method == .hover { - if fallback := app.source_hover_fallback(path, params.position) { + if fallback := app.source_hover_fallback(uri, position) { result = fallback } } else if method == .signature_help { - if fallback := app.source_signature_fallback(path, params.position) { + if fallback := app.source_signature_fallback(uri, position) { result = fallback } } } - $if debug { - log(result.str()) - } - return Response{ - id: request.id - result: result - } + return result } // apply_completion_budget caps the items sent to the client so a huge index @@ -4840,7 +4874,9 @@ fn (mut app App) prepare_rename_request(request Request) !Response { } uri := params.text_document.uri scope := app.index_scope_for_uri(uri) - app.ensure_index_scope(scope) + // prepareRename only answers what the name is: it never edits anything, so + // it waits for no index refresh (the rename that follows does its own). + app.ensure_index_scope_for_request(scope) mut cache := app.rename_anchor_cache() defer { app.keep_rename_anchors(cache) @@ -4941,7 +4977,7 @@ fn (mut app App) handle_workspace_symbol(request Request) Response { // Populate/refresh the persistent index once, then answer from it. Tests are // included so test functions/types are discoverable (P2-11). Subsequent // queries reuse the index instead of re-reading and re-parsing the workspace. - app.ensure_dirs_indexed(app.index_query_dirs()) + app.ensure_indexed_for_request(app.index_query_dirs()) app.ensure_loose_file_dirs_shallow_indexed() results := app.query_workspace_symbols(query) app.end_progress(token, '') @@ -6611,8 +6647,10 @@ fn (mut app App) find_doc_comment_for_symbol(symbol string, current_lines []stri // 2 & 3. Other open files and project .v files, via the persistent index // (avoids re-reading and re-parsing the whole project on every hover, P1-08). // Scope the lookup to the current module directory, then the current project, - // so a same-named symbol from an unrelated project/module is never used. - app.ensure_dirs_indexed(app.index_query_dirs()) + // so a same-named symbol from an unrelated project/module is never used. The + // refresh runs in the background, so the hover answers from the index as it + // stands and never walks the project on the request thread. + app.ensure_indexed_for_request(app.index_query_dirs()) cur_dir := os.dir(uri_to_path(current_file_uri)) scope_root := find_project_root(cur_dir) if imported_module != '' { @@ -7650,9 +7688,11 @@ fn (app &App) workspace_search_dirs(primary_dir string) []string { // search_symbol_in_dirs returns every lexical occurrence of `symbol` across the // indexed project, read from the reference-occurrence index rather than by -// re-walking and re-tokenizing the workspace on each request (P1-05). +// re-walking and re-tokenizing the workspace on each request (P1-05). The +// refresh of a stale index is left to the background worker: a references +// request answers from the index as it stands. fn (mut app App) search_symbol_in_dirs(symbol string, request_id int) []Location { - app.ensure_dirs_indexed(app.index_query_dirs()) + app.ensure_indexed_for_request(app.index_query_dirs()) app.ensure_loose_file_dirs_shallow_indexed() mut locations := []Location{} mut uris := app.symbol_index.keys() diff --git a/handlers_test.v b/handlers_test.v index 7d2e13ea..cd7e3738 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -17069,3 +17069,302 @@ fn test_linked_editing_range_keeps_a_local_apart_from_a_namesake_in_another_func assert ler.ranges.len == 2, 'the declaration and the use of this local, got ${ler.ranges.len}' assert ler.ranges.all(it.start.line in [3, 4]), 'the namesake in `second` is not linked: ${ler.ranges.map(it.start.line)}' } + +// ── answer budget: the index first, the compiler after it ───────────────────── + +// budget_test_source is the shape a hover question about a documented function +// needs: the index resolves its declaration, and the answer the compiler gives +// adds the documentation written above it, so the two cannot be mistaken for +// one another. +const budget_test_source = 'module main\n\n// helper doubles a number.\nfn helper(n int) int {\n\treturn n * 2\n}\n\nfn main() {\n\tprintln(helper(2))\n}\n' + +// budget_helper_position is the `helper` of `println(helper(2))`. +fn budget_helper_position() Position { + return Position{ + line: 8 + char: 10 + } +} + +// budget_completion_source has a prefix the index answers on its own, so the +// completion test costs no compiler at all. +const budget_completion_source = 'module main\n\nfn helper() {}\n\nfn helper_two() {}\n\nfn main() {\n\thel\n}\n' + +// budget_completion_position is the `hel` of the last line. +fn budget_completion_position() Position { + return Position{ + line: 7 + char: 4 + } +} + +// budget_test_open writes `content` as the document under test and returns its +// URI, with its buffer open so the index reads what the editor holds. +fn budget_test_open(mut app App, name string, content string) string { + dir := os.join_path(app.temp_dir, name) + must_mkdir_all(dir) + path := os.join_path(dir, 'main.v') + must_write_file(path, content) + uri := path_to_uri(path) + app.open_files[uri] = content + app.text = content + return uri +} + +// budget_test_check_in_flight gives the app a diagnostics check for `uri` that +// is pending behind its debounce, which is what holds the compilers a question +// about the same document would ask. +fn budget_test_check_in_flight(mut app App, uri string, kind DiagnosticsKind) { + mut scheduler := new_diagnostics_scheduler() + scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: app.open_files[uri] or { '' } + project_key: app.generation_key(uri) + write_mutex: app.write_mutex + kind: kind + }) + app.diagnostics_scheduler = scheduler +} + +// budget_test_request asks `method` about the helper position of `uri`. +fn budget_test_request(method string, id int, uri string) Request { + return Request{ + id: id + method: method + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: budget_helper_position() + }, + escape_unicode: true + ) + } +} + +// budget_completion_request asks `method` about the `hel` prefix of `uri`. +fn budget_completion_request(method string, id int, uri string) Request { + return Request{ + id: id + method: method + params: json2.encode(Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: budget_completion_position() + }, + escape_unicode: true + ) + } +} + +// budget_test_answers returns the outbound messages that carry `id`, read under +// the mutex the transport writes with. +fn budget_test_answers(mut app App, id int) []string { + app.write_mutex.lock() + defer { + app.write_mutex.unlock() + } + return app.captured_output.filter(it.contains('"id":${id},')) +} + +fn test_hover_answers_from_the_index_while_a_check_is_pending() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_pending', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + app.write_mutex.lock() + // The transport is held, so nothing a deferred answer writes can land while + // this is checked: the only answer that exists is the one the request + // returns, and it is the index's. + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + assert app.captured_output.len == 0, 'nothing is written while the request is answered' + app.write_mutex.unlock() + + assert response.id == 1, 'the answer carries the id of the request' + assert response.result is Hover, 'hover answers a Hover: ${response.result}' + hover := response.result as Hover + assert hover.contents.value == '```v\nfn helper(n int) int\n```', 'the answer is the declaration the index holds: ${hover.contents.value}' +} + +fn test_hover_followup_carries_the_compilers_answer_on_the_same_id() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_followup', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + app.write_response_or_cancelled(1, response) + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 1) + assert answers.len == 2, 'the index answered and the compiler replaced it: ${answers}' + assert answers.all(it.contains('"id":1,')), 'both answers carry the id of the one request: ${answers}' + assert answers.any(it.contains('fn helper(n int) int')), 'the declaration is in the answer: ${answers}' + // Only the compiler's answer carries the documentation above the declaration. + assert answers.any(it.contains('helper doubles a number.')), 'the compiler answer follows on the same id: ${answers}' + assert answer_budget_key(uri, .hover) in app.compiler_answer_ms, 'the follow-up measured what the compiler answer cost' +} + +fn test_hover_does_not_answer_twice_when_no_check_is_in_flight() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_idle', budget_test_source) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 1, uri)) + app.write_response_or_cancelled(1, response) + app.wait_answer_followups() + + // No check holds the compiler, so the behaviour is what it was: the compiler + // answers, with the documentation the index answer has no room for. + assert response.result is Hover, 'hover answers a Hover: ${response.result}' + value := (response.result as Hover).contents.value + assert value.contains('helper doubles a number.'), 'the compiler answer is not deferred: ${value}' + answers := budget_test_answers(mut app, 1) + assert answers.len == 1, 'one answer, as before: ${answers}' +} + +fn test_definition_followup_carries_the_compilers_answer_on_the_same_id() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_followup', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + assert response.result is Location, 'definition answers a Location: ${response.result}' + declared := response.result as Location + assert declared.uri == uri && declared.range.start.line == 3, 'the index points at the declaration: ${declared.range.start.line}' + app.write_response_or_cancelled(2, response) + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 2) + assert answers.len == 2, 'the index answered and the compiler replaced it: ${answers}' + assert answers.all(it.contains('"id":2,')), 'both answers carry the id of the one request: ${answers}' + // What the compiler answers is exactly what the same question answers when + // it is asked again, on the same buffers and the same position. + compiler_answer := app.compiler_answer(.definition, uri, budget_helper_position(), '9:gd^10') + expected := encode_response_payload(Response{ + id: 2 + result: compiler_answer + }) + assert answers.any(it.contains(expected)), 'the second answer is the one the compiler gives: ${answers}' + assert answer_budget_key(uri, .definition) in app.compiler_answer_ms, 'the follow-up measured what the compiler answer cost' +} + +fn test_definition_answers_from_the_index_while_a_check_is_pending() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_pending', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + app.write_mutex.lock() + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + assert app.captured_output.len == 0, 'nothing is written while the request is answered' + app.write_mutex.unlock() + + assert response.result is Location, 'definition answers a Location without the compiler: ${response.result}' + declared := response.result as Location + assert declared.uri == uri && declared.range.start.line == 3, 'the index points at the declaration: ${declared.range.start.line}' +} + +fn test_definition_does_not_answer_twice_when_no_check_is_in_flight() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'definition_idle', budget_test_source) + app.capture_output = true + + response := app.operation_at_pos(.definition, budget_test_request('textDocument/definition', 2, uri)) + app.write_response_or_cancelled(2, response) + app.wait_answer_followups() + + assert response.result is Location, 'definition answers a Location: ${response.result}' + answers := budget_test_answers(mut app, 2) + assert answers.len == 1, 'one answer, as before: ${answers}' +} + +fn test_completion_is_never_answered_twice_while_a_check_is_pending() { + // Completion is left as it was: its index answer and the compiler answer + // differ in ways that make a stale list confusing. + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'completion_pending', budget_completion_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.completion, budget_completion_request('textDocument/completion', 3, uri)) + app.write_response_or_cancelled(3, response) + app.wait_answer_followups() + + assert response.result is CompletionList, 'completion answers a list: ${response.result}' + labels := (response.result as CompletionList).items.map(it.label) + assert 'helper' in labels, 'the index offered the function: ${labels}' + answers := budget_test_answers(mut app, 3) + assert answers.len == 1, 'completion is answered once: ${answers}' + assert app.compiler_answer_ms.len == 0, 'completion schedules no deferred answer' +} + +fn test_followup_answer_is_suppressed_when_the_request_is_cancelled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_cancelled', budget_test_source) + budget_test_check_in_flight(mut app, uri, .slow) + app.capture_output = true + + response := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 4, uri)) + // The client cancels the request while the compiler still works. + app.cancelled_requests[4] = true + app.wait_answer_followups() + + answers := budget_test_answers(mut app, 4) + assert answers.len == 1, 'a cancelled request is not answered a second time: ${answers}' + assert answers[0].contains('"code":${jsonrpc_err_request_cancelled}'), 'the cancellation is reported: ${answers[0]}' +} + +fn test_a_quick_compiler_answer_is_waited_for_instead_of_doubled() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + uri := budget_test_open(mut app, 'hover_quick', budget_test_source) + app.capture_output = true + + // The first question measures what the compiler answer costs here. + first := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 5, uri)) + assert first.result is Hover, 'hover answers a Hover: ${first.result}' + elapsed_ms := app.compiler_answer_ms[answer_budget_key(uri, .hover)] or { -1 } + assert elapsed_ms in 0 .. answer_budget_ms, 'the answer measured inside the budget: ${elapsed_ms} ms' + + // A check that is now pending does not make it worth answering twice. + budget_test_check_in_flight(mut app, uri, .slow) + app.write_response_or_cancelled(5, first) + second := app.operation_at_pos(.hover, budget_test_request('textDocument/hover', 6, uri)) + app.write_response_or_cancelled(6, second) + app.wait_answer_followups() + + value := (second.result as Hover).contents.value + assert value.contains('helper doubles a number.'), 'a quick compiler answer is waited for: ${value}' + answers := budget_test_answers(mut app, 6) + assert answers.len == 1, 'one answer, because the compiler answer is cheap: ${answers}' +} diff --git a/integration_test.v b/integration_test.v index 2494afa5..353627a2 100644 --- a/integration_test.v +++ b/integration_test.v @@ -2980,7 +2980,7 @@ fn test_integration_numeric_cancel_ids_match_exact_raw_token() { assert !app.request_is_cancelled(1) app.current_request_raw_id = '1.25' assert app.request_is_cancelled(1) - assert app.consume_cancelled_request(1) + assert app.consume_cancelled_request(1, app.current_request_raw_id) assert !app.request_is_cancelled(1) app.on_cancel_request(Request{ diff --git a/interop.v b/interop.v index 0bde5165..b3023b3b 100644 --- a/interop.v +++ b/interop.v @@ -2505,6 +2505,10 @@ fn (mut app App) run_v_line_info(method Method, path string, line_info string) R real_path := uri_to_path(path) log('real_path=${real_path}, method=${method}') + // A read flushes the debounce: the slow check runs now, so the overlay this + // answer is read against is the one the pause was going to publish anyway. + app.rush_pending_diagnostics(path) + // V3 answers first; V1 what it cannot, such as a file that does not parse. if served := app.v3_line_info(method, path, real_path, line_info) { return served diff --git a/interop_test.v b/interop_test.v index e680541b..acb8c5ce 100644 --- a/interop_test.v +++ b/interop_test.v @@ -4474,3 +4474,259 @@ fn test_stable_overlay_slow_check_reports_stage_timings() { assert false, 'could not record stage timings: ${err}' } } + +// rush_test_app builds an App whose diagnostics go to a scheduler of its own, +// next to a real file to name in the jobs, and returns that file's URI and the +// temporary root to remove afterwards. +fn rush_test_app(name string) (&App, string, string) { + root := os.join_path(os.temp_dir(), '${name}_${os.getpid()}_${time.now().unix_nano()}') + source_dir := os.join_path(root, 'src') + interop_test_must_mkdir_all(source_dir) + source_file := os.join_path(source_dir, 'main.v') + interop_test_must_write_file(source_file, 'module main\n\nfn helper() {}\n\nfn main() {\n\thelper()\n}\n') + mut app := &App{ + temp_dir: os.join_path(root, 'work') + diagnostics_enabled: true + diagnostics_scheduler: new_diagnostics_scheduler() + } + return app, path_to_uri(source_file), root +} + +// rush_test_schedule leaves the two jobs one change to `uri` schedules pending: +// the fast tier due at `fast_ready_at` and the slow one at `slow_ready_at`, both +// from one generation, as begin_project_mutation builds them. +fn rush_test_schedule(mut scheduler DiagnosticsScheduler, mut app App, uri string, project_key string, fast_ready_at i64, slow_ready_at i64) { + global_generation, generation := scheduler.next_generation(uri) + base := DiagnosticsJob{ + uri: uri + content: 'x' + project_key: project_key + global_generation: global_generation + generation: generation + write_mutex: app.write_mutex + } + scheduler.enqueue(DiagnosticsJob{ + ...base + ready_at: fast_ready_at + kind: .fast + }) + scheduler.enqueue(DiagnosticsJob{ + ...base + ready_at: slow_ready_at + kind: .slow + }) +} + +// rush_test_pending_job returns the pending job of `uri` and `kind`, so a test +// can read the deadline a rush left behind. +fn rush_test_pending_job(mut scheduler DiagnosticsScheduler, uri string, kind DiagnosticsKind) ?DiagnosticsJob { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + return scheduler.pending_jobs[diagnostics_job_key(uri, kind)] or { return none } +} + +fn rush_test_scheduler(mut app App) &DiagnosticsScheduler { + return app.diagnostics_scheduler or { panic('the rush test app needs a diagnostics scheduler') } +} + +fn test_rush_starts_a_waiting_slow_job_on_the_next_poll() { + mut app, uri, root := rush_test_app('vls_rush_idle') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + // The change of the last keystroke, whose fast answer has published. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + assert published[0].kind == .fast, 'the fast tier publishes first' + scheduler.finish(published[0]) + + // A question about the program while the slow check still waits. + app.rush_pending_diagnostics(uri) + + rushed := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is still pending' + return + } + assert rushed.ready_at <= time.now().unix_milli(), 'the read moved the deadline to now' + jobs, should_stop := scheduler.take_ready_jobs(time.now().unix_milli()) + assert !should_stop, 'the worker keeps the job it was handed' + assert jobs.len == 1, 'the worker takes the flushed job on its next poll' + assert jobs[0].uri == uri, 'the flushed job is the one the file asked for' + assert jobs[0].kind == .slow, 'the slow tier answers the question' + scheduler.finish(jobs[0]) +} + +fn test_rush_does_nothing_while_the_worker_runs_a_job() { + mut app, uri, root := rush_test_app('vls_rush_busy') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + + // A second change: the worker runs its fast answer, and its slow check waits. + other_uri := '${uri}~2' + rush_test_schedule(mut scheduler, mut app, other_uri, project_key, now, now + 2 * diagnostics_slow_debounce_ms) + active, _ := scheduler.take_ready_jobs(now) + assert active.len == 1, "the worker takes the second change's fast answer" + assert active[0].uri == other_uri, 'the running job belongs to the second change' + + app.rush_pending_diagnostics(uri) + + waiting := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job of the first change is still pending' + return + } + assert waiting.ready_at == now + diagnostics_slow_debounce_ms, 'a busy worker is not jumped' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'no second job starts behind the running one' + scheduler.finish(active[0]) + later, _ := scheduler.take_ready_jobs(now + diagnostics_slow_debounce_ms) + assert later.len == 1, 'the waiting job runs at its own deadline' + assert later[0].uri == uri, 'the job that waited is the one that runs' + assert later[0].kind == .slow, 'the slow tier answers it' + scheduler.finish(later[0]) +} + +fn test_rush_is_a_noop_without_a_pending_job() { + mut app, uri, root := rush_test_app('vls_rush_noop') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + + // Nothing is pending at all. + app.rush_pending_diagnostics(uri) + jobs, should_stop := scheduler.take_ready_jobs(now) + assert jobs.len == 0, 'an empty queue stays empty' + assert should_stop, 'the worker has nothing left to do' + + // The change of the last keystroke, whose fast answer has published. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + published, _ := scheduler.take_ready_jobs(now) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + app.diagnostics_enabled = false + app.rush_pending_diagnostics(uri) + app.diagnostics_enabled = true + waiting := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert waiting.ready_at == now + diagnostics_slow_debounce_ms, 'switched-off diagnostics are not flushed' + + // A session with no scheduler has nothing to ask and nothing to flush. + app.diagnostics_scheduler = none + app.rush_pending_diagnostics(uri) + app.diagnostics_scheduler = scheduler + untouched := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is still pending' + return + } + assert untouched.ready_at == now + diagnostics_slow_debounce_ms, 'a session without a scheduler flushes nothing' +} + +fn test_rush_leaves_a_job_whose_generation_is_stale() { + mut app, uri, root := rush_test_app('vls_rush_stale') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + deadline := time.now().unix_milli() + diagnostics_slow_debounce_ms + rush_test_schedule(mut scheduler, mut app, uri, project_key, deadline, deadline) + published, _ := scheduler.take_ready_jobs(deadline) + assert published.len == 1, 'the worker takes the job that is due' + scheduler.finish(published[0]) + // A newer change supersedes the pending slow job: the generation checks, + // and not the deadline, decide what may run. + scheduler.next_generation(uri) + pending := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert !scheduler.is_job_current(pending), 'a newer change supersedes the pending job' + + app.rush_pending_diagnostics(uri) + + superseded := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the superseded job is still queued' + return + } + assert superseded.ready_at == deadline, 'a stale generation is not rushed' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'the superseded job is not taken early either' +} + +fn test_rush_leaves_the_slow_job_behind_a_waiting_fast_job() { + mut app, uri, root := rush_test_app('vls_rush_order') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + rush_test_schedule(mut scheduler, mut app, uri, project_key, now + diagnostics_fast_debounce_ms, + now + diagnostics_slow_debounce_ms) + + app.rush_pending_diagnostics(uri) + + slow := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert slow.ready_at == now + diagnostics_slow_debounce_ms, 'a fast job still waiting holds the slow one back' + jobs, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert jobs.len == 0, 'neither tier is due yet, so nothing publishes' + fast, _ := scheduler.take_ready_jobs(now + diagnostics_fast_debounce_ms) + assert fast.len == 1, 'the fast tier publishes at its own deadline' + assert fast[0].kind == .fast, 'the fast tier is the one that publishes' + scheduler.finish(fast[0]) + answer, _ := scheduler.take_ready_jobs(now + diagnostics_slow_debounce_ms) + assert answer.len == 1, 'the slow answer follows the fast one' + assert answer[0].kind == .slow, 'the slow tier is the one that follows' + scheduler.finish(answer[0]) +} + +fn test_rush_publishes_the_fast_tier_before_the_flushed_slow_one() { + mut app, uri, root := rush_test_app('vls_rush_order_due') + defer { + os.rmdir_all(root) or {} + } + mut scheduler := rush_test_scheduler(mut app) + project_key := 'file:///rush_project' + now := time.now().unix_milli() + // The fast answer of the change is due and not yet taken, and the slow one + // is still waiting out its debounce. + rush_test_schedule(mut scheduler, mut app, uri, project_key, now, now + diagnostics_slow_debounce_ms) + + app.rush_pending_diagnostics(uri) + + rushed := rush_test_pending_job(mut scheduler, uri, .slow) or { + assert false, 'the slow job is pending' + return + } + assert rushed.ready_at <= time.now().unix_milli(), 'the read flushed the slow job' + first, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert first.len == 1, 'the worker takes one job at a time' + assert first[0].kind == .fast, 'the fast tier publishes before the flushed slow one' + scheduler.finish(first[0]) + second, _ := scheduler.take_ready_jobs(time.now().unix_milli()) + assert second.len == 1, 'the flushed job follows the fast one' + assert second[0].kind == .slow, 'the slow answer supersedes the fast one' + assert second[0].uri == uri, 'the flushed job is the one the file asked for' + scheduler.finish(second[0]) +} diff --git a/main.v b/main.v index f9a38178..9890f133 100644 --- a/main.v +++ b/main.v @@ -56,7 +56,8 @@ mut: exit_was_requested bool // True when the exit notification was received received_initialize bool // True after initialize request was processed next_request_id int = 1 // Counter for server-initiated request ids - diagnostics_scheduler ?&DiagnosticsScheduler // Production-only async diagnostics + diagnostics_scheduler ?&DiagnosticsScheduler // Production-only async diagnostics + index_refresh ?&IndexRefreshScheduler // Production-only background index refresh (see index.v) diagnostics_servers &DiagnosticsServerPool = unsafe { nil } // Compilers answering checks from one process (see diagnostics_server.v) v3_line_info_enabled bool // Whether V3 answers `-line-info` questions first (see v3_line_info.v); off in tests v3_one_shot_unsupported bool // The V in use has no V3 that answers `-line-info` in a process of its own @@ -71,10 +72,15 @@ mut: run_command_manager ?&RunCommandManager // Async code-lens process lifecycle execute_commands_synchronously bool // Test hook for deterministic command assertions write_mutex &sync.Mutex = sync.new_mutex() // Serializes worker and request-loop writes - importable_modules_cache map[string]ImportableModulesCache // Modules a file can import, per project root (see module_imports.v) - vlib_modules_cache map[string][]ImportableModule // The modules of vlib, per vlib folder - module_imports_cache map[string]ModuleImports // The modules each module folder imports (see module_imports.v) - builtin_calls_cache map[string]map[string]Detail // V's builtin functions as completion items, per vlib/builtin folder + // What the compiler's answer cost, per document and method: below the budget + // in answer_budget.v the answer is waited for instead of answered twice. + compiler_answer_ms map[string]i64 + // The threads that replace an index answer with the compiler's (see answer_budget.v). + answer_followups &sync.WaitGroup = sync.new_waitgroup() + importable_modules_cache map[string]ImportableModulesCache // Modules a file can import, per project root (see module_imports.v) + vlib_modules_cache map[string][]ImportableModule // The modules of vlib, per vlib folder + module_imports_cache map[string]ModuleImports // The modules each module imports (see module_imports.v) + builtin_calls_cache map[string]map[string]Detail // V's builtin functions as completion items, per vlib/builtin folder } struct JsonError { @@ -315,6 +321,7 @@ fn main() { open_files: map[string]string{} temp_dir: temp_dir diagnostics_scheduler: new_diagnostics_scheduler() + index_refresh: new_index_refresh_scheduler() v3_line_info_enabled: true } // os.File.read uses C fread, which waits for the entire buffer on an open @@ -391,8 +398,8 @@ fn handle_tcp_client(mut conn net.TcpConn) { text: '' open_files: map[string]string{} temp_dir: temp_dir - tcp_conn: &conn diagnostics_scheduler: new_diagnostics_scheduler() + index_refresh: new_index_refresh_scheduler() v3_line_info_enabled: true } mut reader := io.new_buffered_reader(reader: conn, cap: transport_buffer_cap) @@ -725,10 +732,17 @@ fn (mut app App) handle_requests[T](mut reader T) { reading.wait() app.cancel_all_scheduled_diagnostics() app.stop_run_commands() + // A deferred answer asks through the pooled compilers and writes on the + // transport, so both outlive the request that scheduled it and are joined + // before the session stops them. + app.wait_answer_followups() // However the session ends, its compilers end, and the files they // checked go: no other session uses them. app.stop_diagnostics_servers() app.stop_v3_queries() + // A background refresh must not still be walking the workspace after + // the session it was indexing for is gone. + app.stop_index_refresh() } mut pending := []IncomingMessage{} for { @@ -832,7 +846,7 @@ fn (mut app App) handle_requests[T](mut reader T) { } if method_requires_response(method) && app.request_is_cancelled(lsp_request.id) { app.write_error_response(make_cancelled_error_response(lsp_request.id)) - app.consume_cancelled_request(lsp_request.id) + app.consume_cancelled_request(lsp_request.id, app.current_request_raw_id) continue } // After shutdown, reject all requests except exit. @@ -1062,6 +1076,7 @@ fn (mut app App) handle_requests[T](mut reader T) { // leave the persistent compilers running. app.stop_diagnostics_servers() app.stop_v3_queries() + app.stop_index_refresh() app.exit_was_requested = true break } @@ -1529,6 +1544,7 @@ fn (mut app App) accept_shutdown(id int) { app.stop_run_commands() app.stop_diagnostics_servers() app.stop_v3_queries() + app.stop_index_refresh() app.is_shutdown = true app.write_response(Response{ id: id @@ -2152,25 +2168,42 @@ fn (mut app App) on_initialized(_ Request) { // write_response_or_cancelled sends a cancelled error if the request was // cancelled while being processed; otherwise it sends the normal response. fn (mut app App) write_response_or_cancelled(id int, response Response) { - if app.consume_cancelled_request(id) { - app.write_error_response(make_cancelled_error_response(id)) + app.write_response_for(id, app.current_request_raw_id, response) +} + +// write_deferred_response sends the answer that follows the one already sent for +// a request whose turn is over (see answer_budget.v). It is +// write_response_or_cancelled for such a request: the raw id of that request +// travels with the answer, because both the id echoed and the cancellation +// record read must belong to it and not to whichever request the loop is +// handling by the time the answer is ready. +fn (mut app App) write_deferred_response(id int, raw_id string, response Response) { + app.write_response_for(id, raw_id, response) +} + +// write_response_for sends `response` for the request whose numeric id is `id` +// and whose raw JSON id is `raw_id`, or a cancelled error for it. +fn (mut app App) write_response_for(id int, raw_id string, response Response) { + if app.consume_cancelled_request(id, raw_id) { + app.send_framed(inject_raw_id(encode_error_response_payload(make_cancelled_error_response(id)), + raw_id)) return } // Defensive: catch response/request id mismatches caused by programming errors. if response.id != id { - app.write_error_response(make_internal_error_response(id, 'Response id mismatch: expected ${id}, got ${response.id}')) + app.send_framed(inject_raw_id(encode_error_response_payload(make_internal_error_response(id, + 'Response id mismatch: expected ${id}, got ${response.id}')), raw_id)) return } - app.write_response(response) + app.send_framed(inject_raw_id(encode_response_payload(response), raw_id)) } -fn (mut app App) consume_cancelled_request(id int) bool { - raw := app.current_request_raw_id +fn (mut app App) consume_cancelled_request(id int, raw_id string) bool { mut was_cancelled := false - if raw != '' { + if raw_id != '' { // Consume every request by its exact raw id when it is available. - if raw in app.cancelled_raw_ids { - app.cancelled_raw_ids.delete(raw) + if raw_id in app.cancelled_raw_ids { + app.cancelled_raw_ids.delete(raw_id) was_cancelled = true } return was_cancelled From 0c4b8ea601876d72d663e3104c39eb24f70c18b9 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 16:13:52 +0330 Subject: [PATCH 38/83] perf: refresh the index in the background, answer requests from it as it stands hover, workspace/symbol, references, prepareRename and call hierarchy no longer pay a full index refresh on the request thread. A request reindexes the open buffers and merges a finished refresh, then schedules one background walk that parses into a private result and is applied on the request thread, so a reader never sees a map being written. A burst of requests coalesces into one refresh, watcher events and every write path stay synchronous, and shutdown joins the worker with no dropped work. --- call_hierarchy.v | 5 +- index.v | 472 ++++++++++++++++++++++++++++++++++++++++++++++- index_test.v | 230 +++++++++++++++++++++++ 3 files changed, 700 insertions(+), 7 deletions(-) diff --git a/call_hierarchy.v b/call_hierarchy.v index 0d82630e..05ec2977 100644 --- a/call_hierarchy.v +++ b/call_hierarchy.v @@ -224,8 +224,9 @@ fn find_fn_in_content(fn_name string, content string, uri string, enc PositionEn // find_fn_declaration resolves a function/method named `fn_name` from the // persistent symbol index, restricted to `search_dirs`. fn (mut app App) find_fn_declaration(fn_name string, search_dirs []string, include_tests bool) CallHierarchyItem { - // Answer from the persistent symbol index instead of re-scanning every file. - app.ensure_dirs_indexed(search_dirs) + // Call hierarchy is a read request: it answers from the persistent symbol + // index and leaves the walk of a stale index to the background worker. + app.ensure_indexed_for_request(search_dirs) // Prefer a declaration in the primary (current module) directory, then widen // to the full search set; never look outside the supplied dirs, so a // same-named function in an unrelated indexed workspace root is not returned. diff --git a/index.v b/index.v index 5f53720d..2f513545 100644 --- a/index.v +++ b/index.v @@ -3,6 +3,7 @@ module main import os +import sync import time // Persistent, incremental symbol index (audit Stage 4). @@ -378,13 +379,21 @@ fn index_uri_for_path(path string, open_uris_by_path map[string]string) string { // explicitly removed by the client. A currently active root takes precedence, // allowing a nested folder to be added again under a removed parent. fn (app &App) path_is_in_removed_workspace(path string) bool { + return path_is_under_removed_root(path, app.workspace_roots, app.removed_workspace_roots) +} + +// path_is_under_removed_root is path_is_in_removed_workspace for a snapshot of +// the roots. The background index worker carries one: the request thread may +// replace the App's root slices at any time, and the worker must not read a +// slice while that happens. +fn path_is_under_removed_root(path string, active_roots []string, removed_roots []string) bool { p := path.replace('\\', '/') - for root in app.workspace_roots { + for root in active_roots { if path_is_within(p, root.replace('\\', '/')) { return false } } - for root in app.removed_workspace_roots { + for root in removed_roots { if path_is_within(p, root.replace('\\', '/')) { return true } @@ -843,7 +852,438 @@ fn uri_is_in_index_scope(uri string, scope IndexScope) bool { return path_is_in_index_scope(uri_to_path(uri), scope) } -// ensure_index_scope indexes only the project/module relevant to a request. +// A read request must not pay for the refresh it finds due. A hover, a +// workspace/symbol, a reference or a prepareRename used to walk the workspace +// on the request thread; they now answer from the index as it stands and leave +// the rebuild to one background worker. The worker walks and parses, but it +// never writes the shared index: a request handler may be reading it, and V +// maps are not safe to read while another thread inserts. Its result is merged +// by the request thread at a point where nothing else is reading the index +// (apply_index_refresh_results), which is the same split the diagnostics worker +// uses when it publishes through the scheduler's mutex. + +// IndexRefreshJob is one directory a read request found stale, carrying the +// state the refresh needs. That state is snapshotted on the request thread, +// because the worker must reach the index only through this job: the index it +// rebuilds is the one the request handlers read. +struct IndexRefreshJob { + dir string + indexed_count int // entries the index held, for the total entry cap + fingerprints map[string]int // uri -> fingerprint of the entry held + open_uris map[string]bool // open buffers, which the request thread indexes + open_uris_by_path map[string]string + workspace_roots []string + removed_roots []string + started_ms i64 +} + +// IndexRefreshResult is what one refresh walk decided. Nothing in it refers to +// the App, so the request thread can merge it after the worker has stopped. +struct IndexRefreshResult { +mut: + dir string + started_ms i64 + entries map[string]IndexEntry // built from the content the walk read + deletions []string // indexed uris whose file the walk no longer found + skipped []string // files left out of the bounded index + reindexed int // files this walk parsed + skipped_files int // files whose fingerprint was unchanged + incomplete bool + walk_ms i64 +} + +// IndexRefreshScheduler owns the single background index worker. It mirrors +// DiagnosticsScheduler: one worker at a time, a done channel the worker closes, +// and a stop that joins it. +@[heap] +struct IndexRefreshScheduler { +mut: + mutex sync.Mutex + pending []IndexRefreshJob + // outstanding holds every directory with a refresh queued, running, or + // finished and awaiting its merge. A directory leaves it when the request + // thread merges the refresh's result, which is what makes a directory's + // refresh happen once per stale generation however many requests asked. + outstanding map[string]bool + results []IndexRefreshResult + worker_running bool + worker_started bool + worker_done chan bool + stopped bool + workers_started u64 + refreshes u64 + reindexed_files u64 + skipped_files u64 +} + +fn new_index_refresh_scheduler() &IndexRefreshScheduler { + return &IndexRefreshScheduler{ + outstanding: map[string]bool{} + } +} + +// enqueue records a job and reports whether the caller must start the single +// background worker. A directory that is outstanding is not queued again, so a +// burst of requests coalesces into one refresh, and the one worker ends when +// the queue runs dry instead of lingering. +fn (mut scheduler IndexRefreshScheduler) enqueue(job IndexRefreshJob) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.stopped || scheduler.outstanding[job.dir] { + return false + } + scheduler.outstanding[job.dir] = true + scheduler.pending << job + should_start := !scheduler.worker_running + if should_start { + scheduler.worker_started = true + scheduler.worker_done = chan bool{} + scheduler.workers_started++ + } + scheduler.worker_running = true + return should_start +} + +// is_outstanding reports whether a refresh of `dir` is already queued, running, +// or finished and awaiting its merge. The request thread asks first, so a burst +// of requests builds one job snapshot instead of one per request. +fn (mut scheduler IndexRefreshScheduler) is_outstanding(dir string) bool { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + return scheduler.outstanding[dir] +} + +// take_next_job removes the scope to refresh next, and reports that there is +// none by stopping the worker, exactly as the diagnostics scheduler does. +fn (mut scheduler IndexRefreshScheduler) take_next_job() ?IndexRefreshJob { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.pending.len == 0 { + scheduler.worker_running = false + return none + } + job := scheduler.pending[0] + scheduler.pending.delete(0) + return job +} + +// publish_result records a finished refresh, which the request thread merges. +fn (mut scheduler IndexRefreshScheduler) publish_result(result IndexRefreshResult) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + scheduler.results << result + scheduler.refreshes++ + scheduler.reindexed_files += u64(result.reindexed) + scheduler.skipped_files += u64(result.skipped_files) +} + +// take_results hands the finished refreshes over, clears them, and releases the +// directories they covered so a later request can refresh them again. +fn (mut scheduler IndexRefreshScheduler) take_results() []IndexRefreshResult { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if scheduler.results.len == 0 { + return []IndexRefreshResult{} + } + for result in scheduler.results { + scheduler.outstanding.delete(result.dir) + } + finished := scheduler.results.clone() + scheduler.results.clear() + return finished +} + +// stop_and_wait joins the background index worker, so the process cannot exit +// while a refresh is still reading files. A refresh that is still queued is not +// dropped: the worker drains it before it ends, so what the index is left with +// does not depend on when the shutdown happened to arrive. This is +// DiagnosticsScheduler's stop_and_wait for the index worker; the diagnostics one +// is untouched. +fn (mut scheduler IndexRefreshScheduler) stop_and_wait() { + scheduler.mutex.lock() + scheduler.stopped = true + started := scheduler.worker_started + done := scheduler.worker_done + scheduler.mutex.unlock() + if started { + _ := <-done or {} + } +} + +// ensure_indexed_for_request is what a read request calls instead of +// ensure_dirs_indexed. It answers from the index as it stands: not one directory +// is listed on the request thread, and the refresh of whatever is stale is left +// to the background worker, whose result a later request merges. +fn (mut app App) ensure_indexed_for_request(dirs []string) { + // Open buffers are authoritative, and didOpen/didChange invalidate their + // entry, so refresh them here. That is one fingerprint comparison per open + // file: no directory walk, and no parse of unchanged content. + for uri, _ in app.open_files { + app.reindex_uri(uri) + } + // A refresh that already finished is merged before this request answers, so + // the request after the one that scheduled it sees what it asked for. + app.apply_index_refresh_results() + app.schedule_index_refresh(app.stale_index_dirs(dirs)) +} + +// stale_index_dirs returns the project directories a read request needs indexed: +// those that were never walked, or whose throttle has expired because the client +// has no file watchers (see index_dir_needs_refresh). +fn (mut app App) stale_index_dirs(dirs []string) []string { + mut stale := []string{} + for dir in dirs { + if dir == '' || dir == '/' || app.path_is_in_removed_workspace(dir) || !os.is_dir(dir) { + continue + } + if dir in app.indexed_dirs && !app.index_dir_needs_refresh(dir) { + continue + } + stale << dir + } + return stale +} + +// schedule_index_refresh queues `dirs` for the single background worker. Without +// a scheduler — every App built by a test, and none other — the refresh runs on +// the request thread instead, which is what these paths did before. +fn (mut app App) schedule_index_refresh(dirs []string) { + if dirs.len == 0 { + return + } + if mut scheduler := app.index_refresh { + for dir in dirs { + if scheduler.is_outstanding(dir) { + continue + } + job := app.index_refresh_job(dir) + if scheduler.enqueue(job) { + spawn run_index_refresh_worker(mut app, mut scheduler) + } + } + return + } + app.ensure_dirs_indexed(dirs) +} + +// index_refresh_job snapshots the index state a refresh of `dir` needs. It runs +// on the request thread, so the snapshot is consistent. +fn (app &App) index_refresh_job(dir string) IndexRefreshJob { + mut fingerprints := map[string]int{} + for uri, entry in app.symbol_index { + fingerprints[uri] = entry.fingerprint + } + mut open_uris := map[string]bool{} + for uri, _ in app.open_files { + open_uris[uri] = true + } + return IndexRefreshJob{ + dir: dir + indexed_count: app.symbol_index.len + fingerprints: fingerprints + open_uris: open_uris + open_uris_by_path: app.open_index_uris_by_path() + workspace_roots: app.workspace_roots.clone() + removed_roots: app.removed_workspace_roots.clone() + started_ms: time.now().unix_milli() + } +} + +// run_index_refresh_worker is the single background index worker. It refreshes +// one directory at a time and returns once the queue is empty, so a burst of +// requests costs one thread that ends with the work. +fn run_index_refresh_worker(mut app App, mut scheduler IndexRefreshScheduler) { + scheduler.mutex.lock() + done := scheduler.worker_done + scheduler.mutex.unlock() + defer { + done.close() + } + for { + if job := scheduler.take_next_job() { + scheduler.publish_result(app.run_recursive_index_refresh(job)) + continue + } + return + } +} + +// run_recursive_index_refresh is ensure_dirs_indexed for one directory, with the +// writes collected into a result instead of applied. It reads the filesystem and +// parses, and it touches no App state. +fn (mut app App) run_recursive_index_refresh(job IndexRefreshJob) IndexRefreshResult { + mut result := IndexRefreshResult{ + dir: job.dir + started_ms: job.started_ms + walk_ms: time.now().unix_milli() + } + mut files := []string{} + walk_complete := collect_v_files(job.dir, mut files) + result.incomplete = !walk_complete + mut entries := map[string]IndexEntry{} + mut present := map[string]bool{} + for f in files { + present[index_uri_for_path(f, job.open_uris_by_path)] = true + } + // Reconcile before the file loop, while `walk_complete` still says whether + // every file of the directory was seen: an incomplete walk could simply not + // have reached a file that is still there (see reconcile_indexed_dir). + if walk_complete { + dir_norm := job.dir.replace('\\', '/') + for uri, _ in job.fingerprints { + if uri in present || uri in job.open_uris { + continue + } + if path_is_within(uri_to_path(uri).replace('\\', '/'), dir_norm) { + result.deletions << uri + } + } + } + for f in files { + uri := index_uri_for_path(f, job.open_uris_by_path) + if uri in job.open_uris { + continue + } + if app.index_refresh_file(uri, job, mut result, mut entries) { + break + } + } + result.entries = entries.move() + return result +} + +// index_refresh_file decides what a refresh does with the file at `uri`: build +// its entry, skip it because its content fingerprint is unchanged or it is out +// of the index's bounds, or drop it because it is gone. It returns true when the +// walk must stop, which is only at the total entry cap. +fn (mut app App) index_refresh_file(uri string, job IndexRefreshJob, mut result IndexRefreshResult, mut entries map[string]IndexEntry) bool { + path := uri_to_path(uri) + if path_is_under_removed_root(path, job.workspace_roots, job.removed_roots) { + result.deletions << uri + return false + } + if !os.is_file(path) { + result.deletions << uri + return false + } + if os.file_size(path) > index_max_file_bytes { + result.skipped << uri + return false + } + content := os.read_file(path) or { + result.skipped << uri + return false + } + fingerprint := content.hash() + if previous := job.fingerprints[uri] { + if previous == fingerprint { + result.skipped_files++ + return false + } + } + if uri !in job.fingerprints && job.indexed_count + entries.len >= index_max_files { + result.incomplete = true + result.skipped << uri + return true + } + entries[uri] = build_index_entry(content, app.position_encoding) + result.reindexed++ + return false +} + +// apply_index_refresh_results merges the refreshes the background worker +// finished. It runs on the request thread, where no other thread is reading the +// index, which is why the worker never writes the index itself. +fn (mut app App) apply_index_refresh_results() { + mut scheduler := app.index_refresh or { return } + for mut result in scheduler.take_results() { + app.merge_index_refresh(mut result) + } +} + +fn (mut app App) merge_index_refresh(mut result IndexRefreshResult) { + dir := result.dir + scope := 'recursive:${dir}' + if dir == '' || dir == '/' || app.path_is_in_removed_workspace(dir) { + // A root removed while the refresh ran: nothing it found belongs here. + return + } + if !os.is_dir(dir) { + // The folder vanished during the walk, so the walk found nothing in it, + // which is what a complete walk of it would have recorded. + result.incomplete = true + } + if result.entries.len > 0 { + // Pre-size once, for the same reason ensure_dirs_indexed does. + app.symbol_index.reserve(u32(index_reserve_target(app.symbol_index.len, result.entries.len))) + } + for uri, entry in result.entries { + if uri in app.open_files { + continue // the request thread indexes open buffers itself + } + app.symbol_index[uri] = entry + } + for uri in result.skipped { + if uri in app.open_files { + continue + } + app.drop_index_uri(uri) + app.index_skipped_uris[uri] = true + } + for uri in result.deletions { + if uri in app.open_files { + continue + } + app.drop_index_uri(uri) + app.index_skipped_uris.delete(uri) + } + app.indexed_dirs[dir] = true + if result.walk_ms > 0 { + app.indexed_dir_walk_ms[dir] = result.walk_ms + } + if result.incomplete { + app.index_incomplete_scopes[scope] = true + } else { + app.index_incomplete_scopes.delete(scope) + } + if os.getenv('VLS_PERF_LOG') != '' { + elapsed_ms := time.now().unix_milli() - result.started_ms + app.send_log_message('index-refresh scope=${scope} indexed=${result.reindexed} skipped=${result.skipped_files} deleted=${result.deletions.len} elapsed_ms=${elapsed_ms}', + 4) + } +} + +// stop_index_refresh joins the background index worker, so the process cannot +// exit while a refresh is still reading files. It is the equivalent of +// stop_diagnostics_servers for the index worker. +fn (mut app App) stop_index_refresh() { + if mut scheduler := app.index_refresh { + scheduler.stop_and_wait() + } +} + +// index_scope_key names a scope the way the index already stores it: a +// recursive walk of a project root and a shallow walk of a loose module +// directory are different scopes, even for the same directory. +fn index_scope_key(scope IndexScope) string { + kind := if scope.recursive { 'recursive' } else { 'shallow' } + return '${kind}:${scope.dir}' +} + +// ensure_index_scope indexes only the project/module relevant to a request, on +// the thread that asked. A destructive request (rename, linked editing) must +// not answer before its own scope is indexed; a read request uses +// ensure_index_scope_for_request instead. fn (mut app App) ensure_index_scope(scope IndexScope) { if scope.dir == '' || scope.dir == '/' { return @@ -852,6 +1292,13 @@ fn (mut app App) ensure_index_scope(scope IndexScope) { app.ensure_dirs_indexed([scope.dir]) return } + app.ensure_shallow_scope_indexed(scope) +} + +// ensure_shallow_scope_indexed indexes the open buffers of `scope` and the +// directory itself, shallowly: a V module occupies one directory, so this is +// all a same-module lookup needs and it costs one listing. +fn (mut app App) ensure_shallow_scope_indexed(scope IndexScope) { for uri, _ in app.open_files { if uri_is_in_index_scope(uri, scope) { app.reindex_uri(uri) @@ -860,14 +1307,29 @@ fn (mut app App) ensure_index_scope(scope IndexScope) { app.ensure_dir_shallow_indexed(scope.dir) } +// ensure_index_scope_for_request is the read-request answer to +// ensure_index_scope: the open buffers are refreshed (a fingerprint check per +// open file, no walk), and the scope's walk is left to the background worker. +// A shallow scope stays on the request thread, because it has always been the +// cost of one directory listing. +fn (mut app App) ensure_index_scope_for_request(scope IndexScope) { + if scope.dir == '' || scope.dir == '/' { + return + } + if scope.recursive { + app.ensure_indexed_for_request([scope.dir]) + return + } + app.ensure_shallow_scope_indexed(scope) +} + // index_is_complete_for_scope reports whether every source relevant to a // destructive operation in `scope` was indexed. fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { if scope.dir == '' { return false } - scope_kind := if scope.recursive { 'recursive' } else { 'shallow' } - if '${scope_kind}:${scope.dir}' in app.index_incomplete_scopes { + if index_scope_key(scope) in app.index_incomplete_scopes { return false } for uri, _ in app.index_skipped_uris { diff --git a/index_test.v b/index_test.v index c878b0d7..43627b68 100644 --- a/index_test.v +++ b/index_test.v @@ -1334,3 +1334,233 @@ fn test_fuzzy_trigram_cache_caps_long_names() { assert cached.len == fuzzy_trigram_max_per_symbol, 'cached trigrams of a long name are capped, got ${cached.len}' assert app.query_workspace_symbols(long_name).any(it.name == long_name), 'capped long name is still found exactly' } + +// index_request_refresh_app returns an app whose read requests answer from the +// index and leave a refresh to the background worker, over `root` as its project. +fn index_request_refresh_app(root string) (&App, &IndexRefreshScheduler) { + mut app := index_test_app() + mut scheduler := new_index_refresh_scheduler() + app.index_refresh = scheduler + app.supports_dynamic_watched_files_registration = false // no client watchers + app.ensure_dirs_indexed([root]) + return app, scheduler +} + +fn test_indexed_for_request_answers_from_the_index_as_it_stands() { + root := index_test_tmpdir('request_nowalk') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + assert app.query_workspace_symbols('alpha').len == 1, 'the project walk indexed a.v' + assert app.query_workspace_symbols('beta').len == 0, 'beta does not exist yet' + + // A file created after the walk makes the directory stale for the next + // request: its throttle has expired and it would be re-walked. + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + + app.ensure_indexed_for_request([root]) + assert app.query_workspace_symbols('alpha').len == 1, 'the already-indexed symbol is still answered from the index' + assert app.query_workspace_symbols('beta').len == 0, 'a read request must not walk the workspace: it answered with the symbol of the file it had not indexed' + + // The refresh did run, on the background worker: joining it and merging its + // result is what makes the new file visible. + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'the request left exactly one refresh for the worker, got ${scheduler.refreshes}' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the background refresh indexed the file it found' + assert app.query_workspace_symbols('alpha').len == 1, 'the untouched file keeps its entry' +} + +fn test_indexed_for_request_coalesces_a_burst_of_requests() { + root := index_test_tmpdir('request_burst') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 // a refresh is due on every one of these + + for _ in 0 .. 20 { + app.ensure_indexed_for_request([root]) + } + assert scheduler.workers_started == 1, 'a burst of 20 requests started ${scheduler.workers_started} workers' + + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'a burst of 20 requests ran ${scheduler.refreshes} refreshes' + assert scheduler.pending.len == 0, 'the queue must be drained when the worker ends' + assert scheduler.reindexed_files == 1, 'the one refresh indexed the one new file, got ${scheduler.reindexed_files}' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the one refresh merged the one new file' + + // A stopped scheduler starts no thread again, even for a directory that went + // stale again afterwards. + app.indexed_dir_walk_ms[root] = 0 + for _ in 0 .. 10 { + app.ensure_indexed_for_request([root]) + } + assert scheduler.workers_started == 1, 'a stopped scheduler starts no further worker, ${scheduler.workers_started} started' + assert app.query_workspace_symbols('alpha').len == 1, 'the index still answers from what the one refresh merged' +} + +fn test_watcher_event_still_indexes_on_the_request_thread() { + root := index_test_tmpdir('watcher_sync') + defer { + os.rmdir_all(root) or {} + } + // The scheduler is in place, so only the watcher's own path can explain the + // file being indexed without waiting for the worker. + _, scheduler := index_request_refresh_app(root) + path := os.join_path(root, 'watched.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn watched_symbol() {}\n') or { + assert false, 'write watched.v failed: ${err}' + return + } + mut app := index_test_app() + app.index_refresh = scheduler + + app.on_did_change_watched_files(Request{ + params: json2.encode(DidChangeWatchedFilesParams{ + changes: [FileEvent{ + uri: uri + event_type: 1 + }] + }) + }) + + assert uri in app.symbol_index, 'a watcher event indexes the file on the thread it arrived on' + assert app.query_workspace_symbols('watched_symbol').len == 1, 'a watcher event needs no background refresh to be visible' + assert scheduler.workers_started == 0, 'a watcher event schedules no background refresh, ${scheduler.workers_started} started' +} + +fn test_shutdown_joins_the_background_index_worker() { + root := index_test_tmpdir('shutdown_join') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + assert app.query_workspace_symbols('beta').len == 0, 'the request answered before the refresh landed' + + app.stop_index_refresh() + assert scheduler.refreshes == 1, 'shutdown joined the worker: the refresh had not finished (${scheduler.refreshes} done)' + assert scheduler.results.len == 1, 'the finished refresh is published for the merge' + assert !scheduler.worker_running, 'no worker may outlive the join' + app.apply_index_refresh_results() + assert app.query_workspace_symbols('beta').len == 1, 'the joined refresh left its result to merge' +} + +fn test_background_refresh_skips_unchanged_files_by_fingerprint() { + root := index_test_tmpdir('request_fingerprint') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + os.write_file(os.join_path(root, 'b.v'), 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + uri_a := path_to_uri(os.join_path(root, 'a.v')) + fingerprint_a := app.symbol_index[uri_a].fingerprint + assert app.symbol_index.len == 2, 'both files were indexed, got ${app.symbol_index.len}' + + // A refresh with nothing changed on disk parses nothing: the fingerprint of + // every entry already matches the content on disk, which is what keeps an + // unchanged workspace from being re-parsed on every throttled refresh. + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + app.stop_index_refresh() + app.apply_index_refresh_results() + assert scheduler.refreshes == 1, 'one refresh ran, got ${scheduler.refreshes}' + assert scheduler.skipped_files == 2, 'the refresh skipped both unchanged files, got ${scheduler.skipped_files}' + assert scheduler.reindexed_files == 0, 'no unchanged file was re-parsed, got ${scheduler.reindexed_files}' + assert app.symbol_index.len == 2, 'the index still holds exactly the two entries, got ${app.symbol_index.len}' + assert app.symbol_index[uri_a].fingerprint == fingerprint_a, 'the untouched file keeps its entry' + assert app.query_workspace_symbols('alpha').len == 1, 'the untouched file is still findable' +} + +fn test_background_refresh_reindexes_the_file_that_changed() { + // The control for the skip above: the same background path on the same shape + // of project does pick up an edit to an unopened file, so a skipped file is + // the fingerprint deciding and not a refresh that does nothing. + root := index_test_tmpdir('request_edit') + defer { + os.rmdir_all(root) or {} + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'write v.mod failed: ${err}' + return + } + os.write_file(os.join_path(root, 'a.v'), 'module main\n\nfn alpha() {}\n') or { + assert false, 'write a.v failed: ${err}' + return + } + b_path := os.join_path(root, 'b.v') + os.write_file(b_path, 'module main\n\nfn beta() {}\n') or { + assert false, 'write b.v failed: ${err}' + return + } + mut app, scheduler := index_request_refresh_app(root) + uri_a := path_to_uri(os.join_path(root, 'a.v')) + fingerprint_a := app.symbol_index[uri_a].fingerprint + + os.write_file(b_path, 'module main\n\nfn gamma() {}\n') or { + assert false, 'rewrite b.v failed: ${err}' + return + } + app.indexed_dir_walk_ms[root] = 0 + app.ensure_indexed_for_request([root]) + app.stop_index_refresh() + app.apply_index_refresh_results() + assert scheduler.reindexed_files == 1, 'the edited file was re-parsed, got ${scheduler.reindexed_files}' + assert scheduler.skipped_files == 1, 'the other file was skipped, got ${scheduler.skipped_files}' + assert app.query_workspace_symbols('gamma').len == 1, 'the refreshed index has the new symbol' + assert app.query_workspace_symbols('beta').len == 0, 'the refreshed index dropped the old symbol' + assert app.symbol_index[uri_a].fingerprint == fingerprint_a, 'the untouched file still keeps its entry' + assert app.symbol_index.len == 2, 'the refresh added no entry, got ${app.symbol_index.len}' +} From a24fe4a31de30dcd084e61d6e731c509b7e53575 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 20:40:02 +0330 Subject: [PATCH 39/83] perf: warm rename checks and richer hover Two things. The before and after check of a rename used to start a compiler process of its own while the same copy of the program already had a warm server answering the editor's diagnostics; both now share one pooled question helper, and the one-shot path stays exactly as it was when no server can answer. And hover gained the shape of what it is asked about, all from the index: a struct's fields with their declared types, an enum's variants, a module-qualified variant's value, and a link to a function's declaration file when it is not the one being read. --- handlers.v | 235 ++++++++++++++++++++++- handlers_test.v | 462 +++++++++++++++++++++++++++++++++++++++++++++ hover_extra_test.v | 304 +++++++++++++++++++++++++++++ rename_conflicts.v | 25 ++- v3_line_info.v | 18 ++ 5 files changed, 1025 insertions(+), 19 deletions(-) create mode 100644 hover_extra_test.v diff --git a/handlers.v b/handlers.v index 1aee6f9c..d1e8d61a 100644 --- a/handlers.v +++ b/handlers.v @@ -429,7 +429,7 @@ fn (mut app App) hover_result(uri string, position Position, line_info string) R if answer := app.v3_hover(uri, real_path, line_info) { if location := answer.declared_at { located = true - if hover := app.function_declaration_hover(location) { + if hover := app.function_declaration_hover(uri, location) { return hover } // A local or a parameter V3 knows no type of: nothing else can tell @@ -446,7 +446,7 @@ fn (mut app App) hover_result(uri string, position Position, line_info string) R // function is declared, and V1 what anything else is. if !located { if location := app.resolve_indexed_definition(uri, position) { - if hover := app.function_declaration_hover(location) { + if hover := app.function_declaration_hover(uri, location) { return hover } } @@ -462,8 +462,10 @@ fn (mut app App) hover_result(uri string, position Position, line_info string) R // function_declaration_hover shows the function, the method or the method of an // interface declared at `location` as its declaration writes it, with the // documentation written above it: what a hover shows for its declaration and -// for each of its uses. None when something else is declared there. -fn (mut app App) function_declaration_hover(location Location) ?Hover { +// for each of its uses. `uri` is the document the hover is asked in, so a +// declaration from another file of the project links to it. None when something +// else is declared there. +fn (mut app App) function_declaration_hover(uri string, location Location) ?Hover { content := app.index_source_for(location.uri) or { return none } lines := content.split_into_lines() line := location.range.start.line @@ -479,10 +481,18 @@ fn (mut app App) function_declaration_hover(location Location) ?Hover { return none } doc := extract_doc_comment(lines, line) + link := app.declaration_file_link(uri, location) + mut value := '```v\n${declaration}\n```' + if doc != '' { + value += '\n\n${doc}' + } + if link != '' { + value += '\n\n${link}' + } return Hover{ contents: MarkupContent{ kind: 'markdown' - value: '```v\n${declaration}\n```' + if doc == '' { '' } else { '\n\n${doc}' } + value: value } } } @@ -577,12 +587,117 @@ fn (mut app App) source_hover_fallback(uri string, position Position) ?Hover { if declaration == '' { return none } + // The members of a struct or an enum are the index's to tell, from the same + // file the reads above have just opened. + content := app.index_source_for(location.uri) or { '' } return Hover{ contents: MarkupContent{ kind: 'markdown' - value: '```v\n${declaration}\n```' + value: app.declaration_hover_value(location.uri, content, declaration) + } + } +} + +// hover_member_cap bounds how many members a hover lists of one type, so a +// struct with a hundred fields does not fill the screen. +const hover_member_cap = 20 + +// declaration_hover_value is the markdown a hover on a declaration shows: the +// declaration as the source writes it, and — inside the same fence — the members +// the index knows of a struct or an enum, so hovering the type shows its shape. +fn (mut app App) declaration_hover_value(uri string, content string, declaration string) string { + mut body := declaration + name := declaration_type_name(declaration) + if name != '' { + members := app.hover_type_member_lines(uri, content, name) + if members.len > 0 { + // The declaration the source writes is cut at the `{`, so the shape + // opens it again before listing what is inside. + body += ' {\n\t' + members.join('\n\t') + '\n}' + } + } + return '```v\n${body}\n```' +} + +// declaration_type_name returns the name the type of a `struct` or an `enum` +// declaration header declares, without the type parameters a generic one writes +// after it (`Pair[T]` gives `Pair`), or '' for any other declaration. +fn declaration_type_name(declaration string) string { + mut text := declaration.trim_space() + if text.starts_with('pub ') { + text = text[4..].trim_space() + } + keyword := if text.starts_with('struct ') { + 'struct ' + } else if text.starts_with('enum ') { + 'enum ' + } else { + return '' + } + rest := text[keyword.len..].trim_space() + mut end := 0 + for end < rest.len && (is_ident_char(rest[end]) || rest[end] == `.`) { + end++ + } + name := rest[..end] + if name == '' { + return '' + } + return name.all_after_last('.') +} + +// hover_type_member_lines lists what the index knows of the type `typ` for a +// hover: the fields of a struct as `name Type`, as the source writes the type, +// or the variants of an enum as their names, both in declaration order and both +// capped at hover_member_cap with a tail that says how many were left out. +// Empty when the index names no members of it, which is what makes a hover fall +// back to the declaration alone. +fn (mut app App) hover_type_member_lines(uri string, content string, typ string) []string { + members := app.type_members(uri, content, typ) + mut listed := []string{} + if members.field_declared_types.len > 0 { + for name, declared in members.field_declared_types { + listed << '${name} ${declared}' + } + } else if variants := app.indexed_enum_members(uri, content, typ) { + for variant in variants { + listed << variant.label + } + } + if listed.len == 0 { + return []string{} + } + mut lines := []string{cap: hover_member_cap + 1} + for line in listed { + if lines.len == hover_member_cap { + break } + lines << line + } + if listed.len > hover_member_cap { + lines << '// ... ${listed.len - hover_member_cap} more' + } + return lines +} + +// declaration_file_link is the markdown link to the file a hover's declaration +// lives in, relative to the project root, the way rust-analyzer links a +// declaration in another file. '' when it is the file the hover is asked in, or +// when the two share no project root. +fn (mut app App) declaration_file_link(uri string, location Location) string { + if uri == '' || location.uri == '' || location.uri == uri { + return '' + } + root := find_project_root(os.dir(uri_to_path(uri))) + if root == '' { + return '' + } + relative := path_relative_to(uri_to_path(location.uri).replace('\\', '/'), root.replace('\\', + '/')) or { return '' } + if relative == '' { + return '' } + return '[${relative}](${relative})' } // hovered_variable_name returns the identifier at `position` when it references a @@ -863,6 +978,11 @@ fn (mut app App) member_selector_hover(uri string, position Position) ?Hover { if static_member := app.language_static_hover(uri, content, receiver, name) { return static_member } + // A variant of an enum, `Color.red`, which the compiler says nothing of and + // the index has the declaration of: what it carries after `=`. + if variant := app.enum_variant_hover(uri, content, receiver, name) { + return variant + } typ := app.expression_type(uri, content, receiver, position) if typ == '' { return none @@ -943,6 +1063,109 @@ fn language_member_hover(name string, signature string) Hover { } } +// enum_variant_hover answers for `Type.variant` when `Type` is an enum the index +// knows: the variant with the value its declaration carries. None for anything +// else, and for a variant that declares no value. +fn (mut app App) enum_variant_hover(uri string, content string, receiver string, name string) ?Hover { + if receiver == '' || name == '' || !is_type_name(receiver) { + return none + } + declaration := app.indexed_enum_member_declaration(uri, content, receiver, name) + if declaration == '' { + return none + } + value := declaration_const_value(declaration, name) + if value == '' { + return none + } + return Hover{ + contents: MarkupContent{ + kind: 'markdown' + value: '```v\n${receiver}.${name} = ${value}\n```' + } + } +} + +// indexed_enum_member_declaration returns the line that declares the member +// `name` of the enum `typ`, as the file the index holds it in writes it, or '' +// when the index names no such member. +fn (mut app App) indexed_enum_member_declaration(uri string, content string, typ string, name string) string { + dir, type_name, _, expected_module := app.receiver_type_scope(uri, content, typ) + if dir == '' || type_name == '' || expected_module == '' || !os.is_dir(dir) { + return '' + } + app.ensure_dir_shallow_indexed(dir) + normalized_dir := normalized_index_path(dir) + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir + || entry.module_name != expected_module { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + source_lines := source.split_into_lines() + for symbol in entry.doc_symbols { + if symbol.kind != sym_kind_enum || symbol.name != type_name { + continue + } + for member in symbol.children { + if member.kind != sym_kind_enum_member || member.name != name { + continue + } + if member.range.start.line < 0 || member.range.start.line >= source_lines.len { + return '' + } + return source_lines[member.range.start.line].trim_space() + } + } + } + return '' +} + +// declaration_const_value returns the value the declaration `declaration` +// carries for `name` after its `=`: the literal of a constant (`name = 100`), or +// the whole parenthesised group of an enum variant's value (`name = (1 << 2)`). +// '' when the declaration carries no value of that name. +fn declaration_const_value(declaration string, name string) string { + if name == '' { + return '' + } + mut from := 0 + for from < declaration.len { + relative := declaration[from..].index(name) or { return '' } + start := from + relative + from = start + name.len + if (start > 0 && is_ident_char(declaration[start - 1])) + || (from < declaration.len && is_ident_char(declaration[from])) { + continue + } + mut rest := declaration[from..].trim_left(' \t') + if !rest.starts_with('=') || rest.starts_with('==') { + continue + } + return const_value_text(rest[1..].trim_space()) + } + return '' +} + +// const_value_text is the value written after the `=` of a declaration: up to a +// trailing comment, and through the matching `)` when it is parenthesised. +fn const_value_text(rest string) string { + if rest.starts_with('(') { + close := matching_delimiter(rest, 0, `(`, `)`) + if close > 0 { + return rest[..close + 1] + } + } + mut end := 0 + for end < rest.len && rest[end] !in [` `, `\t`] && !rest[end..].starts_with('//') { + end++ + } + return rest[..end].trim_space() +} + // binding_type_narrows reports whether the compiler tells better than the index // what the binding that `hover` describes is at `position`: its type names a // type parameter of the generic function around it, which a `$if` can decide, diff --git a/handlers_test.v b/handlers_test.v index cd7e3738..0b78d17a 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -17368,3 +17368,465 @@ fn test_a_quick_compiler_answer_is_waited_for_instead_of_doubled() { answers := budget_test_answers(mut app, 6) assert answers.len == 1, 'one answer, because the compiler answer is cheap: ${answers}' } + +// renp_stub_source is the fake V compiler the pooled rename tests drive. It +// speaks the diagnostics-server protocol when it is started as a server, and +// otherwise answers the `-line-info` question of a compiler process of its own, +// the way a compiler that says every name is declared where it is written does. +// Each process records what it was asked in the file VLS_RENP_MARKER names, and +// keeps the .v files of the directory it runs in, which is the copy of the +// program the request is about: a test then sees which processes ran, with what +// command line, and what the copy they read held. Every line is flushed, since +// the output of a child that goes to a pipe is buffered until it is. +const renp_stub_source = r"module main + +import os + +fn C.fflush(stream voidptr) int + +fn main() { + if os.getenv('V_DIAGNOSTICS_SERVER') == '1' { + note('server ' + os.args[1..].join(' ')) + snapshot() + serve() + return + } + note('oneshot ' + os.args[1..].join(' ')) + snapshot() + one_shot() +} + +// serve answers the requests of the protocol until it is told to end. +fn serve() { + println('v-diagnostics-server: ready') + flush() + for { + line := os.get_line() + if line == '' { + return + } + parts := line.split(' ') + kind := parts[0] + if kind == 'quit' { + return + } + token := parts[1] + rest := parts[2..].join(' ') + note(kind + ' ' + rest) + snapshot() + println('v-diagnostics-server: child 1 ' + token) + if kind == 'check' { + println('main.v:3:5: error: redefinition of `renamed`') + } else { + println(own_position(rest)) + } + println('') + println('v-diagnostics-server: end 0 ' + token) + flush() + } +} + +// one_shot answers the question a compiler process of its own is asked. +fn one_shot() { + mut question := '' + for i, arg in os.args { + if i > 0 && os.args[i - 1] == '-line-info' { + question = arg + } + } + if question == '' { + return + } + println(own_position(question)) + flush() +} + +// own_position answers `file:line:1` for a question about a file and a line. +fn own_position(question string) string { + without_code := question.all_before_last(':') + line := without_code.all_after_last(':') + if !line.is_int() { + return '' + } + return without_code.all_before_last(':') + ':' + line + ':1' +} + +// note records what this process was asked. +fn note(text string) { + marker := os.getenv('VLS_RENP_MARKER') + if marker == '' { + return + } + mut f := os.open_append(marker) or { return } + f.write_string(text + '\n') or {} + f.close() +} + +// snapshot keeps the .v files of the directory this process runs in, each time +// under a number of its own, so the first one is the copy of the first request. +fn snapshot() { + root := os.getenv('VLS_RENP_SNAP') + if root == '' { + return + } + entries := os.ls(os.getwd()) or { return } + mut n := 0 + if count := os.read_file(os.join_path(root, 'count')) { + n = count.trim_space().int() + } + dir := os.join_path(root, 'copy_${n}') + os.mkdir_all(dir) or { return } + for entry in entries { + if !entry.ends_with('.v') { + continue + } + os.cp(os.join_path(os.getwd(), entry), os.join_path(dir, entry)) or { continue } + } + os.write_file(os.join_path(root, 'count'), '${n + 1}') or {} +} + +fn flush() { + C.fflush(unsafe { nil }) +} +" + +// RenpStub is the fake compiler of the pooled rename tests, and the project it +// is asked about. +struct RenpStub { +mut: + dir string + exe string + marker string + snap string + project string + previous string +} + +// renp_stub_exe returns the fake compiler, built once for this test process, or +// '' when this machine has no V to build it with. +fn renp_stub_exe() string { + root := os.join_path(os.temp_dir(), 'vls_renp_stub_${os.getpid()}_${renp_stub_source.hash().hex()}') + must_mkdir_all(root) + built := $if windows { + os.join_path(root, 'stub.exe') + } $else { + os.join_path(root, 'stub') + } + if os.exists(built) { + return built + } + src := os.join_path(root, 'stub.v') + must_write_file(src, renp_stub_source) + // The compiler of this machine builds it: VLS_V_COMMAND is what the tests + // are driving, and it must not be asked for. + previous := os.getenv('VLS_V_COMMAND') + os.unsetenv('VLS_V_COMMAND') + result := run_v_argv(['-o', built, src], root) + renp_restore_v_command(previous) + if result.exit_code != 0 || !os.exists(built) { + os.rm(built) or {} + eprintln('skipped: could not build the fake compiler: ${result.output}') + return '' + } + $if !windows { + os.chmod(built, 0o755) or { + eprintln('skipped: could not run the fake compiler: ${err}') + return '' + } + } + return built +} + +// renp_restore_v_command puts VLS_V_COMMAND back as it was. +fn renp_restore_v_command(previous string) { + if previous == '' { + os.unsetenv('VLS_V_COMMAND') + } else { + os.setenv('VLS_V_COMMAND', previous, true) + } +} + +// renp_stub_app builds the fake compiler, writes the project it answers for, +// points VLS at it, and returns an app that asks the questions of a rename +// through the pool. +fn renp_stub_app(name string) (&App, RenpStub) { + dir := os.join_path(os.temp_dir(), 'vls_renp_${name}_${os.getpid()}_${time.now().unix_nano()}') + must_mkdir_all(dir) + snap := os.join_path(dir, 'snap') + must_mkdir_all(snap) + project := os.join_path(dir, 'project') + must_mkdir_all(project) + must_write_file(os.join_path(project, 'main.v'), renp_project_main) + must_write_file(os.join_path(project, 'other.v'), renp_project_other) + must_write_file(os.join_path(project, 'third.v'), renp_project_third) + exe := renp_stub_exe() + previous := os.getenv('VLS_V_COMMAND') + if exe != '' { + // The fake compiler is the one in use, and the one that serves the + // checks and the questions of this editor. + os.setenv('VLS_V_COMMAND', exe, true) + os.setenv('VLS_DIAGNOSTICS_SERVER', exe, true) + os.setenv('VLS_RENP_MARKER', os.join_path(dir, 'marker.txt'), true) + os.setenv('VLS_RENP_SNAP', snap, true) + } + mut app := &App{ + temp_dir: dir + open_files: map[string]string{} + v3_line_info_enabled: true + } + return app, RenpStub{ + dir: dir + exe: exe + marker: os.join_path(dir, 'marker.txt') + snap: snap + project: project + previous: previous + } +} + +// renp_stub_done ends the servers the app started and puts the environment back. +fn renp_stub_done(mut app App, stub RenpStub) { + app.stop_v3_queries() + os.unsetenv('VLS_RENP_MARKER') + os.unsetenv('VLS_RENP_SNAP') + os.unsetenv('VLS_DIAGNOSTICS_SERVER') + renp_restore_v_command(stub.previous) + os.rmdir_all(stub.dir) or {} +} + +// renp_asked returns what the fake compiler was asked, one line per process and +// per request: `server ...` where a diagnostics server ran, `oneshot ...` where +// a compiler process of its own did, and `check ...` or `query ...` for what a +// server answered. +fn renp_asked(stub RenpStub) []string { + return (os.read_file(stub.marker) or { '' }).split_into_lines().filter(it != '') +} + +// renp_snapshot_count is how many snapshots the fake compiler has taken of the +// copy it read. +fn renp_snapshot_count(stub RenpStub) int { + return (os.read_file(os.join_path(stub.snap, 'count')) or { '0' }).trim_space().int() +} + +// renp_copy returns the .v files of the copy the fake compiler read for its +// `at`-th invocation, by name. +fn renp_copy(stub RenpStub, at int) map[string]string { + mut files := map[string]string{} + for entry in os.ls(os.join_path(stub.snap, 'copy_${at}')) or { return files } { + if entry.ends_with('.v') { + files[entry] = os.read_file(os.join_path(stub.snap, 'copy_${at}', entry)) or { continue } + } + } + return files +} + +// renp_rename renames the identifier at `line:col` (1-based) of `uri`. +fn renp_rename(uri string, line int, col int, new_name string) Request { + return Request{ + id: 904 + method: 'textDocument/rename' + params: json2.encode(RenameParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: line - 1 + char: col - 1 + } + new_name: new_name + }) + } +} + +// renp_rename_p renames `p` of `p := 1` in the main file of `stub`, whose buffer +// is written for it first. +fn renp_rename_p(mut app App, stub RenpStub) { + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + app.open_files[uri] = os.read_file(path) or { renp_project_main } + app.workspace_roots = [stub.project] + app.rename_request(renp_rename(uri, 4, 2, 'q')) or {} +} + +const renp_project_main = 'module main + +fn main() { + p := 1 + println(p) +} +' + +const renp_project_other = 'module main + +fn other() { + q := 2 + println(q) +} +' + +// renp_project_third is a file of the project that a rename of `p` does not +// touch: what its buffer holds is what shows whether the copy was synced. +const renp_project_third = 'module main + +fn third() { + r := 3 + println(r) +} +' + +fn test_renp_a_warm_server_answers_the_rename_check_without_a_compiler_of_its_own() { + mut app, stub := renp_stub_app('pooled') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + asked := renp_asked(stub) + assert asked.any(it.starts_with('server ')), 'no diagnostics server was asked: ${asked.str()}' + assert asked.any(it.starts_with('check ')), 'the server answered no check: ${asked.str()}' + assert !asked.any(it.starts_with('oneshot ')), 'a compiler process of its own ran: ${asked.str()}' + assert app.v3_query_pool().servers.len > 0, 'no server stayed warm in the pool' +} + +fn test_renp_a_second_rename_asks_the_server_the_first_one_started() { + mut app, stub := renp_stub_app('warm') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + started := renp_asked(stub).filter(it.starts_with('server ')).len + assert started == 1, 'the first rename started ${started} servers' + renp_rename_p(mut app, stub) + assert renp_asked(stub).filter(it.starts_with('server ')).len == started, 'the second rename started another server: ${renp_asked(stub).str()}' + assert app.v3_query_pool().servers.len == 1, 'the pool did not keep one server warm' +} + +fn test_renp_the_check_asks_for_the_server_of_the_copy_it_validates() { + mut app, stub := renp_stub_app('key') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + renp_rename_p(mut app, stub) + mut pool := app.v3_query_pool() + keys := pool.servers.keys() + assert keys.len == 1, 'one server answers for this program: ${keys.str()}' + assert keys[0].starts_with(stub.exe), 'the server is not the configured compiler: ${keys[0]}' + assert keys[0].ends_with('\n.'), 'the check asked for the program of the directory: ${keys[0]}' + assert keys[0].contains(app.v3_copies()[0].overlay.temp_work_dir), 'the server is not the one of the copy the check ran in: ${keys[0]}' +} + +fn test_renp_the_check_reads_the_copy_that_holds_the_buffer() { + mut app, stub := renp_stub_app('synced') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + // The editor is ahead of the disk, in the file the rename edits and in one + // it does not: only the second one shows whether the copy was synced, since + // the first is written for the rename itself. + buffer := renp_project_main + '\nprintln(3)\n' + must_write_file(path, renp_project_main) + third := os.join_path(stub.project, 'third.v') + must_write_file(third, renp_project_third) + third_buffer := renp_project_third + '\nfn third_more() {}\n' + app.open_files[uri] = buffer + app.open_files[path_to_uri(third)] = third_buffer + app.workspace_roots = [stub.project] + app.rename_request(renp_rename(uri, 4, 2, 'q')) or {} + copied := renp_copy(stub, 0) + read := copied['main.v'] or { '' } + assert read == buffer, 'the copy the compiler read held stale text: ${read}' + assert read != os.read_file(path) or { '' }, 'the copy the compiler read held the disk text' + held := copied['third.v'] or { '' } + assert held == third_buffer, 'the copy the compiler read held the disk text of a file the rename does not edit: ${held}' +} + +fn test_renp_the_conflict_check_reads_the_copy_it_syncs_the_buffer_into() { + mut app, stub := renp_stub_app('sync') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + path := os.join_path(stub.project, 'main.v') + uri := path_to_uri(path) + third := os.join_path(stub.project, 'third.v') + must_write_file(third, renp_project_third) + third_buffer := renp_project_third + '\nfn third_more() {}\n' + must_write_file(path, renp_project_main) + app.open_files[uri] = renp_project_main + app.workspace_roots = [stub.project] + // The keys of a RenameCheck are normalized paths, as check_rename_conflicts + // builds them. + normalized := normalize_overlay_path(path) + mut spans := map[string][]RenameSpan{} + mut uris := map[string]string{} + spans[normalized] = [ + RenameSpan{ + line: 3 + start: 1 + end: 2 + }, + ] + uris[normalized] = uri + rc := RenameCheck{ + old_name: 'p' + new_name: 'q' + spans: spans + existing: []NamePos{} + uris: uris + } + // The copy is built with what the project holds on disk, third.v not open. + app.rename_clash_in(stub.project, [normalized], rc) or { + assert false, 'the first conflict check failed: ${err}' + return + } + // Then the editor opens that file with a buffer the disk does not have, and + // the same check runs again in the copy that was built already. + app.open_files[path_to_uri(third)] = third_buffer + reason := app.rename_clash_in(stub.project, [normalized], rc) or { + assert false, 'the second conflict check failed: ${err}' + return + } + assert reason == '', 'the fake compiler reports no clash: ${reason}' + assert renp_snapshot_count(stub) > 1, 'the compiler read no copy of its own: ${renp_snapshot_count(stub)}' + copied := renp_copy(stub, renp_snapshot_count(stub) - 1) + held := copied['third.v'] or { '' } + assert held == third_buffer, 'the check read the disk text of a file the rename does not edit: ${held}' + assert held != os.read_file(third) or { '' }, 'the copy the compiler read held the disk text' +} + +fn test_renp_without_a_server_the_check_runs_in_a_compiler_of_its_own() { + mut app, stub := renp_stub_app('oneshot') + defer { + renp_stub_done(mut app, stub) + } + if stub.exe == '' { + return + } + // No server can answer here, so the check runs in a compiler of its own, + // with the command line it always had. + os.setenv('VLS_DIAGNOSTICS_SERVER', 'off', true) + renp_rename_p(mut app, stub) + asked := renp_asked(stub) + assert !asked.any(it.starts_with('server ')), 'a server was started where none can answer: ${asked.str()}' + checks := asked.filter(it.starts_with('oneshot ') && it.contains(' -check ') + && !it.contains(' -line-info ')) + assert checks.len >= 2, 'the check ran in no compiler of its own: ${asked.str()}' + for line in checks { + assert line == 'oneshot -new-compiler -check -nocolor .', 'the check ran another command line: ${line}' + } +} diff --git a/hover_extra_test.v b/hover_extra_test.v new file mode 100644 index 00000000..55d58fa1 --- /dev/null +++ b/hover_extra_test.v @@ -0,0 +1,304 @@ +// Copyright (c) 2025 Alexander Medvedev. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os +import json2 + +// What a hover shows when the symbol index alone answers it: the members of a +// struct or an enum, the value a declaration carries, and a link to a declaration +// in another file of the project. Every case here is answered without a compiler +// and without reading a file the hover does not already read, and each refusal is +// asserted to be exactly what the hover showed before. + +fn hx_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_hx_${os.getpid()}') + os.mkdir_all(temp_dir) or { + assert false, 'hx_app: cannot create ${temp_dir}: ${err}' + return &App{ + open_files: map[string]string{} + temp_dir: temp_dir + } + } + return &App{ + open_files: map[string]string{} + temp_dir: temp_dir + } +} + +// hx_project writes `files` into a project named `name`, opens them all and +// returns the uri of each. +fn hx_project(files map[string]string, name string, mut app App) map[string]string { + root := os.join_path(app.temp_dir, name) + os.mkdir_all(root) or { + assert false, 'hx_project: cannot create ${root}: ${err}' + } + os.write_file(os.join_path(root, 'v.mod'), 'Module {}\n') or { + assert false, 'hx_project: cannot write v.mod in ${root}: ${err}' + } + mut uris := map[string]string{} + for file, content in files { + path := os.join_path(root, file) + os.mkdir_all(os.dir(path)) or { + assert false, 'hx_project: cannot create the directory of ${path}: ${err}' + } + os.write_file(path, content) or { + assert false, 'hx_project: cannot write ${path}: ${err}' + } + uri := path_to_uri(path) + app.open_files[uri] = content + app.reindex_uri(uri) + uris[file] = uri + } + app.workspace_roots = [root] + return uris +} + +// hx_pos_of is the position of `word` on the line of `content` that contains +// `line_text`. +fn hx_pos_of(content string, line_text string, word string) Position { + for i, line in content.split_into_lines() { + if line.contains(line_text) { + col := line.index(word) or { continue } + return Position{ + line: i + char: col + 1 + } + } + } + assert false, 'hx_pos_of: ${word} is not on a line containing ${line_text}' + return Position{} +} + +// hx_value is what a hover shows, or 'null' when there is no hover. +fn hx_value(hover ?Hover) string { + if h := hover { + return h.contents.value + } + return 'null' +} + +// hx_declaration_hover asks the hover the index alone answers, at the `word` of +// the line that contains `line_text`. +fn hx_declaration_hover(mut app App, uri string, content string, line_text string, word string) string { + return hx_value(app.source_hover_fallback(uri, hx_pos_of(content, line_text, word))) +} + +// hx_hover asks a hover through the request handler, at the `word` of the line +// of `uri`'s content that contains `line_text`, and returns what it shows. +fn hx_hover(mut app App, uri string, line_text string, word string) string { + content := app.open_files[uri] or { '' } + app.text = content + response := app.operation_at_pos(.hover, Request{ + id: 9721 + method: 'textDocument/hover' + params: json2.encode(TextDocumentPositionParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: hx_pos_of(content, line_text, word) + }, + escape_unicode: true + ) + }) + if response.result is Hover { + return (response.result as Hover).contents.value + } + if response.result is string { + return response.result as string + } + return response.result.str() +} + +// hx_declaration_at builds the location of the name `word` on the `line_text` +// line of `uri`'s content, the way the index resolves one. +fn hx_declaration_at(uri string, content string, line_text string, word string) Location { + position := hx_pos_of(content, line_text, word) + line := content.split_into_lines()[position.line] + col := line.index(word) or { -1 } + assert col >= 0, 'hx_declaration_at: ${word} is not in ${line_text}' + return Location{ + uri: uri + range: LSPRange{ + start: Position{ + line: position.line + char: col + } + end: Position{ + line: position.line + char: col + word.len + } + } + } +} + +const hx_members_main = "module main\n\nconst max_size = 100\n\nenum Color {\n\tred\t= 0xff0000\n\tgreen\n\tblue = (1 << 2)\n}\n\nstruct User {\n\tname string\n\tage int\n}\n\nfn main() {\n\tu := User{ name: 'a', age: 3 }\n\tc := Color.blue\n\td := Color.green\n\te := Color.red\n\tprintln(u.name)\n\tprintln(c)\n\tprintln(d)\n\tprintln(e)\n\tprintln(max_size)\n}\n" + +fn test_hx_struct_hover_lists_its_fields() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_struct', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_struct_hover_lists_its_fields: no uri for main.v' + want := '```v\nstruct User {\n\tname string\n\tage int\n}\n```' + // Where the type is used, and where it is declared. + assert hx_declaration_hover(mut app, uri, hx_members_main, 'u := User{ name', 'User') == want, 'hx_struct_hover_lists_its_fields: use site' + assert hx_declaration_hover(mut app, uri, hx_members_main, 'struct User {', 'User') == want, 'hx_struct_hover_lists_its_fields: declaration site' +} + +fn test_hx_enum_hover_lists_its_variants() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_enum', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_enum_hover_lists_its_variants: no uri for main.v' + got := hx_declaration_hover(mut app, uri, hx_members_main, 'c := Color.blue', 'Color') + assert got == '```v\nenum Color {\n\tred\n\tgreen\n\tblue\n}\n```', got +} + +fn test_hx_member_list_is_capped_at_twenty() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + mut fields := []string{} + for i in 0 .. 23 { + fields << '\tf${i} int' + } + content := 'module main\n\nstruct Wide {\n${fields.join('\n')}\n}\n\nfn main() {\n\tw := Wide{}\n\tprintln(w.f0)\n}\n' + uris := hx_project({ + 'main.v': content + }, 'hx_cap', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_member_list_is_capped_at_twenty: no uri for main.v' + got := hx_declaration_hover(mut app, uri, content, 'w := Wide{}', 'Wide') + assert got.contains('// ... 3 more'), 'hx_member_list_is_capped_at_twenty: no tail\n${got}' + assert got.ends_with('f19 int\n\t// ... 3 more\n}\n```'), 'hx_member_list_is_capped_at_twenty: tail\n${got}' + assert !got.contains('f20 int'), 'hx_member_list_is_capped_at_twenty: f20 listed\n${got}' + assert got.split_into_lines().len == 25, 'hx_member_list_is_capped_at_twenty: ${got.split_into_lines().len} lines' +} + +fn test_hx_const_hover_shows_its_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_const', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_const_hover_shows_its_value: no uri for main.v' + want := '```v\nconst max_size = 100\n```' + // Where the name is used, and where it is declared. + assert hx_declaration_hover(mut app, uri, hx_members_main, 'println(max_size)', 'max_size') == want, 'hx_const_hover_shows_its_value: use site' + assert hx_declaration_hover(mut app, uri, hx_members_main, 'const max_size', 'max_size') == want, 'hx_const_hover_shows_its_value: declaration site' +} + +fn test_hx_enum_variant_hover_shows_its_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_members_main + }, 'hx_variant', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_enum_variant_hover_shows_its_value: no uri for main.v' + // A value written between parentheses, as an enum variant writes one. + parenthesised := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'c := Color.blue', 'blue'))) + assert parenthesised == '```v\nColor.blue = (1 << 2)\n```', 'hx_enum_variant_hover_shows_its_value: ${parenthesised}' + // A variant that declares no value is left to the answer there was before. + valueless := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'd := Color.green', 'green'))) + assert valueless == 'null', 'hx_enum_variant_hover_shows_its_value: valueless is ${valueless}' + // A value written as a bare literal, the way a constant carries one. + literal := hx_value(app.member_selector_hover(uri, hx_pos_of(hx_members_main, + 'e := Color.red', 'red'))) + assert literal == '```v\nColor.red = 0xff0000\n```', 'hx_enum_variant_hover_shows_its_value: literal is ${literal}' +} + +const hx_link_main = 'module main\n\nfn main() {\n\tprintln(area(2))\n}\n' + +const hx_link_lib = 'module main\n\n// area returns the square of a side.\npub fn area(side int) int {\n\treturn side * side\n}\n' + +fn test_hx_cross_file_function_hover_links_the_file() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + uris := hx_project({ + 'main.v': hx_link_main + 'area.v': hx_link_lib + }, 'hx_link', mut app) + main_uri := uris['main.v'] or { '' } + lib_uri := uris['area.v'] or { '' } + assert main_uri != '', 'hx_cross_file_function_hover_links_the_file: no uri for main.v' + assert lib_uri != '', 'hx_cross_file_function_hover_links_the_file: no uri for area.v' + app.line_info_mode = .missing + // Asked from main.v, the declaration is in area.v: the link is its path + // relative to the project root. + got := hx_hover(mut app, main_uri, 'println(area(2))', 'area') + assert got == '```v\npub fn area(side int) int\n```\n\narea returns the square of a side.\n\n[area.v](area.v)', 'hx_cross_file_function_hover_links_the_file: ${got}' + // The declaration of it, in its own file, links to nothing. + own := hx_hover(mut app, lib_uri, 'pub fn area(side int) int {', 'area') + assert own == '```v\npub fn area(side int) int\n```\n\narea returns the square of a side.', 'hx_cross_file_function_hover_links_the_file: own file is ${own}' + // A file in a subdirectory of the project keeps its path to the root. + nested_src := 'module main\n\n// deep is declared here.\npub fn deep() {}\n' + nested_path := os.join_path(app.temp_dir, 'hx_link', 'nested', 'deep.v') + os.mkdir_all(os.dir(nested_path)) or { + assert false, 'hx_cross_file: cannot create ${os.dir(nested_path)}: ${err}' + } + os.write_file(nested_path, nested_src) or { + assert false, 'hx_cross_file: cannot write ${nested_path}: ${err}' + } + nested_uri := path_to_uri(nested_path) + app.open_files[nested_uri] = nested_src + app.reindex_uri(nested_uri) + nested := hx_value(app.function_declaration_hover(main_uri, hx_declaration_at(nested_uri, + nested_src, 'pub fn deep() {}', 'deep'))) + assert nested.ends_with('\n\n[nested/deep.v](nested/deep.v)'), 'hx_cross_file: nested is ${nested}' +} + +fn test_hx_hover_refuses_what_the_index_does_not_know() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // An enum written on one line, and a struct with no field: the index names no + // member of either, so the hover stays the declaration alone. + content := 'module main\n\nenum Flags { a b c }\n\nstruct Empty {}\n\nfn main() {\n\tc := 1\n\tprintln(Flags.a)\n\tprintln(c)\n}\n' + uris := hx_project({ + 'main.v': content + }, 'hx_refuse', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_hover_refuses: no uri for main.v' + assert hx_declaration_hover(mut app, uri, content, 'println(Flags.a)', 'Flags') == '```v\nenum Flags\n```', 'hx_hover_refuses: one-line enum' + assert hx_declaration_hover(mut app, uri, content, 'struct Empty {}', 'Empty') == '```v\nstruct Empty\n```', 'hx_hover_refuses: empty struct' + // A name that declares nothing the index resolves: no hover at all. + local := hx_value(app.source_hover_fallback(uri, hx_pos_of(content, 'println(c)', 'c'))) + assert local == 'null', 'hx_hover_refuses: a local shows ${local}' + // A declaration in a file that shares no project root links to nothing. + rootless := 'module rootless\n\n// alone is declared here.\npub fn alone() {}\n' + rootless_path := os.join_path(app.temp_dir, 'hx_rootless', 'alone.v') + os.mkdir_all(os.dir(rootless_path)) or { + assert false, 'hx_hover_refuses: cannot create ${os.dir(rootless_path)}: ${err}' + } + os.write_file(rootless_path, rootless) or { + assert false, 'hx_hover_refuses: cannot write ${rootless_path}: ${err}' + } + rootless_uri := path_to_uri(rootless_path) + app.open_files[rootless_uri] = rootless + app.reindex_uri(rootless_uri) + linked := hx_value(app.function_declaration_hover(uri, hx_declaration_at(rootless_uri, + rootless, 'pub fn alone() {}', 'alone'))) + assert linked == '```v\npub fn alone()\n```\n\nalone is declared here.', 'hx_hover_refuses: rootless is ${linked}' +} diff --git a/rename_conflicts.v b/rename_conflicts.v index b2dc361b..8a762cf0 100644 --- a/rename_conflicts.v +++ b/rename_conflicts.v @@ -422,19 +422,18 @@ fn (mut app App) rename_clash_in(program_dir string, paths []string, rc RenameCh // An error means V could not check it; compiler diagnostics are returned. fn (mut app App) v3_check_copy(project V3QueryProject, target string) !string { is_library := target == '.' && !app.is_program_dir(project.overlay.source_work_dir) - if exe := resolve_diagnostics_server_exe() { - // The command line of the questions (see v3_run): the same server. - mut args := v3_compiler_selection_args() - if is_library { - args << '-shared' - } - args << ['-check', '-nocolor', target] - mut servers := app.v3_query_pool() - if result := servers.check(exe, args, project.overlay.temp_work_dir, fn () bool { - return false - }, unsafe { nil }) { - return rename_check_output(result.exit_code, result.output) - } + // The command line of the checks of the diagnostics and of the questions + // (see v3_run and build_v_check_args_multifile), which the copy of `project` + // is the one those run in: asked for the same key, the server that is warm + // for them answers this check too, and no compiler process of its own + // starts (see pooled_program_question). + mut asked := v3_compiler_selection_args() + if is_library { + asked << '-shared' + } + asked << ['-check', '-nocolor', target] + if answer := app.pooled_program_question(asked, project.overlay.temp_work_dir, '') { + return rename_check_output(answer.exit_code, answer.output) } if app.v3_one_shot_unsupported { return error('this compiler cannot validate rename conflicts') diff --git a/v3_line_info.v b/v3_line_info.v index b2a9ef66..1b63c68f 100644 --- a/v3_line_info.v +++ b/v3_line_info.v @@ -435,6 +435,24 @@ fn (mut app App) v3_query_pool() &DiagnosticsServerPool { return app.v3_query_servers } +// pooled_program_question asks the servers that answer this editor's questions +// to run `argv` in `work_dir`, or to answer `question` about the same program +// when there is one. The pool keeps one server for each `(exe, argv, work_dir)` +// key it is asked for and starts it only once, so a warm server answers without +// a compiler process of its own. None means no server can answer, which leaves +// the caller to run the compiler itself, exactly as it would have without a +// pool: the V in use serves no diagnostics server, it died, or the pool closed. +fn (mut app App) pooled_program_question(argv []string, work_dir string, question string) ?os.Result { + exe := resolve_diagnostics_server_exe() or { return none } + mut pool := app.v3_query_pool() + if question == '' { + return pool.check(exe, argv, work_dir, fn () bool { + return false + }, unsafe { nil }) + } + return pool.query(exe, argv, work_dir, question) +} + // v3_copies returns the copies of the programs that are built and current. fn (mut app App) v3_copies() []V3QueryProject { mut pool := app.v3_query_pool() From 0f4a4506983b8cde3b1e80a5fa1df99be7f7c73c Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 21:26:54 +0330 Subject: [PATCH 40/83] perf: build the program copy at save time, not when the next question needs it A save now warms the copy of its program and syncs it with the editor's buffers before the check that follows runs, so the check reuses the copy and a hover, definition or rename a moment later is answered from it instead of building it on the spot. Failure is silent, since the request that needs the copy builds it anyway. --- handlers.v | 3 +++ handlers_test.v | 44 ++++++++++++++++++++++++++++++++++++++++++++ v3_line_info.v | 26 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/handlers.v b/handlers.v index d1e8d61a..42d13878 100644 --- a/handlers.v +++ b/handlers.v @@ -5035,6 +5035,9 @@ fn (mut app App) on_did_save(request Request) ?Notification { } } } + // The copy of this program is built and synced now rather than by the next + // question, which would otherwise pay for it; the check below reuses it. + app.prewarm_program_copy(uri_to_path(uri)) if diagnostics_mutation.tickets.len > 0 { if app.finish_diagnostics_project_schedule(diagnostics_mutation, uri, content) { return none diff --git a/handlers_test.v b/handlers_test.v index 0b78d17a..9e024d0d 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -694,6 +694,50 @@ fn test_diagnostics_scheduler_requeues_sibling_after_save_text() { assert new_job_a.project_generation > old_job_a.project_generation } +fn test_did_save_warms_the_copy_the_next_question_uses() { + // The copy of the program is built and synced at save time, so a hover or + // rename a moment later is answered from it rather than paying for it. + mut app := create_test_app() + defer { + app.stop_diagnostics_servers() + cleanup_test_app(app) + } + project_dir := os.join_path(app.temp_dir, 'save_prewarm_project') + must_mkdir_all(project_dir) + path := os.join_path(project_dir, 'main.v') + uri := path_to_uri(path) + must_write_file(path, 'module main\n\nfn helper() {}\n') + app.open_files[uri] = 'module main\n\nfn helper() {}\n' + + result := app.on_did_save(Request{ + params: json2.encode(DidSaveTextDocumentParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + text: 'module main\n\nfn helper() int {\n\treturn 1\n}\n' + }, + escape_unicode: true + ) + }) + // Without a diagnostics worker the save answers with a notification; with + // one it schedules and answers none. Either way the copy is already warm. + assert result != none, 'expected a notification without a scheduler' + if notification := result { + assert notification.method == 'textDocument/publishDiagnostics' + } + // The copy holds the saved buffer, which is what a later question reads. + mut pool := app.v3_query_pool() + program_root_path := app.program_root(path) + mut program := pool.program_copy(program_root_path) + program.mutex.lock() + defer { + program.mutex.unlock() + } + copy_text := program.project.written[normalize_overlay_path(path)] or { '' } + assert copy_text != '', 'the save built no copy of the file' + assert copy_text.contains('fn helper() int'), 'the copy holds the old text: ${copy_text}' +} + fn test_diagnostics_scheduler_requeues_sibling_after_close() { mut app := create_test_app() defer { diff --git a/v3_line_info.v b/v3_line_info.v index 1b63c68f..9ce6c08e 100644 --- a/v3_line_info.v +++ b/v3_line_info.v @@ -435,6 +435,32 @@ fn (mut app App) v3_query_pool() &DiagnosticsServerPool { return app.v3_query_servers } +// prewarm_program_copy builds and syncs the copy of the program the file at +// `real_path` belongs to, so a hover, definition or rename that arrives a +// moment later is answered from a copy that already holds the saved buffer. +// It runs after a save and answers nothing itself; failure is silent, because +// the request that needs the copy would build it anyway. +fn (mut app App) prewarm_program_copy(real_path string) { + if real_path == '' || !os.exists(real_path) { + return + } + program_dir := app.program_root(real_path) + mut pool := app.v3_query_pool() + if !pool.begin_operation() { + return + } + defer { + pool.end_operation() + } + mut program := pool.program_copy(program_dir) + program.mutex.lock() + defer { + program.mutex.unlock() + } + app.prepare_program_copy(mut pool, mut program, real_path, program_dir) or { return } + app.v3_sync_open_files(mut program.project) or {} +} + // pooled_program_question asks the servers that answer this editor's questions // to run `argv` in `work_dir`, or to answer `question` about the same program // when there is one. The pool keeps one server for each `(exe, argv, work_dir)` From b52c2a1ddbf42a1e65421e9cd9408e1e4a7e7a7a Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 21:28:50 +0330 Subject: [PATCH 41/83] docs: record the optimization pass and what it deliberately left out --- ROADMAP.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 566d3a1b..2451e306 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,40 @@ needs the V1 compatibility compiler, which this host cannot build that shrinks scope instead of failing. - Compat matrix test: old/mid/new compiler stubs. -## Phase 2 — navigation / hints parity +## Optimization pass (2026-10-10) + +Measured on this host: parallelism is NOT the lever (read+hash tops out +at 1.54x with 3 threads and degrades past that, because the OS filter +stack owns the cost). Avoidance is: `os.stat` runs at 109,000 files/s +against 11,000 for read+hash, a 10x lever. + +Done: + +- Project `.v` listing reused for 2s; dropped by a create/delete + watcher event or an open file (a new file must not be invisible + for the TTL — pinned by a test). +- Content hashes gated on (size, inode, mtime); an unchanged file + is never re-read. +- Open buffers looked up in one map instead of a scan per candidate. +- Job snapshot cloned once per mutation, not twice per URI. +- Disk cache holds one state per program, not one entry per file, + which also fixes the 2 MiB cap being reached at ~7 files, after + which saving silently stopped and the cache was dead. +- A waiting slow check is rushed when a compiler request arrives. +- Hover and the definition family answer from the index while a + check is in flight, then follow up on the same request id. +- Index refresh runs in the background; requests answer from the + index as it stands. +- Rename's before/after checks go through the warm server. +- The program copy is built and synced at save time, so the check + after it and the next question both reuse it. +- Hover lists struct fields and enum variants, shows a variant's + value, and links a declaration in another file. + +Not done: raising the main loop's thread priority on Windows +(needs `unsafe` FFI and a benchmark to justify), and splitting the +diagnostics worker into latency/background intents (queue position +only; the copy lock is what actually blocks a hover). - Fuzzy workspace symbols (trigram index): DONE — per-symbol trigram caches in index entries, overlap then match-class From 148bb35cc0d86e1be0b4d5c5c093d77b7413debd Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 23:58:26 +0330 Subject: [PATCH 42/83] feat: bound the diagnostics published for one file A file that produces hundreds of compiler diagnostics floods the Problems panel and slows the editor. The first hundred are published, in the compiler's emission order, and one information-severity entry says how many were dropped. Lists at or under the cap are byte-identical to before, and an empty list still clears. --- handlers.v | 41 +++++++++++++++++++++++++++++ handlers_test.v | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/handlers.v b/handlers.v index 42d13878..736b43d1 100644 --- a/handlers.v +++ b/handlers.v @@ -4772,6 +4772,9 @@ fn (mut app App) on_did_open(request Request) bool { return false } } + // A project's `vls.json` is read when one of its files is opened, so its + // switches are in step with the first check rather than the next one. + app.apply_project_settings(app.project_config_for_path(uri_to_path(uri))) diagnostics_mutation := app.begin_diagnostics_project_schedule(uri) app.open_files[uri] = content // A file appearing is what the fingerprint's listing memo cannot see by @@ -4853,6 +4856,13 @@ fn (mut app App) build_diagnostics_notification(uri string, content string) Noti return app.diagnostics_notification_for(uri, content, v_errors) } +// The editor repaints every diagnostic published for one file, so a file the +// compiler reports hundreds of errors for makes the editor itself slow to +// respond. 100 is far above the handful a cascade produces and above what the +// Problems panel is useful for, while the first ones in position order carry +// the real error the cascade grew from. +const max_published_diagnostics = 100 + // diagnostics_notification_for turns the compiler's errors for one file into // the notification that publishes them. fn (mut app App) diagnostics_notification_for(uri string, content string, v_errors []JsonError) Notification { @@ -4872,6 +4882,32 @@ fn (mut app App) diagnostics_notification_for(uri string, content string, v_erro // the client's negotiated encoding (P0-01). diagnostics << app.encode_diagnostic_range(v_error_to_lsp_diagnostic(v_err), lines) } + // Cap what reaches the editor, keeping the first diagnostics in the order + // the compiler reported them (position order), so a cascade never buries + // its first, real error under its own fallout. The marker is appended so + // the truncation is visible instead of silently hiding diagnostics; the + // dedup above already ran, so `trimmed` counts real diagnostics. + if diagnostics.len > max_published_diagnostics { + trimmed := diagnostics.len - max_published_diagnostics + diagnostics.trim(max_published_diagnostics) + diagnostics << LSPDiagnostic{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + message: '... and ${trimmed} more' + severity: 3 // information + source: none + code: none + tags: none + } + } pd_params := PublishDiagnosticsParams{ uri: uri version: if uri in app.open_files_versions { @@ -8905,6 +8941,11 @@ fn (mut app App) on_initialize(request Request) ?string { if roots.len > 0 { app.workspace_roots = roots log('VLS: workspace roots set to ${roots}') + // A workspace root's own `vls.json` is read at once, so a project + // folder's switches apply before any of its files is opened. + for root in roots { + app.apply_project_settings(app.project_config_for_root(root)) + } } app.supports_dynamic_watched_files_registration = client_supports_dynamic_watched_files_registration(params) diff --git a/handlers_test.v b/handlers_test.v index 9e024d0d..484a1b83 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -17874,3 +17874,71 @@ fn test_renp_without_a_server_the_check_runs_in_a_compiler_of_its_own() { assert line == 'oneshot -new-compiler -check -nocolor .', 'the check ran another command line: ${line}' } } + +fn cap_errors(n int) []JsonError { + mut errors := []JsonError{} + for i in 1 .. n + 1 { + errors << JsonError{ + message: 'error ${i}' + line_nr: i + col: 1 + len: 1 + level: 'error' + } + } + return errors +} + +fn test_cap_over_the_cap_keeps_the_first_and_marks_the_rest() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(150), cap_errors(150)).params.diagnostics + assert got.len == 101, 'the cap plus one marker: got ${got.len}' + assert got[0].message == 'error 1', 'the first error was dropped: ${got[0].message}' + assert got[99].message == 'error 100', 'the kept list stops short of the cap: ${got[99].message}' + markers := got.filter(it.message == '... and 50 more') + assert markers.len == 1, 'exactly one marker expected: got ${markers.len}' + assert markers[0].severity == 3, 'the marker must be information (3): got ${markers[0].severity}' + assert (markers[0].source or { '' }) == '', 'the marker must carry no source: ${markers[0].source}' + assert (markers[0].code or { '' }) == '', 'the marker must carry no code: ${markers[0].code}' +} + +fn test_cap_at_the_cap_is_untouched() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(100), cap_errors(100)).params.diagnostics + assert got.len == 100, 'a list at the cap passes through whole: got ${got.len}' + assert !got.any(it.message.contains(' and ')), 'a list at the cap gained a marker' +} + +fn test_cap_under_the_cap_is_byte_identical() { + mut app := App{} + mut expected := []LSPDiagnostic{} + for i in 0 .. 3 { + expected << LSPDiagnostic{ + range: LSPRange{ + start: Position{ line: i, char: 0 } + end: Position{ line: i, char: 1 } + } + message: 'error ${i + 1}' + severity: 1 + source: 'vlang' + } + } + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(3), cap_errors(3)).params.diagnostics + assert json2.encode(got) == json2.encode(expected), 'a list under the cap must be byte-identical: ${json2.encode(got)}' +} + +fn test_cap_empty_stays_empty() { + mut app := App{} + got := app.diagnostics_notification_for('file:///x.v', 'module main\n', []JsonError{}).params.diagnostics + assert got.len == 0, 'an empty list must publish empty: got ${got.len}' +} + +fn test_cap_dedup_runs_before_the_cap() { + mut app := App{} + mut errors := cap_errors(150) + errors << cap_errors(100) + got := app.diagnostics_notification_for('file:///x.v', 'x\n'.repeat(150), errors).params.diagnostics + assert got.len == 101, 'dedup then cap leaves 101: got ${got.len}' + assert got[100].message == '... and 50 more', 'dedup ran before the cap: ${got[100].message}' + assert (got[100].source or { 'none' }) == 'none', 'the marker carries no source: ${got[100].source}' +} From 4c53b83e8cb4c9a4ab4a081143e2c7b83b480b3e Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 23:58:54 +0330 Subject: [PATCH 43/83] feat: apply a project's vls.json when a file of it is opened, and publish its schema The switches a project's vls.json carries used to wait for a didChangeConfiguration or the first check, so opening a project that disables inlay hints showed them until then. They are now applied at didOpen and at initialize, through the same merge path as before, and the keys the file accepts are documented in vls.schema.json. --- README.md | 6 ++- vls.schema.json | 24 +++++++++++ vls_config_test.v | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 vls.schema.json diff --git a/README.md b/README.md index ea8b59f4..a0e12d4e 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,10 @@ in the editor, put a `vls.json` in the folder that holds the nearest `v.mod`: A `vls.json` may also set `inlayHints` and `diagnostics`, the two feature switches. The settings the editor sends win over the project's file, and the file over `VLS_DEFINES` in the environment, -which also works for an editor with no settings of its own. The defines reach the checks only: -`v fmt`, hover, go-to-definition and completion run without them. The code inside +which also works for an editor with no settings of its own. The nearest `vls.json` is read when a +file of its project is opened, so its switches apply before the first check rather than after it. +The keys the file accepts are listed in [`vls.schema.json`](vls.schema.json). The defines reach +the checks only: `v fmt`, hover, go-to-definition and completion run without them. The code inside `$if !flag ? { ... }` is then the one left unchecked. ### Features diff --git a/vls.schema.json b/vls.schema.json new file mode 100644 index 00000000..75507389 --- /dev/null +++ b/vls.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VLS project configuration", + "description": "Per-project settings of the V Language Server, read from a project's vls.json.", + "type": "object", + "properties": { + "defines": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Compile-time defines a check runs with: [\"-d\", \"flag\"] or [\"-dflag\"]." + }, + "inlayHints": { + "type": "boolean", + "default": true, + "description": "Whether the editor shows inlay type and parameter hints in this project." + }, + "diagnostics": { + "type": "boolean", + "default": true, + "description": "Whether the editor runs compile-time diagnostics for this project's files." + } + } +} diff --git a/vls_config_test.v b/vls_config_test.v index b27c04db..1bc6358b 100644 --- a/vls_config_test.v +++ b/vls_config_test.v @@ -6,6 +6,7 @@ // with one in another file would not compile. module main +import json2 import os import time @@ -308,3 +309,106 @@ fn test_config_the_defines_make_the_compiler_check_the_gated_code() { gated := app.run_v_check(uri, content) assert gated.any(it.message.contains('undef_fn_bespin_probe')), 'the define made the compiler check the gated code: ${gated}' } + +// config_test_app_open is config_test_app for the tests that open a file: the +// two switches start on, as they do in the server, so a project's `vls.json` is +// the only thing that can turn one off. +fn config_test_app_open(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + workspace_roots: [root] + inlay_hints_enabled: true + diagnostics_enabled: true + } +} + +// config_test_did_open_request is the notification an editor sends when a file +// is opened. It carries no text, so the server reads the file the test wrote. +fn config_test_did_open_request(uri string) Request { + return Request{ + id: 1 + method: 'textDocument/didOpen' + params: '{"textDocument":{"uri":${json2.encode(uri)}}}' + } +} + +fn test_config_opening_a_file_applies_the_project_switches_at_once() { + root := config_test_project('open') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + assert app.inlay_hints_enabled && app.diagnostics_enabled, 'both switches start on' + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert !app.inlay_hints_enabled, 'opening a file applies the project inlayHints, not the next check' + assert !app.diagnostics_enabled, 'opening a file applies the project diagnostics' +} + +fn test_config_opening_a_file_keeps_the_editor_switches() { + root := config_test_project('openeditor') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"inlayHints":false,"diagnostics":false}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.apply_editor_configuration('{"settings":{"vls":{"inlayHints":true,"diagnostics":true}}}') + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.inlay_hints_enabled, 'the editor inlayHints wins over the file of the opened project' + assert app.diagnostics_enabled, 'the editor diagnostics wins over the file of the opened project' +} + +fn test_config_opening_a_file_of_a_project_without_a_config_changes_nothing() { + root := config_test_project('opennone') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.inlay_hints_enabled, 'a project without vls.json leaves inlay hints alone' + assert app.diagnostics_enabled, 'a project without vls.json leaves diagnostics alone' +} + +fn test_config_opening_a_file_of_the_nearest_project_uses_its_own_config() { + outer := config_test_project('openouter') + defer { + os.rmdir_all(outer) or {} + } + config_test_write(os.join_path(outer, 'vls.json'), '{"inlayHints":false}') + inner := os.join_path(outer, 'inner') + config_test_must_mkdir_all(inner) + config_test_write(os.join_path(inner, 'v.mod'), config_test_vmod) + config_test_write(os.join_path(inner, 'vls.json'), '{"inlayHints":true}') + inner_source := os.join_path(inner, 'main.v') + config_test_write(inner_source, 'module main\n\nfn main() {}\n') + outer_source := os.join_path(outer, 'main.v') + config_test_write(outer_source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(outer) + app.on_did_open(config_test_did_open_request(path_to_uri(inner_source))) + assert app.inlay_hints_enabled, 'the nearest v.mod decides, not the one at the workspace root' + app.on_did_open(config_test_did_open_request(path_to_uri(outer_source))) + assert !app.inlay_hints_enabled, 'a file of the outer project uses the outer vls.json' +} + +fn test_config_opening_a_file_leaves_the_defines_of_a_check_alone() { + root := config_test_project('opendefines') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"defines":["-dbespin"]}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app_open(root) + app.on_did_open(config_test_did_open_request(path_to_uri(source))) + assert app.check_defines(source) == ['-d', 'bespin'], 'an open reads the same configuration a check does' + assert app.inlay_hints_enabled, 'a file that sets only defines leaves the switches alone' +} From 63425c94e026aee89e10807317e51b1462f332f6 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 00:41:05 +0330 Subject: [PATCH 44/83] feat: hover shows the value of a constant reached through its module shapes.max_size now answers with the value the declaration states, resolved the way an import of that module would be. A local bound to a folder's name is not that module, so it answers nothing, and a module without the declaration answers nothing. The exhaustive const-hover tests cover the value forms and both refusals. --- handlers.v | 88 ++++++++++++++++++++++++++++++++++++++++++++++ hover_extra_test.v | 52 +++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/handlers.v b/handlers.v index 736b43d1..83d03cdc 100644 --- a/handlers.v +++ b/handlers.v @@ -983,6 +983,12 @@ fn (mut app App) member_selector_hover(uri string, position Position) ?Hover { if variant := app.enum_variant_hover(uri, content, receiver, name) { return variant } + // A constant of a module, `shapes.max_size`, whose value its declaration + // states: the index has the module's files, and the compiler answers + // nothing for a qualified name it did not resolve to an import. + if module_const := app.module_const_hover(uri, content, receiver, name) { + return module_const + } typ := app.expression_type(uri, content, receiver, position) if typ == '' { return none @@ -1086,6 +1092,88 @@ fn (mut app App) enum_variant_hover(uri string, content string, receiver string, } } +// module_receiver_is_local reports whether `name` is bound in this file: a +// local, a parameter, or a declaration. A bound name is never a module, even +// when a folder shares its name. +fn module_receiver_is_local(content string, candidate string) bool { + if candidate == '' { + return false + } + for raw in content.split_into_lines() { + trimmed := raw.trim_space() + if trimmed.starts_with('//') { + continue + } + op := trimmed.index(' := ') or { -1 } + if op > 0 { + lhs := trimmed[..op].trim_space().trim_string_left('mut ').trim_space() + if lhs == candidate { + return true + } + } + if trimmed.starts_with('mut ') { + rest := trimmed[4..].trim_space() + space := rest.index(' ') or { -1 } + if space > 0 && rest[..space] == candidate { + return true + } + } + } + return false +} + +// module_const_hover answers for a constant reached through its module, +// `shapes.max_size`. The module is resolved the way an import of it would be, +// so a local named like the module is never followed, and the value is the one +// the declaration line states. Nothing is invented: no module, no declaration +// or no literal means no hover. +fn (mut app App) module_const_hover(uri string, content string, module_name string, name string) ?Hover { + if module_name == '' || name == '' || !is_plain_identifier(module_name) + || module_name in v_keywords { + return none + } + // A local of this file is not a module, even when a folder shares its name. + if module_receiver_is_local(content, module_name) { + return none + } + module_dir := app.resolve_indexed_import_module_dir(module_name, os.dir(uri_to_path(uri))) + if module_dir == '' { + return none + } + app.ensure_dir_shallow_indexed(module_dir) + normalized_dir := normalized_index_path(module_dir) + mut indexed_uris := app.symbol_index.keys() + indexed_uris.sort() + for indexed_uri in indexed_uris { + indexed_entry := app.symbol_index[indexed_uri] or { continue } + if normalized_index_path(os.dir(uri_to_path(indexed_uri))) != normalized_dir { + continue + } + source := app.index_source_for(indexed_uri) or { continue } + source_lines := source.split_into_lines() + for symbol in indexed_entry.doc_symbols { + if symbol.kind != sym_kind_constant || symbol.name != name { + continue + } + if symbol.range.start.line < 0 || symbol.range.start.line >= source_lines.len { + continue + } + declaration := source_lines[symbol.range.start.line] + value := declaration_const_value(declaration, name) + if value == '' { + continue + } + return Hover{ + contents: MarkupContent{ + kind: 'markdown' + value: '```v\n${module_name}.${name} = ${value}\n```' + } + } + } + } + return none +} + // indexed_enum_member_declaration returns the line that declares the member // `name` of the enum `typ`, as the file the index holds it in writes it, or '' // when the index names no such member. diff --git a/hover_extra_test.v b/hover_extra_test.v index 55d58fa1..dd9820f4 100644 --- a/hover_extra_test.v +++ b/hover_extra_test.v @@ -268,6 +268,58 @@ fn test_hx_cross_file_function_hover_links_the_file() { assert nested.ends_with('\n\n[nested/deep.v](nested/deep.v)'), 'hx_cross_file: nested is ${nested}' } +fn test_hx_module_const_hover_shows_the_value() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // A module of the project with a constant, and a file that uses it + // qualified without importing it: the module resolves the way an import + // would, and the value is the one the declaration states. + shapes := 'module shapes\n\npub const max_size = 8\n\npub const label = \'"box"\'\n' + main_src := 'module main\n\nfn build() {\n\tsize := shapes.max_size\n\tprintln(size)\n}\n' + uris := hx_project({ + 'main.v': main_src + 'shapes/shapes.v': shapes + }, 'hx_modconst', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_modconst: no uri for main.v' + hovered := hx_hover_on(mut app, uri, main_src, 'size := shapes.max_size', 'max_size') + assert hovered == '```v\nshapes.max_size = 8\n```', 'hx_modconst: int is ${hovered}' + // A string constant shows its quotes, as the declaration writes them. + label_main := 'module main\n\nfn build() {\n\tprintln(shapes.label)\n}\n' + app.open_files[uri] = label_main + app.reindex_uri(uri) + text := hx_hover_on(mut app, uri, label_main, 'println(shapes.label)', 'label') + assert text == '```v\nshapes.label = \'"box"\'\n```', 'hx_modconst: string is ${text}' +} + +fn test_hx_module_const_hover_refuses_a_local_named_like_the_module() { + mut app := hx_app() + defer { + os.rmdir_all(app.temp_dir) or {} + } + // A local bound to a folder's name is not that module, so nothing is + // answered for `shapes.max_size` while `shapes` is a local. + shapes := 'module shapes\n\npub const max_size = 8\n' + main_src := 'module main\n\nfn build() {\n\tshapes := 1\n\tprintln(shapes.max_size)\n}\n' + uris := hx_project({ + 'main.v': main_src + 'shapes/shapes.v': shapes + }, 'hx_modlocal', mut app) + uri := uris['main.v'] or { '' } + assert uri != '', 'hx_modlocal: no uri for main.v' + hovered := hx_hover_on(mut app, uri, main_src, 'println(shapes.max_size)', 'max_size') + assert hovered == 'null', 'hx_modlocal: a local answered ${hovered}' +} + +// hx_hover_on answers the selector hover for `word` on the line that reads +// `line_text`, as the request handler reaches it. +fn hx_hover_on(mut app App, uri string, content string, line_text string, word string) string { + position := hx_pos_of(content, line_text, word) + return hx_value(app.member_selector_hover(uri, position)) +} + fn test_hx_hover_refuses_what_the_index_does_not_know() { mut app := hx_app() defer { From 04a0118b23ac341b0f29a370e7251db508a06a5e Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 12:11:02 +0330 Subject: [PATCH 45/83] feat: rename interface members across implementors, move declarations to modules Two refactors. Renaming a member declared inside an interface used to be refused outright, because a type implementing it must keep that name; it now edits the declaration, every implementor's method of that name across the program, and their uses, and still refuses with a reason when the implementors cannot be told apart. A method that merely shares a name is refused as before. And a selected whole declaration can be moved into a module of its own: the declaration leaves, an import of the new module replaces it, and the new file declares that module. --- extract_to_file.v | 629 +++++++++++++++++++++++++++++++++++++++++ extract_to_file_test.v | 385 +++++++++++++++++++++++++ handlers.v | 3 + handlers_test.v | 144 +++++++++- rename.v | 191 +++++++++++-- 5 files changed, 1327 insertions(+), 25 deletions(-) create mode 100644 extract_to_file.v create mode 100644 extract_to_file_test.v diff --git a/extract_to_file.v b/extract_to_file.v new file mode 100644 index 00000000..c5899d07 --- /dev/null +++ b/extract_to_file.v @@ -0,0 +1,629 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// Move declaration to a new file: a top-level declaration lifted out of the +// file that holds it into a module of its own. +// +// The refactor is textual, over the same masked buffer the other assists use: +// comments and string bodies are blanked, so no scan is fooled by a brace or an +// identifier inside one, while every offset it reports still addresses the +// original bytes. It reads the buffer, the directory the file sits in and +// nothing else, and it refuses rather than guess — a declaration moved under +// the wrong module name, or into a file that already exists, is worse than no +// action at all. + +// ETfDeclKind is one kind of top-level declaration the refactor can move. +enum ETfDeclKind { + function // `fn make()`, a method with a receiver included + struct_type // `struct Point {}` + enum_type // `enum Color {}` + interface_type // `interface Reader {}` + type_alias // `type Handler = fn (int)`, `type Foo struct {}` + const_block // `const (` ... `)`, or a `const name = 1` of its own +} + +// ETfDeclaration is one top-level declaration: the kind of declaration, the +// name it is declared under, the lines it spans, and where its first byte and +// its last one are in the buffer. +struct ETfDeclaration { + kind ETfDeclKind + name string + start_line int + end_line int + start_offset int + text_end int +} + +// ETfSpan is the extent of a declaration: the byte just past its last one, and +// the line that byte falls on. +struct ETfSpan { + text_end int + end_line int +} + +// build_extract_to_file_action returns the "Move declaration to a new file" +// quick fix for the top-level declaration the selection covers: the +// declaration's lines are deleted from the file, `import ` is written +// where that file takes its imports, and a `.v` beside it is created +// holding the declaration under `module ` of its own. +// +// It is refused when the selection is not exactly one whole declaration, when +// the declaration is `fn main` or the file is `module main` itself, when the +// declaration is a C block (`fn C.printf`), when the file's directory has no +// `v.mod` above it, when the name the new module would take is the module the +// file already is, and when both the file names that could hold the +// declaration are taken. The new module is the declaration's name lowercased, +// with an underscore where a word began in the upper case. +fn (mut app App) build_extract_to_file_action(uri string, content string, sel_range LSPRange) ?CodeAction { + masked := masked_v_code(content) + starts := line_start_offsets(content) + byte_start := position_to_byte_offset(content, starts, sel_range.start.line, sel_range.start.char, + app.position_encoding) + mut byte_end := byte_start + if sel_range.end.line != sel_range.start.line || sel_range.end.char != sel_range.start.char { + byte_end = position_to_byte_offset(content, starts, sel_range.end.line, sel_range.end.char, + app.position_encoding) + } + if byte_start < 0 || byte_end > content.len || byte_end < byte_start { + return none + } + from, to := etf_trim_selection(content, byte_start, byte_end) + if to <= from { + return none + } + decl := etf_selected_declaration(masked, starts, from, to) or { return none } + // `fn main` is where a program starts, and a program is the one file it is + // in: moving the start away would leave that file with none. + if decl.kind == .function && decl.name == 'main' { + return none + } + // An attribute left behind would name nothing, so the block that moves is + // the declaration and the attributes attached to it. + block_start := etf_attribute_block_start(masked, starts, decl) or { return none } + dir := os.dir(uri_to_path(uri)) + if dir == '' || dir == '/' { + return none + } + // The new file is created beside the one the declaration is moved out of, + // which only means anything when the directory is part of a project. + if find_project_root(dir) == '' { + return none + } + // A file of `module main` is a program rather than a module, and the module + // being created must not be the one the file already is. + current := etf_current_module(content) + if current == 'main' { + return none + } + module_name := etf_module_name(decl.name) + if module_name == current { + return none + } + file_name := etf_new_file_name(dir, module_name) or { return none } + // The lines go, their terminator with them, so no blank line is left where + // the declaration was. A declaration that ends the file is ended by its + // last byte instead, because a client may reject a range naming a line that + // does not exist. + delete_end := if decl.end_line + 1 < starts.len { + Position{ + line: decl.end_line + 1 + char: 0 + } + } else { + byte_offset_to_position(content, content.len, app.position_encoding) + } + return CodeAction{ + title: 'Move declaration to a new file' + kind: code_action_kind_quickfix + edit: WorkspaceEdit{ + changes: { + uri: [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: block_start + char: 0 + } + end: delete_end + } + new_text: '' + }, + import_spot(content).edit(module_name), + ] + } + document_changes: [ + TextDocumentEdit{ + text_document: OptionalVersionedTextDocumentIdentifier{ + uri: path_to_uri(os.join_path(dir, file_name)) + version: json2.Any(json2.null) + } + edits: [ + TextEdit{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + new_text: etf_new_file_text(content, starts, block_start, decl, module_name) + }, + ] + }, + ] + } + } +} + +// etf_current_module returns the module the file declares, which is `main` +// when it declares none. +fn etf_current_module(content string) string { + declared := get_module_name(content) + if declared == '' { + return 'main' + } + return declared +} + +// etf_module_name returns the module a declaration is named after: its name +// lowercased, with an underscore where a word began in the upper case +// (`UserService` becomes `user_service`). A name already written with them +// keeps them, so `parse_headers` stays `parse_headers`. +fn etf_module_name(name string) string { + mut out := []u8{cap: name.len + 8} + for idx in 0 .. name.len { + c := name[idx] + if c < `A` || c > `Z` { + out << c + continue + } + if out.len > 0 && out[out.len - 1] != `_` && etf_starts_a_word(name, idx) { + out << `_` + } + out << c + 32 + } + return out.bytestr() +} + +// etf_starts_a_word reports whether the upper-case letter at `idx` begins a +// word: one after a lower-case letter or a digit (`userService`), or the last +// of a run of capitals that a lower-case letter follows (`HTTPServer`). +fn etf_starts_a_word(name string, idx int) bool { + if idx == 0 { + return false + } + prev := name[idx - 1] + if prev >= `a` && prev <= `z` { + return true + } + if prev >= `0` && prev <= `9` { + return true + } + return prev >= `A` && prev <= `Z` && idx + 1 < name.len && name[idx + 1] >= `a` + && name[idx + 1] <= `z` +} + +// etf_new_file_name returns the file the declaration is written to: the module +// name's own, or `_ext.v` when that one is taken, and none when both +// are. The file the declaration is moved out of counts as taken, so a +// declaration named after its module always lands beside it with the suffix. +fn etf_new_file_name(dir string, module_name string) ?string { + plain := '${module_name}.v' + if !os.exists(os.join_path(dir, plain)) { + return plain + } + extended := '${module_name}_ext.v' + if !os.exists(os.join_path(dir, extended)) { + return extended + } + return none +} + +// etf_new_file_text returns the text of the file the declaration moves into: +// the module it belongs to from now on, a blank line, and the declaration as it +// was written — the attributes attached to it included. +fn etf_new_file_text(content string, starts []int, block_start int, decl ETfDeclaration, module_name string) string { + mut text := 'module ${module_name}\n\n' + last := decl.end_line + 1 + for idx in block_start .. last { + text += line_text_of(content, starts, idx) + } + if !text.ends_with('\n') { + text += '\n' + } + return text +} + +// etf_trim_selection narrows the selected bytes to the text between them: the +// blanks an editor drags in at either end of a selection are not part of what +// it covers, so a selection reaching to the end of the line, or beginning on +// the blank one above, still covers the declaration between them. +fn etf_trim_selection(content string, from int, to int) (int, int) { + mut start := from + for start < to && content[start] in [` `, `\t`, `\r`, `\n`] { + start++ + } + mut end := to + for end > start && content[end - 1] in [` `, `\t`, `\r`, `\n`] { + end-- + } + return start, end +} + +// etf_selected_declaration returns the one top-level declaration the selection +// [from, to) covers exactly, or none when it covers none of them or part of +// one. A selection covering two of them is refused, because moving one would +// move only part of the other. +fn etf_selected_declaration(masked string, starts []int, from int, to int) ?ETfDeclaration { + mut chosen := ETfDeclaration{} + mut found := false + for decl in etf_top_level_declarations(masked, starts) { + if decl.start_offset == from { + chosen = decl + found = true + break + } + } + if !found { + return none + } + if to < chosen.text_end { + return none + } + line_end := if chosen.end_line + 1 < starts.len { + starts[chosen.end_line + 1] + } else { + masked.len + } + if to > line_end { + return none + } + // Only blanks may sit between the declaration's last byte and the end of + // the selection: the mask turns a comment into blanks, so one trailing the + // closing brace is part of the line and not more text. + if masked[chosen.text_end..to].trim_space() != '' { + return none + } + return chosen +} + +// etf_top_level_declarations returns every top-level declaration in the buffer, +// in order, with the extent of each. They never nest, since the body of one is +// indented and a line opening one of its own is the next declaration, so the +// scan walks line by line and steps over the body it has just measured. +fn etf_top_level_declarations(masked string, starts []int) []ETfDeclaration { + mut decls := []ETfDeclaration{} + mut line := 0 + for line < starts.len { + mut next := line + 1 + if k := etf_declaration_kind(masked, starts, line) { + if span := etf_declaration_span(masked, starts, line, k) { + next = span.end_line + 1 + if n := etf_declaration_name(masked, starts, line, k) { + decls << ETfDeclaration{ + kind: k + name: n + start_line: line + end_line: span.end_line + start_offset: etf_line_offset(masked, starts, line) + text_end: span.text_end + } + } + } + } + line = next + } + return decls +} + +// etf_declaration_kind returns the kind of declaration `line` opens, or none +// when it opens nothing: a blank line, a continuation of another declaration +// (indented, because every body is), or one of the header's other words — a +// module line, an import, an attribute or a `#flag`. +fn etf_declaration_kind(masked string, starts []int, line int) ?ETfDeclKind { + text := line_text_without_terminator(masked, starts, line) + if line_indent(text) != '' { + return none + } + match first_word(etf_after_pub(text.trim_space())) { + 'fn' { return .function } + 'struct' { return .struct_type } + 'enum' { return .enum_type } + 'interface' { return .interface_type } + 'type' { return .type_alias } + 'const' { return .const_block } + else { return none } + } +} + +// etf_declaration_span returns the extent of the declaration `line` opens, or +// none when it opens nothing this can measure: a body brace that never closes, +// or a header that runs on past the next declaration. +fn etf_declaration_span(masked string, starts []int, line int, kind ETfDeclKind) ?ETfSpan { + match kind { + .function { + // The parameter list comes first, so the body brace is the first + // one after it: a brace inside a parameter type cannot be mistaken + // for the body. + open := etf_byte_after(masked, starts[line], `(`) or { return none } + close := matching_delimiter(masked, open, `(`, `)`) + if close < 0 { + return none + } + return etf_braced_span(masked, starts, close + 1) + } + .struct_type, .enum_type, .interface_type, .type_alias { + return etf_maybe_braced_span(masked, starts, line) + } + .const_block { + return etf_maybe_paren_span(masked, starts, line) + } + } +} + +// etf_braced_span measures a declaration that opens a body: the brace that +// follows `from`, matched to the one that closes it. +fn etf_braced_span(masked string, starts []int, from int) ?ETfSpan { + open := etf_bounded_brace(masked, starts, from) or { return none } + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } +} + +// etf_maybe_braced_span measures a declaration that may open a body: a struct +// type does, a type alias of a function or a plain type does not. One that +// opens none is measured to the end of its line. +fn etf_maybe_braced_span(masked string, starts []int, line int) ?ETfSpan { + if open := etf_bounded_brace(masked, starts, starts[line]) { + close := matching_delimiter(masked, open, `{`, `}`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } + } + return etf_inline_span(masked, starts, line) +} + +// etf_maybe_paren_span measures a `const`: a block is the parentheses after the +// keyword matched, a declaration of one is measured to the end of its line. +// Only a `(` directly after the keyword opens a block, so a `const n = f(1)` +// is a line and not one. +fn etf_maybe_paren_span(masked string, starts []int, line int) ?ETfSpan { + from := etf_past_first_word_offset(masked, starts, line) + if from < masked.len && masked[from] == `(` { + close := matching_delimiter(masked, from, `(`, `)`) + if close < 0 { + return none + } + return ETfSpan{ + text_end: close + 1 + end_line: byte_to_line_number(starts, close + 1) + } + } + return etf_inline_span(masked, starts, line) +} + +// etf_inline_span measures a declaration that opens no bracket to the end of +// its line, or refuses it when the brackets on that line are not all closed +// there: a header that continues below is one this cannot follow. The extent +// ends at the last byte that is not blank, so a comment trailing it is covered +// by the line instead of being measured as part of it. +fn etf_inline_span(masked string, starts []int, line int) ?ETfSpan { + text := line_text_without_terminator(masked, starts, line) + mut depth := 0 + for c in text { + if c == `(` || c == `[` || c == `{` { + depth++ + } else if c == `)` || c == `]` || c == `}` { + depth-- + } + } + if depth != 0 { + return none + } + mut last := text.len + for last > 0 && text[last - 1] in [` `, `\t`] { + last-- + } + return ETfSpan{ + text_end: starts[line] + last + end_line: line + } +} + +// etf_bounded_brace returns the offset of the first brace at or after `from`, +// or none when a line opening a declaration of its own comes first: a header +// cannot run on past one of those. +fn etf_bounded_brace(masked string, starts []int, from int) ?int { + mut i := from + for i < masked.len { + if masked[i] == `{` { + return i + } + if masked[i] == `\n` + && etf_line_opens_a_declaration(masked, starts, byte_to_line_number(starts, i) + 1) { + return none + } + i++ + } + return none +} + +// etf_line_opens_a_declaration reports whether `line` begins a top-level +// declaration, which is where the header of another one can no longer continue. +fn etf_line_opens_a_declaration(masked string, starts []int, line int) bool { + if line >= starts.len { + return true + } + if line_indent(line_text_without_terminator(masked, starts, line)) != '' { + return false + } + if _ := etf_declaration_kind(masked, starts, line) { + return true + } + return false +} + +// etf_declaration_name returns the name the declaration is declared under: the +// identifier after `fn`, past the receiver of a method, or after the keyword of +// a type declaration. A `const` block has no name of its own, so it takes the +// first name it assigns. A name that is not a plain identifier — `C.printf`, +// whose dot gives away a C block — refuses the declaration. +fn etf_declaration_name(masked string, starts []int, line int, kind ETfDeclKind) ?string { + if kind == .const_block { + return etf_const_block_name(masked, starts, line) + } + head := line_text_without_terminator(masked, starts, line).trim_space() + mut rest := etf_past_first_word(etf_after_pub(head)) + if kind == .function && rest.starts_with('(') { + close := matching_delimiter(rest, 0, `(`, `)`) + if close < 0 { + return none + } + rest = rest[close + 1..].trim_space() + } + word := etf_declaration_word(rest) + if !is_plain_identifier(word) { + return none + } + return word +} + +// etf_const_block_name returns the first name a `const` block assigns, since +// the block itself has no name. An empty block, and one whose first entry is +// not a plain `name = value`, names nothing and refuses the refactor. +fn etf_const_block_name(masked string, starts []int, line int) ?string { + head := line_text_without_terminator(masked, starts, line).trim_space() + rest := etf_past_first_word(head) + mut from := starts[line] + head.len - rest.len + block := rest.starts_with('(') + mut to := masked.len + if block { + from++ + close := matching_delimiter(masked, from - 1, `(`, `)`) + if close < 0 { + return none + } + to = close + } else { + to = starts[line] + head.len + } + if from > to { + return none + } + for entry_line in masked[from..to].split_into_lines() { + entry := entry_line.trim_space() + if entry == '' { + continue + } + word := etf_declaration_word(entry) + mut after := entry[word.len..].trim_space() + if !is_plain_identifier(word) || !after.starts_with('=') { + return none + } + return word + } + return none +} + +// etf_attribute_block_start returns the first line the refactor moves: the +// declaration's own line, or the one above it when attributes are attached to +// it. The lines of a multi-line attribute, which this reads line by line and +// therefore cannot follow, refuse the whole declaration. +fn etf_attribute_block_start(masked string, starts []int, decl ETfDeclaration) ?int { + mut first := decl.start_line + for first > 0 { + text := line_text_without_terminator(masked, starts, first - 1).trim_space() + mut balance := 0 + for c in text { + if c == `[` { + balance++ + } else if c == `]` { + balance-- + } + } + if balance != 0 { + return none + } + if !text.starts_with('@[') { + break + } + first-- + } + return first +} + +// etf_after_pub returns `text` past a leading `pub`, which every declaration +// may carry and which hides the keyword from the head of the line. +fn etf_after_pub(text string) string { + if text.starts_with('pub ') { + return text[4..].trim_space() + } + return text +} + +// etf_past_first_word returns `text` past its first word and the blanks that +// follow it. +fn etf_past_first_word(text string) string { + mut i := 0 + for i < text.len && text[i] !in [` `, `\t`] { + i++ + } + for i < text.len && text[i] in [` `, `\t`] { + i++ + } + return text[i..] +} + +// etf_past_first_word_offset returns the offset just past the first word of +// `line` and the blanks that follow it. +fn etf_past_first_word_offset(masked string, starts []int, line int) int { + text := line_text_without_terminator(masked, starts, line) + return starts[line] + text.len - etf_past_first_word(text).len +} + +// etf_declaration_word returns the name a declaration is written with: the +// text up to the blank, the parameter list or a type parameter list that +// follows it. +fn etf_declaration_word(rest string) string { + mut end := 0 + for end < rest.len && rest[end] !in [` `, `\t`, `(`, `[`, `{`] { + end++ + } + return rest[..end] +} + +// etf_byte_after returns the offset of the first `want` at or after `from`, or +// none when there is none. +fn etf_byte_after(text string, from int, want u8) ?int { + mut i := from + for i < text.len { + if text[i] == want { + return i + } + i++ + } + return none +} + +// etf_line_offset returns the offset of the first byte of `line` that is not a +// blank, which is where the declaration on it starts. +fn etf_line_offset(masked string, starts []int, line int) int { + text := line_text_without_terminator(masked, starts, line) + return starts[line] + line_indent(text).len +} diff --git a/extract_to_file_test.v b/extract_to_file_test.v new file mode 100644 index 00000000..b92a2784 --- /dev/null +++ b/extract_to_file_test.v @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import os + +// Tests for the "Move declaration to a new file" quick fix. Every `_test.v` +// file of this project shares module main, so the helpers here carry the etf_ +// prefix. + +// EtfRefactor is everything the refactor produces for one selection: the edits +// written into the original file, the file it creates, the text written to it, +// and the document the original file ends up as. +struct EtfRefactor { + edits []TextEdit + new_uri string + new_text string + applied string +} + +// etf_dir returns the project directory a `tag` writes its files in. +fn etf_dir(tag string) string { + return os.join_path(os.temp_dir(), 'vls_etf_${tag}_${os.getpid()}') +} + +// etf_mkdir creates `path` afresh, so a test that runs again does not see the +// files the last run left in it. +fn etf_mkdir(path string) { + os.rmdir_all(path) or {} + os.mkdir_all(path) or { + assert false, 'etf: mkdir failed for ${path}: ${err}' + return + } +} + +// etf_write writes `content` to `path`. +fn etf_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'etf: write failed for ${path}: ${err}' + return + } +} + +// etf_uri converts a path to the uri a client opens it by. +fn etf_uri(path string) string { + return path_to_uri(path) +} + +// etf_app opens `content` as `service.v` in a project directory of its own — +// a `v.mod` beside it, which is what makes that directory part of one — and +// returns the App an editor has on it with the file's uri. +fn etf_app(tag string, content string) (&App, string) { + dir := etf_dir(tag) + etf_mkdir(dir) + etf_write(os.join_path(dir, 'v.mod'), 'Module: app\n') + path := os.join_path(dir, 'service.v') + etf_write(path, content) + uri := etf_uri(path) + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// etf_app_plain opens `content` in a directory with no `v.mod` above it, which +// is a file outside any project. +fn etf_app_plain(tag string, content string) (&App, string) { + dir := etf_dir(tag) + etf_mkdir(dir) + path := os.join_path(dir, 'service.v') + etf_write(path, content) + uri := etf_uri(path) + return &App{ + open_files: { + uri: content + } + temp_dir: dir + }, uri +} + +// etf_extra writes a file beside the one under test, which the collision tests +// fill the module's file name with. It runs after etf_app, so it does not +// recreate the directory. +fn etf_extra(tag string, name string, content string) { + etf_write(os.join_path(etf_dir(tag), name), content) +} + +// etf_range returns the range covering the whole lines [from, to], what an +// editor reports for a selection dragged over them. +fn etf_range(content string, from int, to int) LSPRange { + lines := content.split_into_lines() + mut last := 0 + for idx in 0 .. to + 1 { + if idx < lines.len { + last = lines[idx].len + } + } + return LSPRange{ + start: Position{ + line: from + char: 0 + } + end: Position{ + line: to + char: last + } + } +} + +// etf_original returns the edits the refactor writes into the original file. +fn etf_original(action CodeAction, uri string) []TextEdit { + edit := action.edit or { + assert false, 'etf: the action must carry a WorkspaceEdit' + return []TextEdit{} + } + return edit.changes[uri] or { + assert false, 'etf: the action must edit the original file' + return []TextEdit{} + } +} + +// etf_created returns the uri and the text of the file the refactor creates. +fn etf_created(action CodeAction) (string, string) { + edit := action.edit or { + assert false, 'etf: the action must carry a WorkspaceEdit' + return '', '' + } + changes := edit.document_changes or { + assert false, 'etf: the new file must be created through documentChanges' + return '', '' + } + assert changes.len == 1, 'etf: exactly one file is created, not ${changes.len}' + assert changes[0].edits.len == 1, 'etf: one edit writes the whole new file, not ${changes[0].edits.len}' + assert changes[0].edits[0].range.start == changes[0].edits[0].range.end, 'etf: the whole file is inserted at its start' + return changes[0].text_document.uri, changes[0].edits[0].new_text +} + +// etf_offset maps a position back to a byte offset in `content`. +fn etf_offset(content string, pos Position) int { + return position_to_byte_offset(content, line_start_offsets(content), pos.line, pos.char, + PositionEncoding.utf16) +} + +// etf_apply applies `edits` to `content`, from the last back, and returns what +// the document holds once a client has taken the quick fix. +fn etf_apply(content string, edits []TextEdit) string { + mut spans := []EtfSpan{} + for one in edits { + spans << EtfSpan{ + start: etf_offset(content, one.range.start) + end: etf_offset(content, one.range.end) + text: one.new_text + } + } + spans.sort(a.start < b.start) + mut out := content + for idx := spans.len - 1; idx >= 0; idx-- { + span := spans[idx] + out = out[..span.start] + span.text + out[span.end..] + } + return out +} + +// EtfSpan is one edit resolved to the byte range it replaces. +struct EtfSpan { + start int + end int + text string +} + +// etf_refactor returns what the refactor produces for the whole lines [from, +// to] of `content`. +fn etf_refactor(tag string, content string, from int, to int) EtfRefactor { + mut app, uri := etf_app(tag, content) + action := app.build_extract_to_file_action(uri, content, etf_range(content, from, to)) or { + assert false, 'etf: a whole declaration must be offered the refactor' + return EtfRefactor{} + } + edits := etf_original(action, uri) + etf_created(action) + new_uri, new_text := etf_created(action) + return EtfRefactor{ + edits: edits + new_uri: new_uri + new_text: new_text + applied: etf_apply(content, edits) + } +} + +// etf_refused reports whether the refactor is refused for the whole lines +// [from, to] of `content`. +fn etf_refused(tag string, content string, from int, to int) bool { + mut app, uri := etf_app(tag, content) + return app.build_extract_to_file_action(uri, content, etf_range(content, from, to)) == none +} + +const etf_function_file = "module app\n\nimport os\n\nfn parse_headers(raw string) []string {\n\theaders := raw.split(',')\n\tprintln(headers.len)\n\treturn headers\n}\n\nfn keep() {\n\tparse_headers('')\n}\n" + +const etf_struct_file = "module app\n\n@[json: 'id']\nstruct UserService {\n\tname string\n\tport int\n}\n\nfn keep() {\n\tu := UserService{\n\t\tname: 'a'\n\t}\n\tprintln(u.name)\n}\n" + +const etf_enum_file = 'module app\n\nenum Colour {\n\tred\n\tgreen\n}\n\nfn keep() {\n\tprintln(Colour.red)\n}\n' + +const etf_const_file = "module app\n\nconst (\n\tdefault_port = 8080\n\tdefault_host = 'localhost'\n)\n\nfn keep() {\n\tprintln(default_port)\n}\n" + +const etf_type_file = "module app\n\ntype Handler = fn (int) bool\n\nfn keep() {\n\tprintln('x')\n}\n" + +const etf_program_file = "module app\n\nfn main() {\n\tprintln('hi')\n}\n" + +const etf_module_main_file = 'module main\n\nstruct Point {\n\tx int\n}\n\nfn keep() {\n\tprintln(Point{})\n}\n' + +const etf_c_file = 'module app\n\nfn C.print(text &char) {}\n\nfn keep() {}\n' + +const etf_flag_file = 'module app\n\n#flag linux -lm\n\nfn keep() {}\n' + +const etf_module_named_file = 'module parse_headers\n\nfn parse_headers(raw string) []string {\n\tprintln(raw)\n}\n' + +fn test_etf_moves_a_function_into_a_module_of_its_own() { + content := etf_function_file + result := etf_refactor('fn_move', content, 4, 8) + assert result.edits.len == 2, 'etf: two edits rewrite the original file: the delete and the import' + assert result.edits[0].range == LSPRange{ + start: Position{ + line: 4 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } + }, 'etf: the deleted range runs from the start of the signature line to the start of the line after the closing brace, got ${result.edits[0].range}' + assert result.edits[0].new_text == '', 'etf: the declaration is deleted, not replaced' + assert result.edits[1].range == LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 0 + } + }, 'etf: the import goes on the line after the last import, got ${result.edits[1].range}' + assert result.edits[1].new_text == 'import parse_headers\n', 'etf: the import names the new module, got ${result.edits[1].new_text}' + assert result.new_uri == etf_uri(os.join_path(etf_dir('fn_move'), 'parse_headers.v')), 'etf: the new file is named after the module, got ${result.new_uri}' + expected := "module parse_headers\n\nfn parse_headers(raw string) []string {\n\theaders := raw.split(',')\n\tprintln(headers.len)\n\treturn headers\n}\n" + assert result.new_text == expected, 'etf: the new file carries the module line and the declaration, got ${result.new_text}' + assert result.applied == "module app\n\nimport os\nimport parse_headers\n\n\nfn keep() {\n\tparse_headers('')\n}\n", 'etf: the original file keeps the rest of its declarations and imports the new module, got ${result.applied}' +} + +fn test_etf_moves_a_struct_with_its_attribute_into_a_module_of_its_own() { + content := etf_struct_file + result := etf_refactor('struct_move', content, 3, 6) + assert result.edits[0].range == LSPRange{ + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 7 + char: 0 + } + }, 'etf: the attribute above the struct is deleted with it, got ${result.edits[0].range}' + assert result.new_uri == etf_uri(os.join_path(etf_dir('struct_move'), 'user_service.v')), 'etf: UserService becomes user_service, got ${result.new_uri}' + expected := "module user_service\n\n@[json: 'id']\nstruct UserService {\n\tname string\n\tport int\n}\n" + assert result.new_text == expected, 'etf: the attribute moves with the declaration it qualifies, got ${result.new_text}' + assert result.edits[1].new_text == '\nimport user_service\n', 'etf: the import takes the spot after the module line, got ${result.edits[1].new_text}' + assert result.applied == "module app\n\nimport user_service\n\n\nfn keep() {\n\tu := UserService{\n\t\tname: 'a'\n\t}\n\tprintln(u.name)\n}\n", 'etf: the original file keeps everything below the declaration, got ${result.applied}' +} + +fn test_etf_moves_an_enum_into_a_module_of_its_own() { + content := etf_enum_file + result := etf_refactor('enum_move', content, 2, 5) + assert result.new_uri == etf_uri(os.join_path(etf_dir('enum_move'), 'colour.v')), 'etf: Colour becomes colour, got ${result.new_uri}' + assert result.new_text == 'module colour\n\nenum Colour {\n\tred\n\tgreen\n}\n', result.new_text +} + +fn test_etf_moves_a_const_block_into_a_module_of_its_own() { + content := etf_const_file + result := etf_refactor('const_move', content, 2, 5) + assert result.new_uri == etf_uri(os.join_path(etf_dir('const_move'), 'default_port.v')), 'etf: the block is named after the first name it assigns, got ${result.new_uri}' + assert result.new_text == "module default_port\n\nconst (\n\tdefault_port = 8080\n\tdefault_host = 'localhost'\n)\n", result.new_text +} + +fn test_etf_moves_a_type_alias_into_a_module_of_its_own() { + content := etf_type_file + result := etf_refactor('type_move', content, 2, 2) + assert result.new_uri == etf_uri(os.join_path(etf_dir('type_move'), 'handler.v')), 'etf: Handler becomes handler, got ${result.new_uri}' + assert result.new_text == 'module handler\n\ntype Handler = fn (int) bool\n', result.new_text +} + +fn test_etf_writes_the_new_file_beside_the_name_it_already_has_taken() { + mut app, uri := etf_app('collision', etf_struct_file) + etf_extra('collision', 'user_service.v', 'module other\n') + action := app.build_extract_to_file_action(uri, etf_struct_file, etf_range(etf_struct_file, + 3, 6)) or { + assert false, 'etf: a taken module name is renamed, not refused' + return + } + new_uri, _ := etf_created(action) + assert new_uri == etf_uri(os.join_path(etf_dir('collision'), 'user_service_ext.v')), 'etf: the declaration lands in user_service_ext.v, got ${new_uri}' + assert os.exists(os.join_path(etf_dir('collision'), 'user_service.v')), 'etf: the file that was already there is left alone' +} + +fn test_etf_refuses_when_both_module_names_are_taken() { + mut app, uri := etf_app('taken', etf_struct_file) + etf_extra('taken', 'user_service.v', 'module other\n') + etf_extra('taken', 'user_service_ext.v', 'module other\n') + assert app.build_extract_to_file_action(uri, etf_struct_file, etf_range(etf_struct_file, + 3, 6)) == none, 'etf: no file may be overwritten to make room for the declaration' +} + +fn test_etf_refuses_a_selection_that_stops_short_of_the_closing_brace() { + // The signature line alone: the selection does not reach the body. + assert etf_refused('short', etf_function_file, 4, 4), 'etf: the declaration is not covered' +} + +fn test_etf_refuses_a_selection_that_begins_inside_the_declaration() { + // A selection starting after the first byte of the signature line covers + // part of the line the declaration is on. + mid := LSPRange{ + start: Position{ + line: 4 + char: 2 + } + end: Position{ + line: 8 + char: 1 + } + } + mut app, uri := etf_app('mid', etf_function_file) + assert app.build_extract_to_file_action(uri, etf_function_file, mid) == none, 'etf: a declaration covered in part is not a whole one' +} + +fn test_etf_refuses_a_selection_of_two_declarations() { + assert etf_refused('two', etf_function_file, 4, 12), 'etf: two declarations cannot be moved as one' +} + +fn test_etf_refuses_an_empty_selection() { + empty := LSPRange{ + start: Position{ + line: 4 + char: 0 + } + end: Position{ + line: 4 + char: 0 + } + } + mut app, uri := etf_app('empty', etf_function_file) + assert app.build_extract_to_file_action(uri, etf_function_file, empty) == none, 'etf: a cursor selects no declaration' +} + +fn test_etf_refuses_a_statement_inside_a_body() { + // The lines of `keep` below its own declaration: a statement is not a + // declaration, and the one the indentation belongs to is not covered. + assert etf_refused('body', etf_function_file, 11, 11), 'etf: a statement inside a function is not a top-level declaration' +} + +fn test_etf_refuses_fn_main() { + assert etf_refused('main_fn', etf_program_file, 2, 4), 'etf: the start of a program is not moved out of it' +} + +fn test_etf_refuses_a_file_of_module_main() { + assert etf_refused('main_module', etf_module_main_file, 2, 4), 'etf: a program file holds no module to move a declaration into' +} + +fn test_etf_refuses_a_declaration_written_in_c() { + assert etf_refused('c_block', etf_c_file, 2, 2), 'etf: a C declaration belongs to the file that declares it' +} + +fn test_etf_refuses_a_flag_line() { + assert etf_refused('flag', etf_flag_file, 2, 2), 'etf: a compiler flag is not a declaration' +} + +fn test_etf_refuses_a_file_outside_a_project() { + mut app, uri := etf_app_plain('loose', etf_function_file) + assert find_project_root(etf_dir('loose')) == '', 'etf: this fixture must sit outside a project, or it tests nothing' + assert app.build_extract_to_file_action(uri, etf_function_file, etf_range(etf_function_file, + 4, 8)) == none, 'etf: a file with no v.mod above it is not part of a project' +} + +fn test_etf_refuses_a_name_that_is_the_module_the_file_is() { + assert etf_refused('named', etf_module_named_file, 2, 4), 'etf: the new module must not be the module the file already is' +} diff --git a/handlers.v b/handlers.v index 83d03cdc..c309f7d1 100644 --- a/handlers.v +++ b/handlers.v @@ -8356,6 +8356,9 @@ fn (mut app App) handle_code_action(request Request) Response { if action := app.build_extract_function_action(uri, content, params.range) { actions << action } + if action := app.build_extract_to_file_action(uri, content, params.range) { + actions << action + } if action := app.build_add_import_action(uri, content, params.range) { actions << action } diff --git a/handlers_test.v b/handlers_test.v index 484a1b83..3db125ea 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -11980,8 +11980,10 @@ fn test_rename_refuses_interface_members_and_the_names_they_share() { } // V needs no declaration to implement an interface: the types that // implement one must keep the names of its members, and a rename cannot - // see which types those are. - for at in ['main.v:4:2', 'main.v:5:2', 'main.v:13:15', 'main.v:10:2', 'main.v:28:15', 'main.v:21:13'] { + // see which types those are. A method the interface declares is followed + // into them (see test_ifr_rename_edits_the_declaration_and_every_implementor); + // a field of it is not, and the names they share stay where they are. + for at in ['main.v:5:2', 'main.v:13:15', 'main.v:10:2', 'main.v:21:13'] { count := rename_edit_count(mut app, uris, at) or { if v3_answers_line_info { assert err.msg().contains('interface'), '${at}: ${err}' @@ -11994,6 +11996,10 @@ fn test_rename_refuses_interface_members_and_the_names_they_share() { assert rename_edits_in({ 'main.v': rename_interface_main }, 'main.v:34:2') == ['main.v:34:2', 'main.v:38:10'] + // The method the interface declares is renamed with its implementation. + assert rename_edits_in({ + 'main.v': rename_interface_main + }, 'main.v:4:2') == ['main.v:13:15', 'main.v:28:15', 'main.v:4:2'] } // The types V builds in are the ones of V: `i128` and `u128` since V's #28877, @@ -12570,12 +12576,11 @@ fn test_rename_refuses_a_method_an_interface_declares_and_names_the_interface() } else { assert err.msg().contains('is a member of the interface `Speaker`'), err.msg() } - // And the member of the interface itself. - if _ := app.rename_request(rename_request_named(uris, 'main.v:4:2', 'talk')) { - assert false, 'renaming a member of an interface must be refused' - } else { - assert err.msg().contains('is a member of the interface `Speaker`'), err.msg() - } + // And the member of the interface itself, which the types that implement + // it follow (see test_ifr_rename_edits_the_declaration_and_every_implementor). + assert rename_edits_in({ + 'main.v': rename_interface_methods_main + }, 'main.v:4:2') == ['main.v:11:12', 'main.v:20:11', 'main.v:27:23', 'main.v:4:2'] // A method no interface declares is renamed, with its use. assert rename_edits_in({ 'main.v': rename_interface_methods_main @@ -17942,3 +17947,126 @@ fn test_cap_dedup_runs_before_the_cap() { assert got[100].message == '... and 50 more', 'dedup ran before the cap: ${got[100].message}' assert (got[100].source or { 'none' }) == 'none', 'the marker carries no source: ${got[100].source}' } + +// A program whose interface two files' types implement: the rename of a member +// follows them, wherever they are declared. +const ifr_two_files_main = "module main + +interface Speaker { + speak() string +} + +struct Cat { + name string +} + +fn (c Cat) speak() string { + return 'meow' +} + +fn greet(s Speaker) string { + return s.speak() +} + +fn main() { + d := Dog{ + name: 'rex' + } + println(greet(d) + d.speak()) +} +" + +const ifr_two_files_other = "module main + +struct Dog { + name string +} + +fn (d Dog) speak() string { + return 'woof' +} +" + +fn test_ifr_rename_edits_the_declaration_and_every_implementor() { + // The declaration, the method of each implementor, and every use, over the + // files of the program. + assert rename_edits_in({ + 'main.v': ifr_two_files_main + 'other.v': ifr_two_files_other + }, 'main.v:4:2') == ['main.v:11:12', 'main.v:16:11', 'main.v:23:23', 'main.v:4:2', 'other.v:7:12'] + // An interface no type implements: its declaration and the uses that name it. + assert rename_edits_in({ + 'main.v': 'module main\n\ninterface Speaker {\n\tspeak() string\n}\n\nfn greet(s Speaker) string {\n\treturn s.speak()\n}\n' + }, 'main.v:4:2') == ['main.v:4:2', 'main.v:8:11'] +} + +// What a rename of a member cannot follow, and a method that only shares a +// member's name: refused, with the interface named, as they always were. +fn test_ifr_refuses_what_it_cannot_follow() { + mut app, uris := new_rename_project_app_with({ + 'main.v': rename_interface_methods_main + }) + defer { + cleanup_rename_app(mut app) + } + // The method of a third type: the interface keeps its member, so it stays. + if _ := app.rename_request(rename_request_named(uris, 'main.v:11:12', 'bark')) { + assert false, 'a method an interface declares must be refused' + } else { + assert err.msg() == '`speak` is a member of the interface `Speaker` at main.v:3:11: a type that implements it must keep that name, and V does not say which types those are, so this rename could break the program', err.msg() + } + // An interface whose second member is declared over several lines: the + // implementations of the first cannot be told apart. + mut multi, multi_uris := new_rename_project_app_with({ + 'main.v': "module main\n\ninterface Speaker {\n\tspeak() string\n\tannounce(\n\t\twho string\n\t) string\n}\n\nstruct Dog {\n\tname string\n}\n\nfn (d Dog) speak() string {\n\treturn 'woof'\n}\n\nfn main() {\n\td := Dog{\n\t\tname: 'rex'\n\t}\n\tprintln(d.speak())\n}\n" + }) + defer { + cleanup_rename_app(mut multi) + } + if _ := multi.rename_request(rename_request_named(multi_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member this cannot follow must be refused' + } else { + assert err.msg().contains('interface `Speaker`'), err.msg() + assert err.msg().contains('signature that cannot be matched'), err.msg() + } + // The shadowing refusals are what they were. + mut branch, branch_uris := new_rename_project_app_with({ + 'main.v': rename_branch_main + }) + defer { + cleanup_rename_app(mut branch) + } + if _ := branch.rename_request(rename_request_named(branch_uris, 'main.v:6:3', 'x')) { + assert false, 'renaming `y` to `x` must be refused' + } else { + assert err.msg().contains('`x` is already declared at main.v:4'), err.msg() + assert err.msg().contains('redefinition of `x`'), err.msg() + } + // A name two interfaces declare: the implementations of this one cannot be + // told apart from the ones of the other. + mut shared, shared_uris := new_rename_project_app_with({ + 'main.v': "module main\n\ninterface Speaker {\n\tspeak() string\n}\n\ninterface Loud {\n\tspeak() string\n}\n\nstruct Dog {\n\tname string\n}\n\nfn (d Dog) speak() string {\n\treturn 'woof'\n}\n\nfn main() {\n\td := Dog{\n\t\tname: 'rex'\n\t}\n\tprintln(d.speak())\n}\n" + }) + defer { + cleanup_rename_app(mut shared) + } + if _ := shared.rename_request(rename_request_named(shared_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member two interfaces declare must be refused' + } else { + assert err.msg().contains('interface `Loud`'), err.msg() + } + // A `pub` interface a method of another module implements: that module is + // outside the program the rename walks, so the refusal stands. + mut cross, cross_uris := new_rename_project_app_with({ + 'main.v': "module main\n\npub interface Speaker {\n\tspeak() string\n}\n\nstruct Cat {\n\tname string\n}\n\nfn (c Cat) speak() string {\n\treturn 'meow'\n}\n\nfn main() {\n\tc := Cat{\n\t\tname: 'rex'\n\t}\n\tprintln(c.speak())\n}\n" + 'lib/thing.v': "module lib\n\nstruct Widget {\n\tname string\n}\n\npub fn (w Widget) speak() string {\n\treturn 'hi'\n}\n" + }) + defer { + cleanup_rename_app(mut cross) + } + if _ := cross.rename_request(rename_request_named(cross_uris, 'main.v:4:2', 'talk')) { + assert false, 'a member a method of another module implements must be refused' + } else { + assert err.msg() == '`speak` is a member of the interface `Speaker` at main.v:3:15: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program', err.msg() + } +} diff --git a/rename.v b/rename.v index e9f5825a..272d2bfa 100644 --- a/rename.v +++ b/rename.v @@ -8,11 +8,13 @@ import os // names, but it gives nothing for a declaration itself or for the key of a // struct literal, and in `Color.red` it names the value for `Color` as well. -// RenameTarget is the symbol that a rename changes: its name and the position -// of the name in its declaration. +// RenameTarget is the symbol that a rename changes: its name, the position of +// the name in its declaration, and where each implementor of an interface +// member declares its method of that name. struct RenameTarget { - symbol string - anchor Location + symbol string + anchor Location + implementors ?[]Location } // rename_anchor_cache returns where the names that renames asked about are @@ -68,10 +70,16 @@ fn (mut app App) rename_target(uri string, line int, ch int, scope IndexScope, m if !uri_is_in_index_scope(anchor.uri, scope) { return error('`${symbol}` is declared in ${os.file_name(uri_to_path(anchor.uri))}, outside this project, so it cannot be renamed here') } - app.check_interface_member(anchor, symbol, scope)! + mut implementors := ?[]Location(none) + if iface := app.enclosing_interface(anchor) { + implementors = app.interface_implementors(anchor, symbol, iface, scope)! + } else { + app.check_interface_member(anchor, symbol, scope)! + } return RenameTarget{ - symbol: symbol - anchor: anchor + symbol: symbol + anchor: anchor + implementors: implementors } } @@ -170,7 +178,9 @@ fn rename_max_occurrences() int { } // rename_locations returns every occurrence of `target` in `scope`. It refuses -// when an occurrence cannot be told apart from the target. +// when an occurrence cannot be told apart from the target. For a member of an +// interface it returns the occurrences of each implementor's method of that +// name as well: the interface cannot keep its member while they change. fn (mut app App) rename_locations(target RenameTarget, scope IndexScope, request_id int, mut cache map[string]?Location) ![]Location { candidates := app.rename_candidates(target.symbol, scope, target.anchor) max_occurrences := rename_max_occurrences() @@ -285,6 +295,17 @@ fn (mut app App) rename_locations(target RenameTarget, scope IndexScope, request } return error('`${target.symbol}` at ${os.file_name(uri_to_path(cand.uri))}:${cand.range.start.line + 1}:${cand.range.start.char + 1} could not be resolved, and renaming around it could break the program') } + // A member of an interface: the method each implementor declares is renamed + // with the member, and their occurrences are walked the same way. + if implementors := target.implementors { + for method in implementors { + each := RenameTarget{ + symbol: target.symbol + anchor: method + } + locations << app.rename_locations(each, scope, request_id, mut cache)! + } + } return locations } @@ -399,6 +420,7 @@ const ierror_method_names = ['msg', 'code'] struct InterfaceDeclaration { name string where Location + first int // the line its declaration starts on, where its body follows } // InterfaceMember is a method or field that an interface declares, and the @@ -408,16 +430,14 @@ struct InterfaceMember { iface InterfaceDeclaration } -// check_interface_member refuses a rename of a member of an interface, or of a -// method or a field with the name of one: V needs no declaration to implement -// an interface, so the types that implement it must keep that name, and a -// rename cannot see which types those are. The rename does not change the -// interface with it, so it is refused, and the interface that declares the -// member is named. +// check_interface_member refuses a rename of a method or a field with the name +// of one that an interface declares: V needs no declaration to implement an +// interface, so the types that implement it must keep that name, and a rename +// cannot see which types those are. The rename does not change the interface +// with it, so it is refused, and the interface that declares the member is +// named. A member declared inside an interface is not this case: the rename +// follows it into the types that implement it (see interface_implementors). fn (mut app App) check_interface_member(anchor Location, symbol string, scope IndexScope) ! { - if iface := app.enclosing_interface(anchor) { - return error('`${symbol}` is a member of the interface `${iface.name}` at ${name_pos_text(app.name_pos_of(iface.where))}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') - } kind := app.indexed_declaration_kind(anchor) if kind !in [sym_kind_method, sym_kind_field] { return @@ -430,6 +450,141 @@ fn (mut app App) check_interface_member(anchor Location, symbol string, scope In } } +// interface_implementors returns where the program declares each method named +// `symbol`, a member of `iface`: the methods the rename edits with it, since a +// type that has the method implements the interface. V matches an interface by +// shape, so the interface cannot keep its member while those methods change. +// +// What cannot be followed is refused. A field member: an implementor declares it +// as a field of its own, where the uses that name it are the uses of that field +// alone, so the rename would edit code that has nothing to do with the +// interface. A body this cannot read, or a name another interface declares too: +// the types that implement the member cannot be told apart from the ones that +// implement something else. A method of a `pub` interface that another module +// declares: that module is outside the program the rename walks, so the methods +// this finds say nothing about the ones it cannot see, and the refusal the +// interface member always had stands. +fn (mut app App) interface_implementors(anchor Location, symbol string, iface InterfaceDeclaration, scope IndexScope) ![]Location { + start := '`${symbol}` is a member of the interface `${iface.name}` at ${name_pos_text(app.name_pos_of(iface.where))}' + body_first, body := app.interface_body(iface) + mut at := anchor.range.start.line - body_first + if at < 0 || at >= body.len { + at = 0 + } + for line in body { + if interface_member_form(line) == .unreadable { + return error('${start}: it declares a member with a signature that cannot be matched, so the types that implement it cannot be told apart, and this rename could break the program') + } + } + // Only a method is followed: an implementor declares a field member as a + // field of its own, and the uses that name it are the uses of that field. + if interface_member_form(body[at]) != .method { + return error('${start}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') + } + for other in app.interface_members(scope) { + if other.member != symbol || same_anchor_location(other.iface.where, iface.where) { + continue + } + return error('${start}: the interface `${other.iface.name}` declares a member of that name too, so the types that implement it cannot be told apart, and this rename could break the program') + } + methods := app.interface_method_declarations(symbol, scope) + if app.line_at(iface.where).trim_left(' \t').starts_with('pub ') { + for method in methods { + if !app.in_module_of(method, iface.where) { + return error('${start}: the types that implement it must keep that name, and V does not say which types those are, so this rename could break the program') + } + } + } + return methods +} + +// interface_body returns the line the body of `iface` starts on and its lines, +// between the one its name is on and the one its closing brace is on, with +// their comments and the text of their strings taken out. +fn (mut app App) interface_body(iface InterfaceDeclaration) (int, []string) { + content := app.file_text(iface.where.uri) + lines := content.split_into_lines() + code := source_code_lines(content) + last := declaration_end_line(lines, code, iface.first) + mut body := []string{} + for i in iface.first + 1 .. int_min(last + 1, code.len) { + if code[i].trim_space().starts_with('}') { + break + } + body << code[i] + } + return iface.first + 1, body +} + +// What a line of an interface body declares. +enum InterfaceMemberForm { + method // `name(...) ret` + field // `name Type` + embedded // `Name`, an interface this one extends + section // `mut:`, the line that opens one + blank // nothing + unreadable // what this cannot read +} + +// interface_member_form reads what `line`, one line of an interface body, +// declares. +fn interface_member_form(line string) InterfaceMemberForm { + mut rest := line.trim_space() + if rest == '' { + return .blank + } + if rest.starts_with('pub ') { + rest = rest['pub '.len..].trim_left(' \t') + } + if rest.starts_with('mut:') || rest.starts_with('__global:') { + return .section + } + mut end := 0 + for end < rest.len && is_ident_char(rest[end]) { + end++ + } + if end == 0 { + return .unreadable + } + if rest[0].is_capital() { + return .embedded + } + after := rest[end..].trim_left(' \t') + if !after.starts_with('(') { + return .field + } + // The signature has to close on the line it opens on: one that does not + // cannot be read as a member at all. + open := end + (rest.len - end - after.len) + if matching_delimiter(rest, open, `(`, `)`) < 0 { + return .unreadable + } + return .method +} + +// interface_method_declarations returns where the files of `scope` declare each +// method named `member`. +fn (mut app App) interface_method_declarations(member string, scope IndexScope) []Location { + mut found := []Location{} + mut uris := app.symbol_index.keys() + uris.sort() + for uri in uris { + if !uri_is_in_index_scope(uri, scope) { + continue + } + for s in app.index_doc_symbols(uri) { + if s.kind != sym_kind_method || extract_simple_fn_name(s.name) != member { + continue + } + found << Location{ + uri: uri + range: s.selection_range + } + } + } + return found +} + // indexed_declaration_kind is the kind of the declaration of the index whose // name is at `loc`, the field of a struct included, or 0 when there is none. fn (mut app App) indexed_declaration_kind(loc Location) int { @@ -464,6 +619,7 @@ fn (mut app App) enclosing_interface(loc Location) ?InterfaceDeclaration { uri: loc.uri range: s.selection_range } + first: first } } } @@ -505,6 +661,7 @@ fn (mut app App) interface_members(scope IndexScope) []InterfaceMember { uri: uri range: s.selection_range } + first: s.range.start.line } first := s.range.start.line last := declaration_end_line(lines, code, first) From 601151712f35ccfbf28cd1eaa2a42bdcd09abccd Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 12:11:38 +0330 Subject: [PATCH 46/83] perf: check only the changed file's module when the program's imports cannot move A change confined to a body, with the import block unchanged and every local import of the program's main resolving inside the changed module, now checks that module instead of the whole project: measured run time more than halved on a 300-file project, with the same diagnostics for the file and its program. The target is named in the perf log, and the other open files of the program keep their diagnostics in both modes. --- interop.v | 119 +++++++++++++-- interop_test.v | 406 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 513 insertions(+), 12 deletions(-) diff --git a/interop.v b/interop.v index b3023b3b..9d32f643 100644 --- a/interop.v +++ b/interop.v @@ -1124,6 +1124,57 @@ fn (app &App) imports_local_module(path string, program_dir string) bool { return false } +// narrow_check_dir returns the module directory of the file at `real_path` when +// a check of that directory alone, as `v -check -shared .` there, answers for +// the program it belongs to — the cheapest sufficient answer for a change that +// cannot alter what the program resolves — or '' when the program's own +// directory stays the target. Three things are required, and none of them is +// assumed: +// +// - The program's directory is not the module's own, so the narrowed target +// covers strictly less than the program tree does. +// - The parsed imports of the new content are those of the content the +// program was last checked with, so the change can be no new, removed or +// reordered import: what the program resolves is the resolve it already +// resolved, and the compiler finds every module of it in the same place. +// - The program's main module imports the module of the changed file, and +// every module of the project its files import sits in that module's +// directory. The check walks the directory it is run in and what that +// imports, so a module the program imports from elsewhere would stop being +// checked at all, and the program's other open files would lose the answer +// the whole program gives them. +// +// The reachability is read off program_reaches and the per-import resolution +// off resolve_local_module_dir; no import closure is walked again here. +fn (app &App) narrow_check_dir(real_path string, program_dir string, text string) string { + module_dir := normalize_overlay_path(os.dir(normalize_overlay_path(real_path))) + if module_dir == program_dir { + return '' + } + // The file's previous content is the one the program was last checked + // from: what the file holds on disk, since a buffer that was never saved + // has none, and a buffer that was saved has its own save as the previous + // state. + previous := os.read_file(real_path) or { return '' } + if parse_imports(text) != parse_imports(previous) { + return '' + } + vmod_root := normalize_overlay_path(find_project_root(program_dir)) + if !app.program_reaches(program_dir, vmod_root, module_dir) { + return '' + } + for program_file in app.v_files_in(program_dir) { + for module_path in parse_imports(app.source_text(program_file)) { + if imported := resolve_local_module_dir(module_path, program_dir, vmod_root) { + if imported != module_dir { + return '' + } + } + } + } + return module_dir +} + // resolve_local_module_dir returns the directory of the module imported as // `module_path` by a program in `program_dir`, if it is one of the project's: // `a.b` is `a/b` below the program or below the v.mod root, and `proj.a` is `a` @@ -1402,6 +1453,11 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { // within the session and across restarts (see diag_cache.v). program_dir := app.program_root(real_path) overlay_root := program_overlay_root(real_path, program_dir) + // The target the check runs against: the program's directory, or the + // module directory of the changed file when that alone answers for the + // program (see narrow_check_dir). + mut narrow_dir := app.narrow_check_dir(real_path, program_dir, text) + check_target := if narrow_dir != '' { 'narrow' } else { 'full' } // The defines a check runs with are resolved here, before the fingerprint // they salt and the arguments they are appended to (see vls_config.v). defines := app.check_defines(real_path) @@ -1428,7 +1484,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { } } if os.getenv('VLS_PERF_LOG') != '' { - app.send_log_message('diagnostics cache root=${overlay_root} fp=${fingerprint.hash().hex()} hit=${cache_hit}', + app.send_log_message('diagnostics cache root=${overlay_root} fp=${fingerprint.hash().hex()} hit=${cache_hit} target=${check_target}', 4) } if cache_hit != '' { @@ -1522,17 +1578,34 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { compile_target = singlefile_tmppath } + // A narrowed check walks only the module directory of the changed file, in + // its counterpart inside the same overlay: the buffers that overlay holds + // are the ones it reads, and the rest of the program is not walked at all. + mut narrow_work_dir := '' + if narrow_dir != '' && use_multifile { + if narrow_rel := overlay_relative_path(narrow_dir, overlay.source_root) { + narrow_work_dir = os.join_path(overlay.temp_root, narrow_rel) + } + } + if narrow_work_dir != '' { + compile_target = narrow_work_dir + } else { + // No overlay to narrow it in: the whole program stays the target. + narrow_dir = '' + } mut cmd_args := []string{} module_name := get_module_name(text) - // A module checked with the program that imports it is not a library there. + // A module checked with the program that imports it is not a library there, + // and the directory a narrowed check walks holds a library module on its + // own, which has no `fn main()` of its own to satisfy. is_library_module := if use_multifile { - !app.is_program_dir(overlay.source_work_dir) + !app.is_program_dir(if narrow_dir != '' { narrow_dir } else { overlay.source_work_dir }) } else { module_name != '' && module_name != 'main' } if use_multifile { cmd_args = check_args_with_defines(build_v_check_args_multifile(is_library_module), defines) - log('MULTIFILE CMD - compile_target=${compile_target}): v ${cmd_args.join(' ')}') + log('MULTIFILE CMD - compile_target=${compile_target} narrow=${narrow_dir != ''}): v ${cmd_args.join(' ')}') } else { cmd_args = check_args_with_defines(build_v_check_args_single(file_to_check, is_library_module), defines) @@ -1542,7 +1615,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { exec_dir := if use_multifile { compile_target } else { working_dir } diagnostic_source_dir := if use_multifile { exec_dir } else { os.dir(file_to_check) } program_uris := if use_multifile { - app.program_open_files(real_path, overlay) + app.program_open_files_answered(real_path, program_dir, narrow_dir) } else { map[string]string{} } @@ -1592,7 +1665,7 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { parse_ms := time.now().unix_milli() - parse_stage_start_ms if os.getenv('VLS_PERF_LOG') != '' { total_ms := time.now().unix_milli() - stages_total_start_ms - app.send_log_message('diagnostics stages fp=${fp_ms} prep=${prep_ms} run=${run_ms} parse=${parse_ms} total=${total_ms}', + app.send_log_message('diagnostics stages fp=${fp_ms} prep=${prep_ms} run=${run_ms} parse=${parse_ms} total=${total_ms} target=${check_target}', 4) } if server_exe == '' { @@ -1601,9 +1674,16 @@ fn (mut app App) run_v_check(path string, text string) []JsonError { if use_multifile { // The check covered the whole program, so it answers for every open file - // of that program too, including the ones it found nothing in. + // of that program too, including the ones it found nothing in. A narrowed + // one covered the module directory of the changed file alone, so + // program_uris held only the program's open files that sit inside it: the + // rest are not published for at all and keep what they already show. app.program_errors = found.program.clone() - app.program_dir_checked = overlay.source_work_dir + app.program_dir_checked = if narrow_dir != '' { + narrow_dir + } else { + overlay.source_work_dir + } log('FILTERED ERRORS: ${found.file.len} of ${found.parsed}') app.cache_v_check_result(path, program_dir, fingerprint, found.file, x.exit_code, found.parsed) return found.file @@ -1626,13 +1706,30 @@ struct CheckErrors { // program_open_files returns the other open files of the program `overlay` // checks than the file at `real_path`: their URIs by normalized path. fn (app &App) program_open_files(real_path string, overlay CompilationOverlay) map[string]string { + return app.program_open_files_answered(real_path, overlay.source_work_dir, '') +} + +// program_open_files_answered is program_open_files for the program directory +// given directly, restricted to the files inside `answered_dir` when the check +// answers only for those (a narrowed target, see narrow_check_dir). A check of +// one module directory says nothing about the program's other open files, so +// they keep the diagnostics they have rather than be told they hold none: an +// empty set would clear the errors the last check of the whole program found in +// them. +fn (app &App) program_open_files_answered(real_path string, program_dir string, answered_dir string) map[string]string { mut program_uris := map[string]string{} for open_uri, _ in app.open_files { open_path := uri_to_path(open_uri) - if normalized_index_path(open_path) != normalized_index_path(real_path) - && app.program_root(open_path) == overlay.source_work_dir { - program_uris[normalized_index_path(open_path)] = open_uri + if normalized_index_path(open_path) == normalized_index_path(real_path) { + continue + } + if app.program_root(open_path) != program_dir { + continue + } + if answered_dir != '' && !path_is_within(normalize_overlay_path(open_path), answered_dir) { + continue } + program_uris[normalized_index_path(open_path)] = open_uri } return program_uris } diff --git a/interop_test.v b/interop_test.v index acb8c5ce..7eab3b2e 100644 --- a/interop_test.v +++ b/interop_test.v @@ -4725,8 +4725,412 @@ fn test_rush_publishes_the_fast_tier_before_the_flushed_slow_one() { assert first[0].kind == .fast, 'the fast tier publishes before the flushed slow one' scheduler.finish(first[0]) second, _ := scheduler.take_ready_jobs(time.now().unix_milli()) - assert second.len == 1, 'the flushed job follows the fast one' + assert second.len == 1, 'the flushed job follows the fast tier' assert second[0].kind == .slow, 'the slow answer supersedes the fast one' assert second[0].uri == uri, 'the flushed job is the one the file asked for' scheduler.finish(second[0]) } + +// ============================================================================ +// The narrowed check target: the module directory of the changed file +// ============================================================================ + +// nrw_base_files is the project of these tests: a program whose main module +// imports the module of the changed file, which sits in a directory of its own +// with a second file of the same module next to it. The changed file imports +// only the standard library, so its own directory answers for it. +fn nrw_base_files() map[string]string { + return { + 'v.mod': "Module {\n\tname: 'nrwproj'\n}\n" + 'main.v': 'module main\n\nimport util\n\nfn main() {\n\tprintln(util.value())\n}\n' + 'util/util.v': "module util\n\nimport os\nimport strings\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + 'util/helper.v': 'module util\n\npub fn other() int {\n\treturn 1\n}\n' + } +} + +// NrwStub is the fixture of the narrowed-check tests: the project, the paths a +// test names in it, and what VLS_V_COMMAND was before the stand-in compiler. +struct NrwStub { + dir string // the temporary root, removed afterwards + marker string // where the stand-in compiler records what it was asked + proj string // the project root the program is checked from + main_uri string + util_uri string + other_uri string + previous string +} + +// nrw_stub_source is a stand-in for the V compiler. It records what it was +// asked — the argv, the working directory it was run in, and the `.v` files it +// found there — in the file VLS_NRW_MARKER names, and answers a check with one +// diagnostic per `.v` file of that directory, at line 3 column 5, so a check +// that walks a directory is told apart from one that walks the whole project. +const nrw_stub_source = r"module main + +import os + +fn main() { + mut files := os.ls(os.getwd()) or { [] } + files.sort() + marker := os.getenv('VLS_NRW_MARKER') + if marker != '' { + mut f := os.open_append(marker) or { return } + f.write_string('---\n') or {} + f.write_string('argv=' + os.args[1..].join(' ') + '\n') or {} + f.write_string('cwd=' + os.getwd() + '\n') or {} + for entry in files { + if entry.ends_with('.v') { + f.write_string('file=' + entry + '\n') or {} + } + } + f.close() + } + for entry in files { + if entry.ends_with('.v') { + println(entry + ':3:5: error: nrw stub error in ' + entry) + } + } +} +" + +// nrw_stub_exe builds the stand-in compiler once for this test process, or '' +// when this machine has no V to build it with. +fn nrw_stub_exe() string { + root := os.join_path(os.temp_dir(), 'vls_nrw_stub_${os.getpid()}_${nrw_stub_source.hash().hex()}') + interop_test_must_mkdir_all(root) + built := $if windows { + os.join_path(root, 'stub.exe') + } $else { + os.join_path(root, 'stub') + } + if os.exists(built) { + return built + } + src := os.join_path(root, 'stub.v') + interop_test_must_write_file(src, nrw_stub_source) + // The compiler of this machine builds it: VLS_V_COMMAND is what the tests + // are driving, and it must not be asked for. + previous := os.getenv('VLS_V_COMMAND') + os.unsetenv('VLS_V_COMMAND') + result := run_v_argv(['-o', built, src], root) + restore_v_command(previous) + if result.exit_code != 0 || !os.exists(built) { + os.rm(built) or {} + eprintln('skipped: could not build the stand-in compiler: ${result.output}') + return '' + } + $if !windows { + os.chmod(built, 0o755) or { + eprintln('skipped: could not run the stand-in compiler: ${err}') + return '' + } + } + return built +} + +// nrw_stub_app writes `files` (project-relative path to content) as the project +// of a check, opens every one of them, and points VLS_V_COMMAND at the +// stand-in compiler, which records what it was asked into the fixture's marker. +fn nrw_stub_app(name string, files map[string]string) (&App, NrwStub) { + dir := os.join_path(os.temp_dir(), 'vls_nrw_${name}_${os.getpid()}_${time.now().unix_nano()}') + proj := os.join_path(dir, 'nrwproj') + work := os.join_path(dir, 'work') + interop_test_must_mkdir_all(proj) + interop_test_must_mkdir_all(work) + mut open_files := map[string]string{} + mut uris := map[string]string{} + for rel, content in files { + path := os.join_path(proj, rel.replace('/', os.path_separator)) + interop_test_must_mkdir_all(os.dir(path)) + interop_test_must_write_file(path, content) + uris[rel] = path_to_uri(path) + open_files[uris[rel]] = content + } + previous := os.getenv('VLS_V_COMMAND') + marker := os.join_path(dir, 'marker.txt') + exe := nrw_stub_exe() + if exe != '' { + os.setenv('VLS_V_COMMAND', exe, true) + os.setenv('VLS_NRW_MARKER', marker, true) + } + mut app := &App{ + temp_dir: work + open_files: open_files + } + return app, NrwStub{ + dir: dir + marker: marker + proj: proj + main_uri: uris['main.v'] + util_uri: uris['util/util.v'] + other_uri: uris['util/helper.v'] + previous: previous + } +} + +// nrw_stub_done puts the environment back and removes the fixture. +fn nrw_stub_done(stub NrwStub) { + os.unsetenv('VLS_NRW_MARKER') + restore_v_command(stub.previous) + os.rmdir_all(stub.dir) or {} +} + +// nrw_last_run returns the argv, the working directory and the `.v` files of +// the stand-in compiler's last invocation. +fn nrw_last_run(marker string) (string, string, []string) { + content := os.read_file(marker) or { return '', '', []string{} } + blocks := content.split('---\n') + last := if blocks.len > 0 { blocks.last() } else { '' } + mut argv := '' + mut cwd := '' + mut files := []string{} + for line in last.split_into_lines() { + if line.starts_with('argv=') { + argv = line['argv='.len..] + } else if line.starts_with('cwd=') { + cwd = line['cwd='.len..] + } else if line.starts_with('file=') { + files << line['file='.len..] + } + } + files.sort() + return argv, cwd, files +} + +// nrw_check_argv is what the stand-in compiler was run with, one argument at a +// time, so a target is told apart from an option. +fn nrw_check_argv(marker string) []string { + argv, _, _ := nrw_last_run(marker) + return argv.split(' ') +} + +fn test_nrw_a_body_edit_checks_the_module_directory_alone() { + // A change that cannot alter what the program resolves does not need the + // program walked: the module directory of the changed file answers for it. + previous_cache := with_temp_diag_cache_dir('nrwtarget') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('target', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, files := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert !argv.any(it.ends_with('.v')), 'no file is passed as the target: ${argv}' + assert os.file_name(cwd) == 'util', 'the check runs in the module directory: ${cwd}' + assert files == ['helper.v', 'util.v'], 'only the module directory is walked: ${files}' +} + +fn test_nrw_adding_an_import_checks_the_whole_program() { + // A new import changes what the program resolves, so the program's own + // directory stays the target. + previous_cache := with_temp_diag_cache_dir('nrwimport') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('import', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + // A third import the file did not have. + edited := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, files := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert os.file_name(cwd) != 'util', 'the check does not run in the module directory: ${cwd}' + assert files == ['main.v'], 'the whole program is walked from its root: ${files}' +} + +fn test_nrw_a_module_another_module_imports_checks_the_whole_program() { + // The program's main imports the changed file's module through another one, + // so the module directory does not hold every file the program imports and + // the program's own directory stays the target. + previous_cache := with_temp_diag_cache_dir('nrwcore') + defer { + restore_diag_cache_dir(previous_cache) + } + mut files := nrw_base_files() + files['util/util.v'] = 'module util\n\nimport os\nimport core\n\npub fn value() string {\n\treturn core.name()\n}\n' + files['core/core.v'] = "module core\n\npub fn name() string {\n\treturn 'core'\n}\n" + mut app, stub := nrw_stub_app('core', files) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := files['core/core.v'].replace("'core'", "'renamed'") + core_uri := path_to_uri(os.join_path(stub.proj, 'core', 'core.v')) + app.open_files[core_uri] = edited + app.run_v_check(core_uri, edited) + argv := nrw_check_argv(stub.marker) + _, cwd, walked := nrw_last_run(stub.marker) + assert argv.len > 0, 'the stand-in compiler was never asked: ${stub.marker}' + assert argv.last() == '.', 'the check asks for a directory, not a file: ${argv}' + assert os.file_name(cwd) != 'core', 'the check does not run in the imported module directory: ${cwd}' + assert walked == ['main.v'], 'the whole program is walked from its root: ${walked}' +} + +fn test_nrw_the_narrow_check_reports_the_edited_files_own_errors() { + // The narrowed target answers for the file it was run for, at the right + // place, which is what makes it sufficient rather than merely cheaper. + previous_cache := with_temp_diag_cache_dir('nrwerrors') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('errors', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + edited := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + got := app.run_v_check(stub.util_uri, edited) + assert got.len == 1, 'the edited file reports its own diagnostics: ${got}' + assert normalize_overlay_path(got[0].path) == normalize_overlay_path(os.join_path(stub.proj, + 'util', 'util.v')), 'the error is mapped back to the edited file: ${got[0].path}' + assert got[0].message.contains('nrw stub error'), 'the error is the check%s own: ${got[0].message}' + assert got[0].line_nr == 3 && got[0].col == 5, 'the error keeps its position: ${got[0].line_nr}:${got[0].col}' + assert got[0].level == 'error', 'the error keeps its level: ${got[0].level}' +} + +fn test_nrw_the_programs_other_open_files_keep_their_diagnostics() { + // A narrowed check answers for the program's other open files only where it + // walked, so the ones outside the module directory are published nothing at + // all: an empty set would clear the errors a full check found in them. + previous_cache := with_temp_diag_cache_dir('nrwothers') + defer { + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('others', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + base := nrw_base_files() + edited := base['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + narrow_answers := app.program_errors.clone() + assert stub.other_uri in narrow_answers, 'the module directory%s other open file is answered by the same check: ${narrow_answers.keys()}' + assert narrow_answers[stub.other_uri].len == 1, 'that file keeps the diagnostics the check found in it: ${narrow_answers[stub.other_uri]}' + assert stub.main_uri !in narrow_answers, 'the program%s main module is published nothing, so it keeps what it shows: ${narrow_answers.keys()}' + // The whole program answers for both of them. + with_import := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = with_import + app.run_v_check(stub.util_uri, with_import) + full_answers := app.program_errors.clone() + assert stub.main_uri in full_answers, 'the full check answers for the main module: ${full_answers.keys()}' + assert full_answers[stub.main_uri].len == 1, 'and publishes what it found there: ${full_answers[stub.main_uri]}' + assert stub.other_uri in full_answers, 'the full check answers for the module directory%s other file: ${full_answers.keys()}' + assert full_answers[stub.other_uri].len == 0, 'which holds nothing, so it is cleared to that: ${full_answers[stub.other_uri]}' +} + +fn test_nrw_the_stage_line_names_the_chosen_target() { + // The target is observation, like the rest of the stage line: it names which + // of the two the check ran against, under the same VLS_PERF_LOG gate. + previous_perf := os.getenv('VLS_PERF_LOG') + previous_cache := with_temp_diag_cache_dir('nrwperf') + defer { + if previous_perf == '' { + os.unsetenv('VLS_PERF_LOG') + } else { + os.setenv('VLS_PERF_LOG', previous_perf, true) + } + restore_diag_cache_dir(previous_cache) + } + mut app, stub := nrw_stub_app('perf', nrw_base_files()) + defer { + nrw_stub_done(stub) + } + if !compiler_is_available() { + return + } + os.setenv('VLS_PERF_LOG', '1', true) + app.capture_output = true + base := nrw_base_files() + edited := base['util/util.v'].replace("'a'", "'b'") + app.open_files[stub.util_uri] = edited + app.run_v_check(stub.util_uri, edited) + with_import := "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn strings.to_upper(os.file_name('a'))\n}\n" + app.open_files[stub.util_uri] = with_import + app.run_v_check(stub.util_uri, with_import) + stage_lines := app.captured_output.filter(it.contains('diagnostics stages')) + assert stage_lines.len == 2, 'one stage line per slow check: ${app.captured_output}' + assert stage_lines[0].contains('target=narrow'), 'the narrowed check names its target: ${stage_lines[0]}' + assert stage_lines[1].contains('target=full'), 'the full check names its target: ${stage_lines[1]}' + cache_lines := app.captured_output.filter(it.contains('diagnostics cache')) + assert cache_lines.len == 2, 'one cache line per slow check: ${app.captured_output}' + assert cache_lines[0].contains('target=narrow'), 'the cache line names the target too: ${cache_lines[0]}' + assert cache_lines[1].contains('target=full'), 'the cache line names the target too: ${cache_lines[1]}' +} + +fn test_nrw_check_dir_requires_an_unchanged_import_block() { + // The validity condition, without a compiler: each requirement on its own, + // so a narrowing that dropped one is caught here rather than in a check. + temp_dir := os.join_path(os.vtmp_dir(), 'vls_nrw_predicate_${os.getpid()}') + os.rmdir_all(temp_dir) or {} + defer { + os.rmdir_all(temp_dir) or {} + } + proj := os.join_path(temp_dir, 'nrwproj') + for rel, content in nrw_base_files() { + path := os.join_path(proj, rel.replace('/', os.path_separator)) + interop_test_must_mkdir_all(os.dir(path)) + interop_test_must_write_file(path, content) + } + util_file := os.join_path(proj, 'util', 'util.v') + main_file := os.join_path(proj, 'main.v') + program_dir := normalize_overlay_path(proj) + module_dir := normalize_overlay_path(os.join_path(proj, 'util')) + mut app := &App{ + open_files: { + path_to_uri(util_file): nrw_base_files()['util/util.v'] + } + } + // A change confined to a function body narrows the target. + body_edit := nrw_base_files()['util/util.v'].replace("'a'", "'b'") + assert app.narrow_check_dir(util_file, program_dir, body_edit) == module_dir, 'a body edit narrows the target' + // A new import, a removed import, and a reordered one do not. + for _, changed in [ + "module util\n\nimport os\nimport strings\nimport sort\n\npub fn value() string {\n\treturn 'b'\n}\n", + "module util\n\nimport os\n\npub fn value() string {\n\treturn 'b'\n}\n", + "module util\n\nimport strings\nimport os\n\npub fn value() string {\n\treturn 'b'\n}\n", + ] { + assert app.narrow_check_dir(util_file, program_dir, changed) == '', 'a changed import block keeps the program the target' + } + // A file that was never saved has no previous content to compare with. + unsaved := os.join_path(proj, 'util', 'never_saved.v') + assert app.narrow_check_dir(unsaved, program_dir, 'module util\n\nimport os\n\npub fn extra() int {\n\treturn 2\n}\n') == '', 'an unsaved file keeps the program the target' + // A file whose module directory is the program's own has nothing to narrow. + assert app.narrow_check_dir(main_file, app.program_root(main_file), 'module main\n\nimport util\n\nfn main() {}\n') == '', 'the program%s own directory is already the whole program' + // A module the program imports through another one is not held by the + // changed file's directory. + mut nested := nrw_base_files() + nested['util/util.v'] = 'module util\n\nimport os\nimport core\n\npub fn value() string {\n\treturn core.name()\n}\n' + nested['core/core.v'] = "module core\n\npub fn name() string {\n\treturn 'core'\n}\n" + core_dir := os.join_path(proj, 'core') + interop_test_must_mkdir_all(core_dir) + interop_test_must_write_file(os.join_path(core_dir, 'core.v'), nested['core/core.v']) + core_file := os.join_path(core_dir, 'core.v') + assert app.narrow_check_dir(core_file, program_dir, nested['core/core.v']) == '', 'a module reached through another keeps the program the target' +} From 85f11f80130f9cb9ab343df980b85f1a07ab3aea Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 14:44:53 +0330 Subject: [PATCH 47/83] docs: record the reword workflow, and the thread-priority measurement that lost --- AGENTS.md | 10 ++++++++++ ROADMAP.md | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c81e07fa..5f19de95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,16 @@ Build & test (development) # V struct JSON attributes - Use `@[json]` attributes only when a field name does not exactly match the JSON name. +# Rewording a commit that is not HEAD +- Do not use `git rebase -i`: this host runs the sequence editor through `sh`, so + `%1` never expands and the rebase dies with "there was a problem with the editor". +- The reliable path is a temp branch plus a replay, which needs no force-push: + `git checkout -b tmp `, `git commit --amend -F msg.txt` there, then + `git rebase --onto tmp feat/` and `git branch -D tmp`. +- Always run `git add` and `git commit` sequentially, never as parallel tool calls: + two parallel calls race and one commit ends up holding both tracks' files, which + then needs the reword above. After grouping, verify with `git log --stat -1`. + Mandatory guardrails (enforced for all agent work) - Add `2>&1` to commands so all output is captured. - Keep `.md` lines ≤ 100 characters. diff --git a/ROADMAP.md b/ROADMAP.md index 2451e306..5c3ff281 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,10 +126,19 @@ Done: - Hover lists struct fields and enum variants, shows a variant's value, and links a declaration in another file. -Not done: raising the main loop's thread priority on Windows -(needs `unsafe` FFI and a benchmark to justify), and splitting the -diagnostics worker into latency/background intents (queue position -only; the copy lock is what actually blocks a hover). +Measured and dropped: raising the main loop's thread priority on +Windows, as rust-analyzer does. Built both variants and measured +interleaved (128 completion samples per variant, 124-126 hover): +completion p50 56 vs 52ms, p95 102 vs 103ms — noise, and hover, +which is pure main-loop work, showed nothing (p50 3 vs 3). The task +that would have shown a win (a completion spends ~53 of its ~56ms +blocked on the compiler) is exactly the one a thread priority +cannot help, so the code was deleted rather than shipped behind a +flag. + +Also not done: splitting the diagnostics worker into +latency/background intents (queue position only; the copy lock is +what actually blocks a hover). - Fuzzy workspace symbols (trigram index): DONE — per-symbol trigram caches in index entries, overlap then match-class From ff2d1bce59e32ea902fb397fc1f08028c47c9cc7 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 14:44:54 +0330 Subject: [PATCH 48/83] feat: link imports and the URLs in comments textDocument/documentLink answers with one link per import that resolves to a module file, and one per URL in a comment; an import that resolves to nothing, import C, and a URL inside a string are not links. The capability is advertised and the ranges are in the negotiating client's encoding. --- document_link.v | 198 ++++++++++++++++++++++++++++++++ document_link_test.v | 262 +++++++++++++++++++++++++++++++++++++++++++ lsp.v | 17 +++ main.v | 15 +++ 4 files changed, 492 insertions(+) create mode 100644 document_link.v create mode 100644 document_link_test.v diff --git a/document_link.v b/document_link.v new file mode 100644 index 00000000..2f92af6c --- /dev/null +++ b/document_link.v @@ -0,0 +1,198 @@ +// Links a client can click: one per `import` statement, pointing at the module +// file V compiles for it, and one per absolute URL the file's comments hold. +// Nothing is invented here — a link exists only where its target does — and +// nothing is asked of the compiler: the text and the indexed module resolution +// are enough, so the request never waits for a build. +module main + +import os +import json2 + +// document_link_c_imports are the imports that name no module folder, so they +// have no file to link to: `C` is C interop and `JS` the JavaScript backend's. +const document_link_c_imports = ['C', 'JS'] + +// document_link_schemes are the absolute URL prefixes a comment may hold. +const document_link_schemes = ['https://', 'http://'] + +// document_link_url_cap is how many comment URLs one file is scanned for. A file +// with more than that keeps the first `document_link_url_cap` links and drops the +// rest, so one request over a generated or minified file cannot become an +// unbounded scan. +const document_link_url_cap = 500 + +// handle_document_link handles textDocument/documentLink: the modules the file +// imports and the URLs its comments hold, each as a clickable range. +fn (mut app App) handle_document_link(request Request) Response { + params := json2.decode[DocumentLinkParams](request.params) or { + log('Failed to decode DocumentLinkParams: ${err.msg()}') + return Response{ + id: request.id + result: []DocumentLink{} + } + } + return Response{ + id: request.id + result: app.build_document_links(params.text_document.uri) + } +} + +// build_document_links returns the links of the file at `uri`, ordered by +// position: one per `import` statement that resolves to a module file, and one +// per absolute URL in a `//` comment. An import that resolves to no file is not a +// link, so a client is never sent a target that is not there. +fn (mut app App) build_document_links(uri string) []DocumentLink { + content := app.open_files[uri] or { + os.read_file(uri_to_path(uri)) or { return []DocumentLink{} } + } + lines := content.split_into_lines() + mut links := []DocumentLink{} + links << document_link_import_links(app, uri, content, lines) + links << document_link_comment_url_links(content, lines, app.position_encoding) + links.sort_with_compare(fn (a &DocumentLink, b &DocumentLink) int { + if a.range.start.line != b.range.start.line { + return a.range.start.line - b.range.start.line + } + return a.range.start.char - b.range.start.char + }) + return links +} + +// document_link_import_links resolves each import of `content` to the module file +// V compiles for it. The position of the module path comes from the file's own +// import scan, so a grouped import or one with an alias is where that scan says +// it is. +fn document_link_import_links(app &App, uri string, content string, lines []string) []DocumentLink { + mut links := []DocumentLink{} + work_dir := os.dir(uri_to_path(uri)) + for ref in parse_import_refs(content) { + if ref.path in document_link_c_imports || ref.line < 0 || ref.line >= lines.len { + continue + } + line := lines[ref.line] + if ref.col < 0 || ref.col + ref.path.len > line.len { + continue + } + module_dir := app.resolve_indexed_import_module_dir(ref.path, work_dir) + if module_dir == '' { + continue + } + // A module folder is named after the module and holds the file V compiles + // first, `.v`. Without that file there is no link: the target a + // client would open has to exist. + module_name := ref.path.all_after_last('.') + module_file := os.join_path(module_dir, '${module_name}.v') + if !os.is_file(module_file) { + continue + } + links << DocumentLink{ + range: document_link_range(line, ref.line, ref.col, ref.col + ref.path.len, app.position_encoding) + target: path_to_uri(module_file) + } + } + return links +} + +// document_link_comment_url_links returns one link per absolute URL in a `//` +// comment of `content`, doc comments included. The ranges come from the file's +// own tokenizer, so a URL inside a string literal is never one of them, and a +// `/* */` block comment is not either: V's doc comments are `//`. +fn document_link_comment_url_links(content string, lines []string, enc PositionEncoding) []DocumentLink { + mut links := []DocumentLink{} + for token in tokenize_v_source(content) { + if token.type_idx != sem_tok_comment || token.line < 0 || token.line >= lines.len { + continue + } + line := lines[token.line] + if token.start < 0 || token.start + token.length > line.len { + continue + } + text := line[token.start..token.start + token.length] + if !text.starts_with('//') { + continue + } + for span in document_link_url_spans(text) { + if links.len >= document_link_url_cap { + break + } + links << DocumentLink{ + range: document_link_range(line, token.line, token.start + span[0], token.start + span[1], enc) + target: text[span[0]..span[1]] + } + } + } + return links +} + +// document_link_url_spans returns the byte spans of the absolute URLs in `text`. +// A URL runs from the first byte of its scheme to the first whitespace or `)` +// after it, and has to hold something past the scheme to be a link at all. +fn document_link_url_spans(text string) [][2]int { + mut spans := [][2]int{} + mut at := 0 + for at < text.len { + scheme_len := document_link_scheme_len(text, at) + if scheme_len == 0 { + at++ + continue + } + start := at + at += scheme_len + mut end := at + for end < text.len && !document_link_url_ends_at(text[end]) { + end++ + } + if end > at { + spans << [start, end]! + } + at = end + } + return spans +} + +// document_link_scheme_len returns the length of the `https://` or `http://` +// scheme starting at `text[at]`, or 0 when no absolute URL starts there. +fn document_link_scheme_len(text string, at int) int { + for scheme in document_link_schemes { + if document_link_text_matches(text, at, scheme) { + return scheme.len + } + } + return 0 +} + +fn document_link_text_matches(text string, at int, needle string) bool { + if at < 0 || at + needle.len > text.len { + return false + } + for i in 0 .. needle.len { + if text[at + i] != needle[i] { + return false + } + } + return true +} + +// document_link_url_ends_at reports whether `c` terminates a URL written in a +// comment: whitespace, or a `)` closing the parenthesis the URL sits in. +fn document_link_url_ends_at(c u8) bool { + return c == ` ` || c == `\t` || c == `\r` || c == `)` +} + +// document_link_range builds the range of `line[from..to]` in the encoding the +// client negotiated, so the bytes this scan works in are never reported as +// characters. +fn document_link_range(line string, line_idx int, from int, to int, enc PositionEncoding) LSPRange { + start := byte_to_encoded_col(line, from, enc) + end := byte_to_encoded_col(line, to, enc) + return LSPRange{ + start: Position{ + line: line_idx + char: start + } + end: Position{ + line: line_idx + char: end + } + } +} diff --git a/document_link_test.v b/document_link_test.v new file mode 100644 index 00000000..2d839223 --- /dev/null +++ b/document_link_test.v @@ -0,0 +1,262 @@ +// Tests for textDocument/documentLink: one link per import that resolves to a +// module file, and one per absolute URL in a `//` comment. + +module main + +import os +import json2 + +fn dl_app() &App { + temp_dir := os.join_path(os.temp_dir(), 'vls_doclink_test') + 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 dl_mkdir(path string) { + os.mkdir_all(path) or { + assert false, 'Failed to create directory ${path}: ${err}' + return + } +} + +fn dl_write(path string, content string) { + os.write_file(path, content) or { + assert false, 'Failed to write file ${path}: ${err}' + return + } +} + +fn dl_uri(path string) string { + return path_to_uri(path) +} + +fn dl_cleanup(app &App) { + os.rmdir_all(app.temp_dir) or {} +} + +// dl_project writes `content` as main.v in a fresh folder of the app's temp dir +// and returns its path. +fn dl_project(app &App, name string, content string) string { + root := os.join_path(app.temp_dir, name) + dl_mkdir(root) + path := os.join_path(root, 'main.v') + dl_write(path, content) + return path +} + +fn test_document_link_a_resolved_import_links_to_the_module_file() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_resolved') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + main_path := dl_project(app, 'dl_resolved', 'module main\n\nimport shapes\n\nfn main() {\n\tshapes.render()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 7 + assert links[0].range.end.line == 2 + assert links[0].range.end.char == 13 +} + +fn test_document_link_a_submodule_import_links_into_the_subdirectory() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_submodule') + dl_mkdir(os.join_path(root, 'utils', 'parse')) + dl_write(os.join_path(root, 'utils', 'parse', 'parse.v'), "module parse\n\npub fn line() string {\n\treturn ''\n}\n") + main_path := dl_project(app, 'dl_submodule', 'module main\n\nimport utils.parse\n\nfn main() {\n\tparse.line()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == dl_uri(os.join_path(root, 'utils', 'parse', 'parse.v')) + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 7 + assert links[0].range.end.char == 18 +} + +fn test_document_link_a_grouped_import_links_every_module_it_names() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_grouped') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + dl_mkdir(os.join_path(root, 'utils', 'parse')) + dl_write(os.join_path(root, 'utils', 'parse', 'parse.v'), "module parse\n\npub fn line() string {\n\treturn ''\n}\n") + main_path := dl_project(app, 'dl_grouped', 'module main\n\nimport (\n\tshapes\n\tutils.parse\n)\n\nfn main() {\n\tshapes.render()\n\tparse.line()\n}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[0].range.start.line == 3 + assert links[0].range.start.char == 1 + assert links[0].range.end.char == 7 + assert links[1].target == dl_uri(os.join_path(root, 'utils', 'parse', 'parse.v')) + assert links[1].range.start.line == 4 + assert links[1].range.start.char == 1 + assert links[1].range.end.char == 12 +} + +fn test_document_link_c_import_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_c_import') + dl_mkdir(os.join_path(root, 'C')) + dl_write(os.join_path(root, 'C', 'C.v'), 'module C\n') + main_path := dl_project(app, 'dl_c_import', 'module main\n\nimport C\n\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 0, 'C interop has no module folder to link to, even one named C' +} + +fn test_document_link_an_import_that_resolves_to_no_file_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_unresolved') + dl_mkdir(os.join_path(root, 'notes')) + dl_write(os.join_path(root, 'notes', 'README.md'), 'not a module\n') + main_path := dl_project(app, 'dl_unresolved', 'module main\n\nimport nowhere.at.all\nimport notes\n\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 0, 'no link may name a file the client cannot open' +} + +fn test_document_link_a_doc_comment_url_is_a_link_with_its_own_range() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_doc_url', 'module main\n\n// Docs: https://example.com/guide\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/guide' + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 9 + assert links[0].range.end.line == 2 + assert links[0].range.end.char == 34 +} + +fn test_document_link_a_url_in_a_string_literal_is_not_a_link() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_string_url', "module main\n\nfn main() {\n\turl := 'https://example.com/literal'\n\t// kept: https://example.com/comment\n\tprintln(url)\n}\n") + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/comment' + assert links[0].range.start.line == 4 +} + +fn test_document_link_http_and_https_urls_are_both_found() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_two_schemes', 'module main\n\n// Docs at https://example.com and mirror at http://example.org\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == 'https://example.com' + assert links[1].target == 'http://example.org' +} + +fn test_document_link_a_url_ends_at_whitespace_and_at_a_parenthesis() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_url_end', 'module main\n\n// (see https://example.com/parens) and https://example.com/plain\nfn main() {}\n') + + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 2 + assert links[0].target == 'https://example.com/parens' + assert links[1].target == 'https://example.com/plain' +} + +fn test_document_link_ranges_use_the_negotiated_encoding() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + main_path := dl_project(app, 'dl_encoding', 'module main\n\n// Дока https://example.com/x\nfn main() {}\n') + + app.position_encoding = .utf8 + links := app.build_document_links(dl_uri(main_path)) + + assert links.len == 1 + assert links[0].target == 'https://example.com/x' + assert links[0].range.start.line == 2 + assert links[0].range.start.char == 12 + assert links[0].range.end.char == 33 + + app.position_encoding = .utf16 + utf16_links := app.build_document_links(dl_uri(main_path)) + + assert utf16_links[0].range.start.char == 8 + assert utf16_links[0].range.end.char == 29 +} + +fn test_document_link_handler_answers_with_the_links_of_the_document() { + mut app := dl_app() + defer { + dl_cleanup(app) + } + root := os.join_path(app.temp_dir, 'dl_handler') + dl_mkdir(os.join_path(root, 'shapes')) + dl_write(os.join_path(root, 'shapes', 'shapes.v'), 'module shapes\n\npub fn render() {}\n') + main_path := dl_project(app, 'dl_handler', 'module main\n\nimport shapes\n\n// see https://example.com/handler\nfn main() {\n\tshapes.render()\n}\n') + + response := app.handle_document_link(Request{ + id: 4242 + method: 'textDocument/documentLink' + params: json2.encode(DocumentLinkParams{ + text_document: TextDocumentIdentifier{ + uri: dl_uri(main_path) + } + }, + escape_unicode: true + ) + }) + + assert response.id == 4242 + assert response.result is []DocumentLink + links := response.result as []DocumentLink + assert links.len == 2 + assert links[0].target == dl_uri(os.join_path(root, 'shapes', 'shapes.v')) + assert links[1].target == 'https://example.com/handler' +} diff --git a/lsp.v b/lsp.v index 52415092..609d399f 100644 --- a/lsp.v +++ b/lsp.v @@ -111,6 +111,7 @@ type ResponseResult = string | []DocumentHighlight | []SelectionRange | []CodeLens + | []DocumentLink | CodeLens | []InlineValueText | LinkedEditingRanges @@ -283,6 +284,7 @@ struct Capability { execute_command_provider ?ExecuteCommandOptions @[json: 'executeCommandProvider'] document_formatting_provider bool @[json: 'documentFormattingProvider'] document_range_formatting_provider bool @[json: 'documentRangeFormattingProvider'] + document_link_provider bool @[json: 'documentLinkProvider'] document_symbol_provider bool @[json: 'documentSymbolProvider'] workspace_symbol_provider bool @[json: 'workspaceSymbolProvider'] inlay_hint_provider bool @[json: 'inlayHintProvider'] @@ -890,6 +892,18 @@ struct DocumentRangeFormattingParams { options FormattingOptions } +// DocumentLink is one clickable link in a document: the range of the text that +// carries it and the URI it opens. +struct DocumentLink { + range LSPRange + target string +} + +// DocumentLinkParams holds parameters for textDocument/documentLink. +struct DocumentLinkParams { + text_document TextDocumentIdentifier @[json: 'textDocument'] +} + // DidChangeWatchedFilesParams holds file-change events sent by the client watcher. struct DidChangeWatchedFilesParams { changes []FileEvent @@ -936,6 +950,7 @@ enum Method { selection_range semantic_tokens_range range_formatting + document_link will_save will_save_wait_until did_change_watched_files @@ -1087,6 +1102,7 @@ fn Method.from_string(s string) Method { 'textDocument/selectionRange' { .selection_range } 'textDocument/semanticTokens/range' { .semantic_tokens_range } 'textDocument/rangeFormatting' { .range_formatting } + 'textDocument/documentLink' { .document_link } 'textDocument/willSave' { .will_save } 'textDocument/willSaveWaitUntil' { .will_save_wait_until } 'workspace/didChangeWatchedFiles' { .did_change_watched_files } @@ -1140,6 +1156,7 @@ fn (m Method) str() string { .selection_range { 'textDocument/selectionRange' } .semantic_tokens_range { 'textDocument/semanticTokens/range' } .range_formatting { 'textDocument/rangeFormatting' } + .document_link { 'textDocument/documentLink' } .will_save { 'textDocument/willSave' } .will_save_wait_until { 'textDocument/willSaveWaitUntil' } .did_change_watched_files { 'workspace/didChangeWatchedFiles' } diff --git a/main.v b/main.v index 9890f133..9f82a6bb 100644 --- a/main.v +++ b/main.v @@ -1004,6 +1004,9 @@ fn (mut app App) handle_requests[T](mut reader T) { // changed hunk only when it lies entirely inside the requested // range, and no edit at all otherwise. document_range_formatting_provider: true + // Imports link to the module they name, and a URL in a doc + // comment is clickable as written. + document_link_provider: true position_encoding: position_encoding_string(app.position_encoding) workspace: WorkspaceCapability{ workspace_folders: WorkspaceFoldersServerCapability{ @@ -1126,6 +1129,10 @@ fn (mut app App) handle_requests[T](mut reader T) { resp := app.handle_range_formatting(lsp_request) app.write_response_or_cancelled(lsp_request.id, resp) } + .document_link { + resp := app.handle_document_link(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } .did_change_watched_files { app.on_did_change_watched_files(lsp_request) } @@ -1931,6 +1938,14 @@ fn validate_request_params(method Method, params_json string) ?string { return 'Missing textDocument.uri' } } + .document_link { + params := json2.decode[DocumentLinkParams](params_json) or { + return 'Invalid documentLink params: ${err.msg()}' + } + if params.text_document.uri == '' { + return 'Missing textDocument.uri' + } + } .linked_editing_range { params := json2.decode[TextDocumentPositionParams](params_json) or { return 'Invalid linkedEditingRange params: ${err.msg()}' From 8e2b30525eb8781f57638de11160fe5962afb764 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 16:45:58 +0330 Subject: [PATCH 49/83] feat: answer textDocument/diagnostic from the diagnostics already computed A pull returns the same items the last push published, through the same dedup and cap path, so there is one truncation rule; a repeated pull whose previousResultId matches answers unchanged. The result id is derived from the uri and the encoded items rather than stored, so push and pull agree without a second write path. A document never checked answers empty, not an error. --- lsp.v | 45 ++++++- main.v | 20 ++- pull_diagnostics.v | 66 ++++++++++ pull_diagnostics_test.v | 273 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 pull_diagnostics.v create mode 100644 pull_diagnostics_test.v diff --git a/lsp.v b/lsp.v index 609d399f..3786b21e 100644 --- a/lsp.v +++ b/lsp.v @@ -115,6 +115,7 @@ type ResponseResult = string | CodeLens | []InlineValueText | LinkedEditingRanges + | DocumentDiagnosticReport // WorkspaceSymbol represents a symbol found across the workspace. struct WorkspaceSymbol { @@ -188,6 +189,35 @@ struct LSPRange { end Position } +// DocumentDiagnosticParams holds the parameters of textDocument/diagnostic, the +// LSP 3.17 pull-diagnostics request. A client sends the resultId it holds for +// the document, so the server can answer only what changed since. +struct DocumentDiagnosticParams { + text_document TextDocumentIdentifier @[json: 'textDocument'] + identifier ?string + previous_result_id ?string @[json: 'previousResultId'] +} + +// FullDocumentDiagnosticReport answers a pull with the whole item list and the +// resultId the client holds for its next pull. +struct FullDocumentDiagnosticReport { + kind string = 'full' + result_id ?string @[json: 'resultId'] + items []LSPDiagnostic +} + +// UnchangedDocumentDiagnosticReport answers a pull whose previousResultId still +// describes the document. It carries no items, so the client keeps the markers +// it already shows. +struct UnchangedDocumentDiagnosticReport { + kind string = 'unchanged' + result_id string @[json: 'resultId'] +} + +// DocumentDiagnosticReport is the result of textDocument/diagnostic. +type DocumentDiagnosticReport = FullDocumentDiagnosticReport + | UnchangedDocumentDiagnosticReport + // Detail represents a completion or symbol detail item. struct Detail { kind int // The type of item (e.g., Method, Function, Field) @@ -269,6 +299,15 @@ struct CodeLensOptions { resolve_provider ?bool @[json: 'resolveProvider'] } +// DiagnosticOptions advertises pull diagnostics (LSP 3.17 DiagnosticOptions). +// interFileDependencies is false because a pull answers for the one document it +// is asked about, and workspaceDiagnostics is false because +// workspace/diagnostic is not served. +struct DiagnosticOptions { + inter_file_dependencies bool @[json: 'interFileDependencies'] + workspace_diagnostics bool @[json: 'workspaceDiagnostics'] +} + // Capability lists supported LSP features for the server. struct Capability { completion_provider CompletionProvider @[json: 'completionProvider'] @@ -299,7 +338,8 @@ struct Capability { document_highlight_provider bool @[json: 'documentHighlightProvider'] selection_range_provider bool @[json: 'selectionRangeProvider'] workspace WorkspaceCapability - position_encoding ?string @[json: 'positionEncoding'] + position_encoding ?string @[json: 'positionEncoding'] + diagnostic_provider DiagnosticOptions @[json: 'diagnosticProvider'] } // OnTypeFormattingOptions describes the triggers for on-type formatting. @@ -963,6 +1003,7 @@ enum Method { will_rename_files will_delete_files on_type_formatting + diagnostic set_trace cancel_request shutdown @@ -1115,6 +1156,7 @@ fn Method.from_string(s string) Method { 'workspace/willRenameFiles' { .will_rename_files } 'workspace/willDeleteFiles' { .will_delete_files } 'textDocument/onTypeFormatting' { .on_type_formatting } + 'textDocument/diagnostic' { .diagnostic } 'shutdown' { .shutdown } 'exit' { .exit } else { .unknown } @@ -1169,6 +1211,7 @@ fn (m Method) str() string { .will_rename_files { 'workspace/willRenameFiles' } .will_delete_files { 'workspace/willDeleteFiles' } .on_type_formatting { 'textDocument/onTypeFormatting' } + .diagnostic { 'textDocument/diagnostic' } .set_trace { u8(36).ascii_str() + '/setTrace' } .cancel_request { u8(36).ascii_str() + '/cancelRequest' } .shutdown { 'shutdown' } diff --git a/main.v b/main.v index 9f82a6bb..4ae5449e 100644 --- a/main.v +++ b/main.v @@ -999,6 +999,8 @@ fn (mut app App) handle_requests[T](mut reader T) { call_hierarchy_provider: true document_highlight_provider: true selection_range_provider: true + // Pull diagnostics: a pull answers for one document, no workspace pull. + diagnostic_provider: DiagnosticOptions{} // Range formatting is advertised: `v fmt` only formats whole // files, so the handler formats the document and returns the // changed hunk only when it lies entirely inside the requested @@ -1133,6 +1135,10 @@ fn (mut app App) handle_requests[T](mut reader T) { resp := app.handle_document_link(lsp_request) app.write_response_or_cancelled(lsp_request.id, resp) } + .diagnostic { + resp := app.handle_pull_diagnostic(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } .did_change_watched_files { app.on_did_change_watched_files(lsp_request) } @@ -1646,9 +1652,9 @@ fn method_requires_response(method Method) bool { .workspace_symbol, .formatting, .document_symbols, .inlay_hint, .shutdown, .code_action, .semantic_tokens, .folding_range, .callhierarchy_prepare, .callhierarchy_incoming, .callhierarchy_outgoing, .document_highlight, .selection_range, .semantic_tokens_range, - .range_formatting, .code_lens, .code_lens_resolve, .execute_command, .inline_value, - .linked_editing_range, .will_create_files, .will_rename_files, .will_delete_files, - .on_type_formatting { + .range_formatting, .document_link, .code_lens, .code_lens_resolve, .execute_command, + .inline_value, .linked_editing_range, .will_create_files, .will_rename_files, + .will_delete_files, .on_type_formatting, .diagnostic { true } else { @@ -1946,6 +1952,14 @@ fn validate_request_params(method Method, params_json string) ?string { return 'Missing textDocument.uri' } } + .diagnostic { + params := json2.decode[DocumentDiagnosticParams](params_json) or { + return 'Invalid textDocument/diagnostic params: ${err.msg()}' + } + if params.text_document.uri == '' { + return 'Missing textDocument.uri' + } + } .linked_editing_range { params := json2.decode[TextDocumentPositionParams](params_json) or { return 'Invalid linkedEditingRange params: ${err.msg()}' diff --git a/pull_diagnostics.v b/pull_diagnostics.v new file mode 100644 index 00000000..473ded83 --- /dev/null +++ b/pull_diagnostics.v @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// handle_pull_diagnostic answers textDocument/diagnostic (LSP 3.17) from the +// diagnostics the last check already published for the document: a pull starts +// no compiler and waits for no check. What the cache holds is what the push +// published, so the items are rebuilt through the same function the push builds +// its notification with — one dedup, one cap — and never through a second copy +// of either. +fn (mut app App) handle_pull_diagnostic(request Request) Response { + params := json2.decode[DocumentDiagnosticParams](request.params) or { + $if debug { + log('Failed to decode DocumentDiagnosticParams: ${err}') + } + return Response{ + id: request.id + result: 'null' + } + } + uri := params.text_document.uri + items := app.pull_diagnostics_items(uri) + result_id := pull_diagnostics_result_id(uri, items) + if previous_id := params.previous_result_id { + if previous_id == result_id { + return Response{ + id: request.id + result: UnchangedDocumentDiagnosticReport{ + result_id: result_id + } + } + } + } + return Response{ + id: request.id + result: FullDocumentDiagnosticReport{ + result_id: result_id + items: items + } + } +} + +// pull_diagnostics_items rebuilds the item list last published for `uri`. An +// absent cache entry is a document no check has answered for yet, which is an +// empty list rather than an error. The buffer is read for the encoding step, so +// a pull of a file on disk that was never opened answers from disk. +fn (mut app App) pull_diagnostics_items(uri string) []LSPDiagnostic { + mut v_errors := []JsonError{} + if cached := app.diag_cache[uri] { + v_errors = cached.errors + } + content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { '' } } + return app.diagnostics_notification_for(uri, content, v_errors).params.diagnostics +} + +// pull_diagnostics_result_id names the item list published for a document. Both +// sides agree because the id is derived the same way on both: the URI and the +// encoded list, so a client that echoes back the id it holds compares equal +// exactly while nothing changed. Two digests over one string instead of one, so +// a collision cannot alias another document's list onto this id. +fn pull_diagnostics_result_id(uri string, items []LSPDiagnostic) string { + return '${uri.hash().hex()}${json2.encode(items).hash().hex()}' +} diff --git a/pull_diagnostics_test.v b/pull_diagnostics_test.v new file mode 100644 index 00000000..d5225603 --- /dev/null +++ b/pull_diagnostics_test.v @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import io +import json2 +import os + +// --- pull diagnostics: what the published set answers without a compiler --- + +const pd_main_source = 'module main + +fn main() { + x := 1 + y := 2 +} +' + +// pd_fixed_source is the same file after the edit that fixed one of the errors +// the last check reported. +const pd_fixed_source = 'module main + +fn main() { + x := 1 + y := 2 + z := 3 +} +' + +// pd_project makes a folder of its own for one test, writes the file into it and +// returns the folder, the file's URI and the text it holds. +fn pd_project(tag string, text string) (string, string, string) { + dir := os.join_path(os.temp_dir(), 'vls_pd_${os.getpid()}_${tag}') + os.mkdir_all(dir) or { + assert false, 'Failed to create ${dir}: ${err}' + } + path := os.join_path(dir, 'main.v') + os.write_file(path, text) or { + assert false, 'Failed to write ${path}: ${err}' + } + return dir, path_to_uri(path), text +} + +// pd_cleanup removes the folder of one test. +fn pd_cleanup(dir string) { + os.rmdir_all(dir) or {} +} + +// pd_seed records the errors of the last check of `uri`: what the push +// published, and what a pull must answer from. +fn pd_seed(mut app App, uri string, errors []JsonError) { + app.diag_cache[uri] = DiagCacheEntry{ + fingerprint: 'pd' + errors: errors + } +} + +// pd_errors builds `n` errors on their own lines, as a compiler cascade reports. +fn pd_errors(n int) []JsonError { + mut errors := []JsonError{} + for i in 1 .. n + 1 { + errors << JsonError{ + message: 'error ${i}' + line_nr: i + col: 1 + len: 1 + level: 'error' + } + } + return errors +} + +// pd_pull asks for the diagnostics of `uri`, echoing `previous_result_id` when +// the caller holds one. +fn pd_pull(mut app App, uri string, previous_result_id string) Response { + mut previous := ?string(none) + if previous_result_id != '' { + previous = previous_result_id + } + return app.handle_pull_diagnostic(Request{ + id: 1 + method: 'textDocument/diagnostic' + params: json2.encode(DocumentDiagnosticParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + previous_result_id: previous + }) + }) +} + +// pd_report decodes the full report of a pull response. +fn pd_report(response Response) FullDocumentDiagnosticReport { + return response.result as DocumentDiagnosticReport as FullDocumentDiagnosticReport +} + +// pd_unchanged decodes the unchanged report of a pull response. +fn pd_unchanged(response Response) UnchangedDocumentDiagnosticReport { + return response.result as DocumentDiagnosticReport as UnchangedDocumentDiagnosticReport +} + +// pd_published is the item list the push carries, for the same errors. +fn pd_published(mut app App, uri string, text string, errors []JsonError) []LSPDiagnostic { + return app.diagnostics_notification_for(uri, text, errors).params.diagnostics +} + +// A pull answers the items the push published, byte for byte, and no compiler +// runs to answer it: the messages here are ones no compiler could have emitted. +fn test_pull_diagnostic_returns_the_items_the_push_published() { + dir, uri, text := pd_project('pd_push_match', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + errors := pd_errors(3) + pd_seed(mut app, uri, errors) + pushed := pd_published(mut app, uri, text, errors) + + report := pd_report(pd_pull(mut app, uri, '')) + + assert report.kind == 'full' + assert report.items.len == pushed.len + assert json2.encode(report.items) == json2.encode(pushed) + assert report.items[0].message == 'error 1' + assert report.items[2].message == 'error 3' + assert report.items[0].severity == 1 + assert (report.items[0].source or { '' }) == 'vlang' + assert (report.result_id or { '' }).len > 0 +} + +// The id a pull issues is stable while nothing changed: asking again with it +// answers unchanged and sends no items back. +fn test_pull_diagnostic_with_the_matching_result_id_is_unchanged() { + dir, uri, _ := pd_project('pd_unchanged', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + pd_seed(mut app, uri, pd_errors(3)) + first := pd_report(pd_pull(mut app, uri, '')) + result_id := first.result_id or { + assert false, 'a full report always names its result' + return + } + + unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + + assert unchanged.kind == 'unchanged' + assert unchanged.result_id == result_id + encoded := encode_response_payload(pd_pull(mut app, uri, result_id)) + assert encoded.contains('"kind":"unchanged"') + assert !encoded.contains('"items"') +} + +// An edit that the next check answers for moves both the items and the id, so a +// client holding the old id is re-sent the list instead of being told unchanged. +fn test_pull_diagnostic_after_an_edit_returns_new_items_with_a_new_result_id() { + dir, uri, _ := pd_project('pd_after_edit', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + pd_seed(mut app, uri, pd_errors(3)) + first := pd_report(pd_pull(mut app, uri, '')) + first_id := first.result_id or { + assert false, 'a full report always names its result' + return + } + + app.open_files[uri] = pd_fixed_source + pd_seed(mut app, uri, pd_errors(2)) + second := pd_report(pd_pull(mut app, uri, '')) + second_id := second.result_id or { + assert false, 'a full report always names its result' + return + } + + assert second_id != first_id, 'a re-check must name a different result' + assert second.items.len == 2 + after_edit_with_the_old_id := pd_report(pd_pull(mut app, uri, first_id)) + assert after_edit_with_the_old_id.items.len == 2 + unchanged := pd_unchanged(pd_pull(mut app, uri, second_id)) + assert unchanged.result_id == second_id +} + +// The cap is the push's own helper, and a pull goes through the same function, +// so a cascading file is capped once — the same constant, the same marker — and +// the id that names the capped list is the id that answers unchanged. +fn test_pull_diagnostic_applies_the_cap_helper_of_the_push() { + dir, uri, text := pd_project('pd_cap', 'x\n'.repeat(150)) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + errors := pd_errors(150) + pd_seed(mut app, uri, errors) + pushed := pd_published(mut app, uri, text, errors) + + report := pd_report(pd_pull(mut app, uri, '')) + + assert report.items.len == pushed.len + assert report.items.len == max_published_diagnostics + 1 + assert report.items[99].message == 'error 100' + assert report.items[100].message == '... and 50 more' + assert report.items[100].severity == 3 + assert json2.encode(report.items) == json2.encode(pushed) + result_id := report.result_id or { + assert false, 'a full report always names its result' + return + } + capped_unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + assert capped_unchanged.kind == 'unchanged' +} + +// A document no check has answered for answers an empty list with a result id, +// never an error. +fn test_pull_diagnostic_of_a_document_never_checked_answers_empty() { + dir, uri, text := pd_project('pd_never_checked', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = text + + response := pd_pull(mut app, uri, '') + report := pd_report(response) + + assert response.id == 1 + assert report.kind == 'full' + assert report.items.len == 0 + result_id := report.result_id or { + assert false, 'a full report always names its result' + return + } + empty_unchanged := pd_unchanged(pd_pull(mut app, uri, result_id)) + assert empty_unchanged.kind == 'unchanged' + assert empty_unchanged.result_id == result_id +} + +// initialize advertises the pull capability in the response the real handshake +// writes, with both of its flags false. +fn test_pull_diagnostic_capability_is_advertised_in_initialize() { + dir, uri, _ := pd_project('pd_capabilities', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(dir, 'initialize.txt') + os.write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') or { + assert false, 'Failed to write ${input_path}: ${err}' + } + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + initialize := app.captured_output.join('') + + assert initialize.contains('"diagnosticProvider"'), 'pull diagnostics is advertised' + assert initialize.contains('"interFileDependencies":false') + assert initialize.contains('"workspaceDiagnostics":false') +} From f6400d9bf7aa13ce381140b2703e0dc8da5f952d Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 20:24:30 +0330 Subject: [PATCH 50/83] feat: workspace/diagnostic answers the Problems pane in one report A workspace pull reports every document a check has answered for, from the same cache the per-file pull reads, so no compiler runs and no second dedup or cap exists. An id the client already holds is answered unchanged, and the answer is bounded: at five hundred documents the report says how many were left out instead of silently dropping them. --- lsp.v | 31 ++++++++ main.v | 16 +++- pull_diagnostics_test.v | 172 +++++++++++++++++++++++++++++++++++++++- workspace_diagnostic.v | 115 +++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 workspace_diagnostic.v diff --git a/lsp.v b/lsp.v index 3786b21e..71d7f2e2 100644 --- a/lsp.v +++ b/lsp.v @@ -112,6 +112,7 @@ type ResponseResult = string | []SelectionRange | []CodeLens | []DocumentLink + | []WorkspaceDiagnosticReport | CodeLens | []InlineValueText | LinkedEditingRanges @@ -218,6 +219,33 @@ struct UnchangedDocumentDiagnosticReport { type DocumentDiagnosticReport = FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport +// WorkspaceDiagnosticParams is textDocument/diagnostic's whole-workspace +// sibling: the identifiers whose documents the client wants, and the id it +// already holds for each, so an unchanged document is answered with nothing. +struct WorkspaceDiagnosticParams { + identifier string + previous_result_ids map[string]string @[json: 'previousResultIds'] +} + +// WorkspaceDocumentReport is one document's answer inside a workspace report. +struct WorkspaceDocumentReport { + uri string + kind string = 'full' + version ?i64 + result_id ?string @[json: 'resultId'] + items []LSPDiagnostic +} + +// WorkspaceUnchangedReport is a document whose id the client already holds. +struct WorkspaceUnchangedReport { + uri string + kind string = 'unchanged' + result_id string @[json: 'resultId'] +} + +// WorkspaceDiagnosticReport is the result of workspace/diagnostic. +type WorkspaceDiagnosticReport = WorkspaceDocumentReport | WorkspaceUnchangedReport + // Detail represents a completion or symbol detail item. struct Detail { kind int // The type of item (e.g., Method, Function, Field) @@ -1004,6 +1032,7 @@ enum Method { will_delete_files on_type_formatting diagnostic + workspace_diagnostic set_trace cancel_request shutdown @@ -1157,6 +1186,7 @@ fn Method.from_string(s string) Method { 'workspace/willDeleteFiles' { .will_delete_files } 'textDocument/onTypeFormatting' { .on_type_formatting } 'textDocument/diagnostic' { .diagnostic } + 'workspace/diagnostic' { .workspace_diagnostic } 'shutdown' { .shutdown } 'exit' { .exit } else { .unknown } @@ -1212,6 +1242,7 @@ fn (m Method) str() string { .will_delete_files { 'workspace/willDeleteFiles' } .on_type_formatting { 'textDocument/onTypeFormatting' } .diagnostic { 'textDocument/diagnostic' } + .workspace_diagnostic { 'workspace/diagnostic' } .set_trace { u8(36).ascii_str() + '/setTrace' } .cancel_request { u8(36).ascii_str() + '/cancelRequest' } .shutdown { 'shutdown' } diff --git a/main.v b/main.v index 4ae5449e..34f5abe6 100644 --- a/main.v +++ b/main.v @@ -1000,7 +1000,10 @@ fn (mut app App) handle_requests[T](mut reader T) { document_highlight_provider: true selection_range_provider: true // Pull diagnostics: a pull answers for one document, no workspace pull. - diagnostic_provider: DiagnosticOptions{} + diagnostic_provider: DiagnosticOptions{ + inter_file_dependencies: false + workspace_diagnostics: true + } // Range formatting is advertised: `v fmt` only formats whole // files, so the handler formats the document and returns the // changed hunk only when it lies entirely inside the requested @@ -1139,6 +1142,10 @@ fn (mut app App) handle_requests[T](mut reader T) { resp := app.handle_pull_diagnostic(lsp_request) app.write_response_or_cancelled(lsp_request.id, resp) } + .workspace_diagnostic { + resp := app.handle_workspace_diagnostic(lsp_request) + app.write_response_or_cancelled(lsp_request.id, resp) + } .did_change_watched_files { app.on_did_change_watched_files(lsp_request) } @@ -1654,7 +1661,7 @@ fn method_requires_response(method Method) bool { .callhierarchy_outgoing, .document_highlight, .selection_range, .semantic_tokens_range, .range_formatting, .document_link, .code_lens, .code_lens_resolve, .execute_command, .inline_value, .linked_editing_range, .will_create_files, .will_rename_files, - .will_delete_files, .on_type_formatting, .diagnostic { + .will_delete_files, .on_type_formatting, .diagnostic, .workspace_diagnostic { true } else { @@ -1960,6 +1967,11 @@ fn validate_request_params(method Method, params_json string) ?string { return 'Missing textDocument.uri' } } + .workspace_diagnostic { + params := json2.decode[WorkspaceDiagnosticParams](params_json) or { + return 'Invalid workspace/diagnostic params: ${err.msg()}' + } + } .linked_editing_range { params := json2.decode[TextDocumentPositionParams](params_json) or { return 'Invalid linkedEditingRange params: ${err.msg()}' diff --git a/pull_diagnostics_test.v b/pull_diagnostics_test.v index d5225603..af6ed835 100644 --- a/pull_diagnostics_test.v +++ b/pull_diagnostics_test.v @@ -269,5 +269,175 @@ fn test_pull_diagnostic_capability_is_advertised_in_initialize() { assert initialize.contains('"diagnosticProvider"'), 'pull diagnostics is advertised' assert initialize.contains('"interFileDependencies":false') - assert initialize.contains('"workspaceDiagnostics":false') + assert initialize.contains('"workspaceDiagnostics":true') +} + +// pd_ws_seed records the last check of one document, as a save of it would, and +// returns the folder it wrote so a test can clean it up. +fn pd_ws_seed(mut app App, tag string, text string) (string, string) { + dir, uri, _ := pd_project(tag, text) + app.open_files[uri] = text + pd_seed(mut app, uri, [ + JsonError{ + message: 'error in ${tag}' + line_nr: 3 + col: 1 + len: 1 + level: 'error' + }, + ]) + return dir, uri +} + +// pd_ws_pull asks for the whole workspace, echoing the ids the caller holds. +fn pd_ws_pull(mut app App, previous_result_ids map[string]string) Response { + return app.handle_workspace_diagnostic(Request{ + id: 9 + method: 'workspace/diagnostic' + params: json2.encode(WorkspaceDiagnosticParams{ + previous_result_ids: previous_result_ids + }) + }) +} + +// pd_ws_reports decodes the reports of a workspace pull. +fn pd_ws_reports(response Response) []WorkspaceDiagnosticReport { + return response.result as []WorkspaceDiagnosticReport +} + +// pd_ws_ids is the id a workspace pull computes for one document, so a test can +// echo it back. +fn pd_ws_ids(mut app App, uris []string) map[string]string { + mut ids := map[string]string{} + for uri in uris { + items := app.pull_diagnostics_items(uri) + ids[uri] = pull_diagnostics_result_id(uri, items) + } + return ids +} + +fn test_ws_diagnostic_reports_every_document_with_diagnostics() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_first', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_second', pd_main_source) + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == 2, 'expected both documents, got ${reports.len}' + assert reports[0].uri == first, 'first is ' + assert reports[1].uri == second, 'second is ' + assert reports[0].kind == 'full', 'first kind is ' + assert reports[0].items.len == 1, 'first items ' + assert reports[0].items[0].message.contains('ws_first'), reports[0].items[0].message +} + +fn test_ws_diagnostic_answers_unchanged_for_every_document_the_client_holds() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_hold_a', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_hold_b', pd_main_source) + ids := pd_ws_ids(mut app, [first, second]) + reports := pd_ws_reports(pd_ws_pull(mut app, ids)) + assert reports.len == 2, 'expected two reports, got ' + for report in reports { + assert report.kind == 'unchanged', '${report.uri} is ${report.kind}' + match report { + WorkspaceUnchangedReport { + assert report.result_id != '', '${report.uri} carries no id' + } + else { + assert false, '${report.uri} is not an unchanged report' + } + } + } +} + +fn test_ws_diagnostic_answers_a_mix_when_the_client_holds_one_of_two() { + mut app := App{} + _, first := pd_ws_seed(mut app, 'ws_mix_a', pd_main_source) + _, second := pd_ws_seed(mut app, 'ws_mix_b', pd_main_source) + ids := pd_ws_ids(mut app, [first]) + reports := pd_ws_reports(pd_ws_pull(mut app, ids)) + assert reports.len == 2, 'expected two reports, got ' + mut unchanged := 0 + mut full := 0 + for report in reports { + if report.kind == 'unchanged' { + unchanged++ + } else { + full++ + } + } + assert unchanged == 1, 'unchanged ' + assert full == 1, 'full ' +} + +fn test_ws_diagnostic_of_a_document_never_checked_is_absent() { + mut app := App{} + dir, checked, _ := pd_project('ws_unchecked_a', pd_main_source) + defer { + pd_cleanup(dir) + } + pd_seed(mut app, checked, pd_errors(1)) + dir2, unchecked, _ := pd_project('ws_unchecked_b', pd_main_source) + defer { + pd_cleanup(dir2) + } + app.open_files[unchecked] = pd_main_source + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == 1, 'only the checked document is reported, got ' + assert reports[0].uri == checked, reports[0].uri +} + +fn test_ws_diagnostic_truncates_and_says_so() { + mut app := App{} + mut uris := []string{} + mut dirs := []string{} + defer { + for dir in dirs { + pd_cleanup(dir) + } + } + for i in 0 .. (workspace_diagnostic_max_documents + 12) { + one, uri := pd_ws_seed(mut app, 'ws_cap_${i}', pd_main_source) + dirs << one + uris << uri + } + assert uris.len == workspace_diagnostic_max_documents + 12, 'seeded ${uris.len}' + reports := pd_ws_reports(pd_ws_pull(mut app, map[string]string{})) + assert reports.len == workspace_diagnostic_max_documents + 1, 'expected the cap and the truncation report, got ${reports.len}' + last := reports[workspace_diagnostic_max_documents] + assert last.uri == workspace_diagnostic_truncated_uri, last.uri + assert last.kind == 'full', 'the truncation report is a full report' + match last { + WorkspaceDocumentReport { + assert last.items.len == 1, 'the truncation report carries one entry, got ${last.items.len}' + assert last.items[0].message.contains('12'), 'the truncation says how many: ${last.items[0].message}' + } + else { + assert false, 'the truncation report is not a full report' + } + } +} + +fn test_ws_diagnostic_capability_is_advertised_in_initialize() { + dir, uri, _ := pd_project('ws_capabilities', pd_main_source) + defer { + pd_cleanup(dir) + } + mut app := App{} + app.open_files[uri] = pd_main_source + payload := '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + input_path := os.join_path(dir, 'initialize.txt') + os.write_file(input_path, 'Content-Length: ${payload.len}\r\n\r\n${payload}') or { + assert false, 'Failed to write ${input_path}: ${err}' + } + mut input := os.open(input_path) or { + assert false, 'Failed to open ${input_path}: ${err}' + return + } + defer { + input.close() + } + app.capture_output = true + mut reader := io.new_buffered_reader(reader: input, cap: 1) + app.handle_requests(mut reader) + assert app.captured_output.join('').contains('"workspaceDiagnostics":true') } diff --git a/workspace_diagnostic.v b/workspace_diagnostic.v new file mode 100644 index 00000000..5302b817 --- /dev/null +++ b/workspace_diagnostic.v @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 + +// handle_workspace_diagnostic answers workspace/diagnostic (LSP 3.17): one +// report per document the server has diagnostics for, from the same cache the +// per-file pull reads. No compiler runs, no check is waited for, and the +// dedup and cap are the push's own, so a workspace report cannot disagree with +// what a per-file pull of the same document returns. +fn (mut app App) handle_workspace_diagnostic(request Request) Response { + params := json2.decode[WorkspaceDiagnosticParams](request.params) or { + $if debug { + log('Failed to decode WorkspaceDiagnosticParams: ${err}') + } + return Response{ + id: request.id + result: []WorkspaceDiagnosticReport{} + } + } + uris := app.uris_with_diagnostics() + mut reports, dropped := app.workspace_diagnostic_reports(uris, params.previous_result_ids) + if dropped > 0 { + // The truncation is a report of its own rather than a silent gap: a + // client that sees it knows the pane is partial. + reports << WorkspaceDocumentReport{ + uri: workspace_diagnostic_truncated_uri + items: [ + LSPDiagnostic{ + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } + } + message: 'the workspace report is truncated: ${dropped} documents with diagnostics were not reported' + severity: 3 + }, + ] + } + } + return Response{ + id: request.id + result: reports + } +} + +// workspace_diagnostic_truncated_uri is the identifier the truncation report +// carries. It is not a file, so no client resolves it to a buffer. +const workspace_diagnostic_truncated_uri = 'vls.workspace.diagnosticTruncated' + +// workspace_diagnostic_max_documents bounds how many documents one workspace +// report describes. A Problems pane that asked for everything gets a bounded +// answer and a report saying so, rather than a payload that grows with the +// project. +const workspace_diagnostic_max_documents = 500 + +// uris_with_diagnostics lists the documents a check has answered for. A file +// checked and found clean is left out: a workspace report answers what the +// client asked about, and listing every clean file of a project would flood it. +// The order is the cache's own, and only the count is bounded. +fn (app &App) uris_with_diagnostics() []string { + mut uris := []string{cap: app.diag_cache.len} + for uri, cached in app.diag_cache { + if cached.errors.len == 0 { + continue + } + uris << uri + } + uris.sort() + return uris +} + +// workspace_diagnostic_reports answers one report per document, unchanged when +// the id the client holds still describes it. The second result is how many +// documents were left out by the cap. +fn (mut app App) workspace_diagnostic_reports(uris []string, previous_ids map[string]string) ([]WorkspaceDiagnosticReport, int) { + mut limit := uris.len + if limit > workspace_diagnostic_max_documents { + limit = workspace_diagnostic_max_documents + } + mut reports := []WorkspaceDiagnosticReport{cap: limit} + for idx, uri in uris { + if idx >= limit { + return reports, uris.len - idx + } + items := app.pull_diagnostics_items(uri) + result_id := pull_diagnostics_result_id(uri, items) + if previous_id := previous_ids[uri] { + if previous_id == result_id { + reports << WorkspaceUnchangedReport{ + uri: uri + result_id: result_id + } + continue + } + } + reports << WorkspaceDocumentReport{ + uri: uri + version: if uri in app.open_files_versions { + ?i64(app.open_files_versions[uri]) + } else { + none + } + result_id: result_id + items: items + } + } + return reports, 0 +} From 3af7ba3c95fe56c71ade8f50bdf86ebd66d99337 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 22:48:38 +0330 Subject: [PATCH 51/83] fix: clamp index reservation, gate occurrences, treat unsaved buffers as incomplete index_reserve_target clamps to index_max_files instead of dropping the reservation when the projection crosses the cap; occurrences_for applies the same oversized-source gate as reindex_uri; index_is_complete_for_scope and index_is_complete treat a skipped open buffer with no file on disk as not indexed, so rename refuses instead of silently omitting references. --- handlers_test.v | 47 +++++++++++++++++++++ index.v | 52 ++++++++++++++++++----- index_test.v | 110 ++++++++++++++++++++++++++++++++++++++++++------ main.v | 1 + 4 files changed, 188 insertions(+), 22 deletions(-) diff --git a/handlers_test.v b/handlers_test.v index 7172f282..a395563d 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -3967,6 +3967,53 @@ fn test_handle_rename_refuses_incomplete_oversized_sibling_index() { assert (resp.result as string) == 'null' } +fn test_rename_refuses_an_unsaved_oversized_buffer() { + // The on-disk oversized file above has a file to point at; a buffer that + // was never saved has none, and its skipped entry must still stop the + // rename rather than let it silently omit the file's references. + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'unsaved_oversized') + must_mkdir_all(test_dir) + 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) + unsaved := os.join_path(test_dir, 'unsaved.v') + uri := path_to_uri(test_file) + unsaved_uri := path_to_uri(unsaved) + assert !os.exists(unsaved), 'the unsaved file must not exist on disk' + app.open_files[uri] = content + app.open_files[unsaved_uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + app.reindex_uri(unsaved_uri) + // The state the rename must read is asserted before running it, since the + // handler re-indexes and may clear what it needs. + assert app.index_scope_for_uri(uri).dir != '' + assert !app.index_is_complete() + assert !app.index_is_complete_for_scope(app.index_scope_for_uri(uri)) + + resp := app.handle_rename(Request{ + id: 904 + method: 'textDocument/rename' + params: json2.encode(RenameParams{ + text_document: TextDocumentIdentifier{ + uri: uri + } + position: Position{ + line: 2 + char: 4 + } + new_name: 'renamed' + }, + escape_unicode: true + ) + }) + + assert resp.result is string + assert (resp.result as string) == 'null' +} + fn test_parse_document_symbols_empty_content() { syms := parse_document_symbols('') assert syms.len == 0 diff --git a/index.v b/index.v index cbf5e735..cbc47c84 100644 --- a/index.v +++ b/index.v @@ -301,6 +301,13 @@ fn (mut app App) occurrences_for(uri string) map[string][]TokenOccurrence { app.ref_occurrences.delete(uri) return map[string][]TokenOccurrence{} } + if u64(content.len) > index_max_file_bytes { + // The gate reindex_uri applies, for the same reason: an occurrence map + // of a source this large is built without a bound, and the local + // binding hover builds one per keystroke. + app.ref_occurrences.delete(uri) + return map[string][]TokenOccurrence{} + } fp := content.hash() if existing := app.ref_occurrences[uri] { if existing.fingerprint == fp { @@ -505,18 +512,37 @@ fn find_project_root(dir string) string { // collect_v_files recursively gathers `.v` files under `root`, skipping hidden // and known heavy directories and stopping once `index_max_files` is reached. // It returns false when a limit or filesystem error prevents a complete walk. -fn collect_v_files(root string, mut acc []string) bool { - return collect_v_files_bounded(root, index_max_files, mut acc) +// project_walk_budget is the file ceiling one project walk uses. It is the +// global cap unless a test lowers it, which is how a genuinely partial walk is +// produced without writing twenty thousand files. +fn (app &App) project_walk_budget() int { + if app.index_walk_budget > 0 { + return app.index_walk_budget + } + return index_max_files +} + +// collect_v_files recursively gathers `.v` files under `root`, skipping hidden +// and known heavy directories and stopping once `max_files` is reached. It +// returns false when a limit or filesystem error prevents a complete walk, so +// a caller that must know whether the walk was partial learns it here. +fn collect_v_files(root string, max_files int, mut acc []string) bool { + return collect_v_files_bounded(root, max_files, mut acc) } // index_reserve_target is how many index entries a walk should pre-size for, -// bounded by the global file cap. A partial walk reserves what it found, so -// growth toward that cap stays bounded in both cases; 0 means reserve nothing. +// clamped to the global file cap. A partial walk reserves what it found, so +// growth toward that cap stays bounded in both cases; 0 means reserve nothing, +// which is also the answer when the index is already at the cap, since no more +// entries can be added however many remain. fn index_reserve_target(indexed int, found int) int { - want := indexed + found - if want <= 0 || want > index_max_files { + if indexed >= index_max_files || found <= 0 { return 0 } + mut want := indexed + found + if want > index_max_files { + want = index_max_files + } return want } @@ -645,7 +671,7 @@ fn (mut app App) ensure_dirs_indexed(dirs []string) { mut files := []string{} scope := 'recursive:${dir}' app.index_incomplete_scopes.delete(scope) - walk_complete := collect_v_files(dir, mut files) + walk_complete := collect_v_files(dir, app.project_walk_budget(), mut files) if !walk_complete { app.index_incomplete_scopes[scope] = true } @@ -870,7 +896,11 @@ fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { return false } for uri, _ in app.index_skipped_uris { - if uri_is_in_index_scope(uri, scope) && os.is_file(uri_to_path(uri)) { + // An unsaved buffer has no file on disk, so "is there a file" would + // read a skipped open buffer as indexed and a destructive operation + // would proceed over references it never saw. + if uri_is_in_index_scope(uri, scope) + && ((uri in app.open_files) || os.is_file(uri_to_path(uri))) { return false } } @@ -878,13 +908,15 @@ fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { } // index_is_complete reports whether every discovered disk source was indexed. -// Non-destructive queries may use a partial bounded index. +// Non-destructive queries may use a partial bounded index. An oversized buffer +// still open counts as not indexed, as it does for a scope: no file may exist +// for it, so the file test alone would miss it. fn (app &App) index_is_complete() bool { if app.index_incomplete_scopes.len > 0 { return false } for uri, _ in app.index_skipped_uris { - if os.is_file(uri_to_path(uri)) { + if (uri in app.open_files) || os.is_file(uri_to_path(uri)) { return false } } diff --git a/index_test.v b/index_test.v index 4144e85f..0b822b0d 100644 --- a/index_test.v +++ b/index_test.v @@ -26,32 +26,48 @@ fn test_index_reserve_target_covers_partial_walks() { assert index_reserve_target(0, 50) == 50 assert index_reserve_target(500, 50) == 550 assert index_reserve_target(0, index_max_files) == index_max_files + // Crossing the cap clamps to it rather than dropping the reservation: the + // walk can still add entries up to the cap one at a time. + assert index_reserve_target(12_000, 10_000) == index_max_files + assert index_reserve_target(12_000, 20) == 12_020 // Already at or above the cap: reserve nothing. assert index_reserve_target(index_max_files, 1) == 0 assert index_reserve_target(index_max_files + 100, 1) == 0 + // Nothing new to add. + assert index_reserve_target(500, 0) == 0 + assert index_reserve_target(0, 0) == 0 } fn test_partial_walk_still_reserves_what_it_found() { // The reservation is not gated on the walk being complete: an incomplete - // walk indexes what it found, and that count still sizes the map. An - // incomplete scope marked by an earlier walk is cleared when a later one - // completes, as before. + // walk indexes what it found, and that count still sizes the map. The walk + // here is genuinely partial — the budget cuts it off — rather than a + // complete one with the scope marked afterwards. root := index_test_tmpdir('partial_reserve') defer { os.rmdir_all(root) or {} } - for i in 0 .. 20 { + for i in 0 .. 30 { os.write_file(os.join_path(root, 'p${i}.v'), 'module p${i}\n\nfn p${i}() {}\n') or { assert false, 'write fixture failed: ${err}' return } } mut app := index_test_app() - app.index_incomplete_scopes['recursive:${root}'] = true - assert index_reserve_target(0, 20) == 20 + app.index_walk_budget = 12 + assert index_reserve_target(0, 12) == 12 + app.ensure_dirs_indexed([root]) + // The walk stopped at the budget, so the scope is incomplete and only what + // it found was indexed: the reservation counted those files. + assert 'recursive:${root}' in app.index_incomplete_scopes, 'the walk completed' + assert app.symbol_index.len == 12, 'got ${app.symbol_index.len}' + assert index_reserve_target(0, 30) == 30 + // With the budget lifted the same walk completes and clears the scope. + app.index_walk_budget = 0 + app.indexed_dirs.delete(root) app.ensure_dirs_indexed([root]) - assert app.symbol_index.len == 20, 'got ${app.symbol_index.len}' assert 'recursive:${root}' !in app.index_incomplete_scopes + assert app.symbol_index.len == 30, 'got ${app.symbol_index.len}' } fn test_bulk_walk_presizes_symbol_index_without_losing_entries() { @@ -214,6 +230,76 @@ fn test_open_buffer_reindex_skips_oversized_content() { assert uri !in app.index_skipped_uris } +fn test_an_unsaved_oversized_buffer_counts_as_not_indexed() { + // An unsaved buffer has no file on disk, so the file test alone would read + // its skipped entry as indexed: a destructive operation would proceed over + // references it never saw. Both completeness checks must say otherwise. + root := index_test_tmpdir('unsaved_large') + defer { + os.rmdir_all(root) or {} + } + // The two files live in separate projects, so one scope's skipped entry + // does not describe the other. + large_dir := os.join_path(root, 'big') + small_dir := os.join_path(root, 'small') + os.mkdir_all(large_dir) or { + assert false, 'mkdir big failed: ${err}' + return + } + os.mkdir_all(small_dir) or { + assert false, 'mkdir small failed: ${err}' + return + } + small := os.join_path(small_dir, 'main.v') + small_content := 'module main\n\nfn main() {}\n' + os.write_file(small, small_content) or { + assert false, 'write small file failed: ${err}' + return + } + unsaved := os.join_path(large_dir, 'unsaved.v') + unsaved_uri := path_to_uri(unsaved) + assert !os.exists(unsaved), 'the fixture must not write the file' + mut app := index_test_app() + small_uri := path_to_uri(small) + app.open_files[small_uri] = small_content + app.open_files[unsaved_uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + app.reindex_uri(unsaved_uri) + + assert unsaved_uri in app.index_skipped_uris + assert unsaved_uri !in app.symbol_index + assert !app.index_is_complete_for_scope(app.index_scope_for_uri(unsaved_uri)), 'an unsaved oversized buffer reads as indexed' + assert !app.index_is_complete(), 'an unsaved oversized buffer reads as indexed' + // The other project's scope is unaffected by the first file's size. + assert app.index_is_complete_for_scope(app.index_scope_for_uri(small_uri)) +} + +fn test_occurrences_of_an_oversized_source_are_not_built() { + // The occurrence map of a source this large is unbounded work, and the + // local-binding hover builds one per keystroke: the same gate the index + // applies applies here. + root := index_test_tmpdir('occ_large') + defer { + os.rmdir_all(root) or {} + } + path := os.join_path(root, 'large.v') + uri := path_to_uri(path) + os.write_file(path, 'module main\n\nfn small() {}\n') or { + assert false, 'write initial file failed: ${err}' + return + } + mut app := index_test_app() + app.open_files[uri] = 'x'.repeat(int(index_max_file_bytes) + 1) + occ := app.occurrences_for(uri) + assert occ.len == 0, 'a large buffer built ${occ.len} occurrence groups' + assert uri !in app.ref_occurrences, 'a large buffer was cached' + // Below the gate the occurrences are built and cached as before. + app.open_files[uri] = 'module main\n\nfn alpha() {\n\talpha()\n}\n' + small := app.occurrences_for(uri) + assert small.len > 0, 'a normal buffer built nothing' + assert 'alpha' in small, 'a normal buffer is missing its names' + assert uri in app.ref_occurrences +} + fn test_watched_file_reindex_obeys_total_entry_limit() { root := index_test_tmpdir('watched_count') defer { @@ -414,7 +500,7 @@ fn test_collect_v_files_excludes_hidden_and_heavy_dirs() { os.write_file(os.join_path(root, 'sub', 'lib.v'), 'module main\n') or { return } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('main.v')) assert files.any(it.ends_with('lib.v')) assert files.all(!it.contains('.git')) @@ -442,7 +528,7 @@ fn test_collect_v_files_excludes_out_of_tree_symlink() { return } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('in.v')) // The out-of-tree file reachable only via the symlink must NOT be indexed. assert files.all(!it.ends_with('out.v')) @@ -470,7 +556,7 @@ fn test_collect_v_files_excludes_out_of_tree_file_symlink() { } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.any(it.ends_with('in.v')) assert files.all(!it.ends_with('external.v')) } @@ -491,7 +577,7 @@ fn test_collect_v_files_deduplicates_in_tree_file_symlink() { } mut files := []string{} - assert collect_v_files(root, mut files) + assert collect_v_files(root, index_max_files, mut files) assert files.len == 1 assert normalized_index_path(files[0]) == normalized_index_path(real_file) @@ -1130,7 +1216,7 @@ fn test_large_vlang_v_workspace_from_env() { assert os.is_file(os.join_path(root, 'v.mod')), 'VLS_VLANG_V_REPO must point at a vlang/v checkout' mut files := []string{} - assert collect_v_files(root, mut files), 'vlang/v walk must finish without hitting index bounds' + assert collect_v_files(root, index_max_files, mut files), 'vlang/v walk must finish without hitting index bounds' assert files.len >= 2000, 'expected a large vlang/v checkout, found only ${files.len} V files' mut app := index_test_app() diff --git a/main.v b/main.v index 03bf5ab5..3da43178 100644 --- a/main.v +++ b/main.v @@ -44,6 +44,7 @@ mut: ref_occurrences map[string]OccEntry // Per-URI identifier occurrences for references (see index.v) index_skipped_uris map[string]bool // Disk files omitted from the bounded index index_incomplete_scopes map[string]bool // Index walks that could not finish + index_walk_budget int // Test seam: file ceiling for one project walk, 0 = index_max_files vlib_fn_cache map[string]map[string]string // Per-vlib-module fn→return-type index (immutable during a session) expression_type_depth int // Nesting of expression_type, which a binding's declaration re-enters line_info_mode LineInfoMode // How the configured `v` reaches the `-line-info` checker (see interop.v) From 941b1b84223554394e4f5e0b353717460117b7bb Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 23:34:34 +0330 Subject: [PATCH 52/83] fix: carry the three index-memory review findings over from #547 @medvednikov's review of the index-memory change asked for three things, and this branch predates those fixes. Ported across, adapted to this tree: index_reserve_target clamps to index_max_files instead of dropping the reservation when the projection crosses the cap; occurrences_for applies the same oversized-source gate reindex_uri uses; index_is_complete_for_scope and index_is_complete treat a skipped URI still in open_files as not indexed, so an unsaved oversized buffer stops a rename. Verified: v -check, v fmt -verify, and the full index_test.v green on Windows. --- index.v | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/index.v b/index.v index 2f513545..c4f0528e 100644 --- a/index.v +++ b/index.v @@ -304,6 +304,13 @@ fn (mut app App) occurrences_for(uri string) map[string][]TokenOccurrence { app.ref_occurrences.delete(uri) return map[string][]TokenOccurrence{} } + if u64(content.len) > index_max_file_bytes { + // The gate reindex_uri applies, for the same reason: an occurrence map + // of a source this large is built without a bound, and the local + // binding hover builds one per keystroke. + app.ref_occurrences.delete(uri) + return map[string][]TokenOccurrence{} + } fp := content.hash() if existing := app.ref_occurrences[uri] { if existing.fingerprint == fp { @@ -521,14 +528,18 @@ fn collect_v_files(root string, mut acc []string) bool { } // index_reserve_target is how many index entries a walk should pre-size for, -// bounded by the global file cap. A partial walk reserves what it found, so -// the growth toward that cap stays bounded in both cases; 0 means reserve -// nothing. +// clamped to the global file cap. A partial walk reserves what it found, so +// growth toward that cap stays bounded in both cases; 0 means reserve nothing, +// which is also the answer when the index is already at the cap, since no more +// entries can be added however many remain. fn index_reserve_target(indexed int, found int) int { - want := indexed + found - if want <= 0 || want > index_max_files { + if indexed >= index_max_files || found <= 0 { return 0 } + mut want := indexed + found + if want > index_max_files { + want = index_max_files + } return want } @@ -1333,7 +1344,11 @@ fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { return false } for uri, _ in app.index_skipped_uris { - if uri_is_in_index_scope(uri, scope) && os.is_file(uri_to_path(uri)) { + // An unsaved buffer has no file on disk, so "is there a file" would + // read a skipped open buffer as indexed and a destructive operation + // would proceed over references it never saw. + if uri_is_in_index_scope(uri, scope) + && ((uri in app.open_files) || os.is_file(uri_to_path(uri))) { return false } } @@ -1341,13 +1356,15 @@ fn (app &App) index_is_complete_for_scope(scope IndexScope) bool { } // index_is_complete reports whether every discovered disk source was indexed. -// Non-destructive queries may use a partial bounded index. +// Non-destructive queries may use a partial bounded index. An oversized buffer +// still open counts as not indexed, as it does for a scope: no file may exist +// for it, so the file test alone would miss it. fn (app &App) index_is_complete() bool { if app.index_incomplete_scopes.len > 0 { return false } for uri, _ in app.index_skipped_uris { - if os.is_file(uri_to_path(uri)) { + if (uri in app.open_files) || os.is_file(uri_to_path(uri)) { return false } } From cd28b510a27eb8092374c5c5eae20035cf2ca9bb Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 09:04:42 +0330 Subject: [PATCH 53/83] test: pin the innermost call in signature help A cursor inside a nested call, on the inner paren, three levels deep, on a method chain and past every closing paren is what source_call_target answers, so the signature-help fallback cannot regress to reporting the outer function. --- handlers_test.v | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/handlers_test.v b/handlers_test.v index 3db125ea..342d58fc 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -2796,6 +2796,79 @@ fn test_source_call_target_handles_multiline_and_generic_calls() { assert multiline_target.active_parameter == 1 } +fn test_source_call_target_reports_the_innermost_call() { + // A cursor inside a nested call names the inner callee, not the outer one: + // `outer(a, inner(b, c))` at `c` is `inner`, and the parameter is inner's + // second. The reported column probes the callee's own name. + nested := ' inner(b, outer(1, 2))' + outer_start := nested.index('outer(') or { + assert false, 'expected the nested call' + return + } + on_inner := source_call_target(nested, Position{ + char: nested.len - 1 + }, .utf8) or { + assert false, 'expected call target inside a nested call' + return + } + assert on_inner.position == Position{ + line: 0 + char: 3 + } + assert on_inner.active_parameter == 1 + + // A cursor on the inner's own `(` is still the inner's first parameter, not + // the outer's. + on_inner_paren := source_call_target(nested, Position{ + char: outer_start + 'outer'.len + 1 + }, .utf8) or { + assert false, 'expected call target on the inner call' + return + } + assert on_inner_paren.position == Position{ + line: 0 + char: outer_start + 2 + } + assert on_inner_paren.active_parameter == 0 + + // Three levels: `f(g(h(1)))` at the literal names `h`. + deep := 'f(g(h(1)))' + h_start := deep.index('h(') or { + assert false, 'expected the innermost call' + return + } + on_h := source_call_target(deep, Position{ + char: h_start + 2 + }, .utf8) or { + assert false, 'expected call target at three levels of nesting' + return + } + assert on_h.position == Position{ + line: 0 + char: h_start + } + assert on_h.active_parameter == 0 + + // A chain reads the same: `item.rewrap(value)` at `value` names `rewrap`. + chain := 'item.rewrap(value)' + on_rewrap := source_call_target(chain, Position{ + char: chain.len - 1 + }, .utf8) or { + assert false, 'expected call target of a method call' + return + } + assert on_rewrap.position == Position{ + line: 0 + char: 7 + } + assert on_rewrap.active_parameter == 0 + + // Past every closing paren there is no enclosing call to name. + assert source_call_target(deep, Position{ + char: deep.len + }, .utf8) == none +} + fn test_declaration_signature_label_keeps_generics_and_return_type() { declaration := 'pub fn convert[T](value T) !T' assert declaration_signature_label(declaration, 'convert') == 'convert[T](value T) !T' From 042e9252460818e8ee5649d54b1add1eec869def Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 10:59:48 +0330 Subject: [PATCH 54/83] docs: close out refactor-members and extract-to-file on the roadmap --- ROADMAP.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5c3ff281..ac88d678 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,7 +160,13 @@ what actually blocks a hover). - Extract variable / inline variable: DONE (assists.v) — both pure text edits with purity gates; inline requires a single-assignment local. -- stub-missing-members: NOT started. +- Implement missing interface members: DONE (implement_members.v) + — offered when exactly one interface of the module is a superset + of the struct's methods; stubs carry the interface member's + signature and a zero value for its return type. +- Extract to a new file: DONE (extract_to_file.v) — a selected + declaration moves into a module of its own, the `import` lands + via import_spot, and the name collision falls back beside it. ## Phase 4 — build / config UX: DONE From aa09b7fdf05aebabfe0910223ad39ce53ba208a9 Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 18:06:29 +0330 Subject: [PATCH 55/83] feat: structural search and replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ssr.v`: a rule written as a comment on the line the selection starts on, `pattern ==>> template`, with `$name` placeholders carrying the text they matched. The action is offered as `refactor.rewrite` when the selection sits on such a line. Two invariants hold it together: * Scope-negative. A rule is refused as a whole, and the reason is logged, when any match sits in a `$if` branch, spans a `${...}` interpolation, or starts where V writes a type — a struct field's type, a generic argument, an annotation, or a return type. * Visible. Every match is inside the selection, and the action's preview is the edit set itself. Three bugs found while making the tests green, each pinned by a test that fails without the fix: * The match's start was the offset the scan arrived from, so the edit range began in the whitespace before the match and would have replaced the preceding newline. * The refusal read the whole document with spans relative to the selected region, so a `$if` branch below the selection's start was rewritten instead of refused. * A trailing separator skip ate the pattern's own next token, so `f($a, $b)` did not match `f(1, 2)`. The render decides parentheses from the binding's own operators rather than from a re-parse, because vls has no parser to re-read it with, and a comma in the template is followed by a space as V code is written. Deliberately absent, as each cannot be made safe on the index alone: typed wildcards, rules that span files, and declaration-shape rewrites. The full suite is green apart from nine failures that reproduce on a clean checkout, where the V1 compatibility compiler is unavailable. --- ROADMAP.md | 12 + handlers.v | 10 + lsp.v | 1 + ssr.v | 703 +++++++++++++++++++++++++++++++++++++++++++++++++++++ ssr_test.v | 211 ++++++++++++++++ 5 files changed, 937 insertions(+) create mode 100644 ssr.v create mode 100644 ssr_test.v diff --git a/ROADMAP.md b/ROADMAP.md index ac88d678..77444a79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,6 +167,18 @@ what actually blocks a hover). - Extract to a new file: DONE (extract_to_file.v) — a selected declaration moves into a module of its own, the `import` lands via import_spot, and the name collision falls back beside it. +- Structural search and replace: DONE (ssr.v) — a rule written as + a comment on the line the selection starts on, `pattern ==>>` + `template`, with `$name` placeholders carrying the text they + matched. Every match is inside the selection, and the preview is + the edit set itself. The rule is refused as a whole, naming the + reason, when any match sits in a `$if` branch, spans a `${...}` + interpolation, or starts where V writes a type — a struct field's + type, a generic argument, an annotation, or a return type. The + placeholder's own precedence decides parentheses in the render, + because there is no parser to re-read the result with. + Deliberately absent: typed wildcards, rules that span files, and + declaration-shape rewrites. ## Phase 4 — build / config UX: DONE diff --git a/handlers.v b/handlers.v index c309f7d1..b7b755b6 100644 --- a/handlers.v +++ b/handlers.v @@ -8367,6 +8367,16 @@ fn (mut app App) handle_code_action(request Request) Response { } } + // 9. Structural replace — the rule written on the line of the selection. + if code_action_kind_wanted(only, code_action_kind_refactor) { + rule := app.ssr_rule_at_selection(uri, params.range) + if rule != '' { + if action := app.build_ssr_action(uri, content, params.range, rule) { + actions << action + } + } + } + return Response{ id: request.id result: actions diff --git a/lsp.v b/lsp.v index 71d7f2e2..4ff1fe6a 100644 --- a/lsp.v +++ b/lsp.v @@ -158,6 +158,7 @@ const sym_kind_struct = 23 // LSP CodeActionKind constants const code_action_kind_quickfix = 'quickfix' +const code_action_kind_refactor = 'refactor.rewrite' const code_action_kind_source_organize_imports = 'source.organizeImports' // Notification represents an LSP notification sent to the client. diff --git a/ssr.v b/ssr.v new file mode 100644 index 00000000..44b31e81 --- /dev/null +++ b/ssr.v @@ -0,0 +1,703 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +// Structural search and replace, in the shape rust-analyzer's SSR uses: +// a `pattern ==>> template` rule whose `$name` placeholders carry the text +// of what they matched. +// +// What is deliberately NOT here, because each cannot be made safe on the +// index alone: typed wildcards (`$placeholders must be strings`), rules that +// span files or the workspace, and declaration-shape rewrites (rename already +// does that, and compiles the program to check its own result). +// +// Two invariants carry the safety of everything below: +// +// 1. Scope-negative. A rule is refused, as a whole, when anything it would +// touch cannot be told safe from text: a `${...}` interpolation, a `$if` +// branch, a comptime block, an attribute, a generic type position, or a +// project that is only partly indexed. +// 2. Visible. Every match is inside the region the user selected, and the +// preview is the edit set itself. There is no silent second match. + +// ssr_max_matches bounds how many matches one rule applies, as +// rename_max_occurrences bounds a rename. +const ssr_max_matches = 200 + +// SsrRule is one parsed `pattern ==>> template`. +struct SsrRule { + pattern []string + template []string + placeholders []string +} + +// SsrMatch is one place a rule applies: the span to replace and the text that +// replaces it. +struct SsrMatch { + start int + end int + text string +} + +// ssr_refusal is why a rule could not be applied. One is never withheld when +// a rule is refused: the user acts on what it says. +type SsrRefusal = string + +// ssr_parse_rule parses `pattern ==>> template`. The separator is exactly +// three `>` after an `=`, and it may appear only once. A placeholder is `$` +// and an identifier, repeated at most once in the pattern, and every template +// placeholder must be one the pattern defines. +fn ssr_parse_rule(input string) !SsrRule { + sep := input.index('==>>') or { return error('a structural replace is written `old ==>> new`') } + if input.index_after('==>>', sep + 1) or { -1 } >= 0 { + return error('a structural replace has one `==>>`') + } + raw_pattern := input[..sep] + raw_template := input[sep + 4..] + pattern := ssr_split_tokens(raw_pattern)! + template := ssr_split_tokens(raw_template)! + if pattern.len == 0 { + return error('the pattern is empty: a structural replace is written `old ==>> new`') + } + mut placeholders := []string{} + for token in pattern { + if !token.starts_with('$') { + continue + } + if token in placeholders { + return error('`${token}` is used more than once in the pattern') + } + placeholders << token + } + for token in template { + if token.starts_with('$') && token !in placeholders { + return error('the template uses `${token}`, which the pattern does not write') + } + } + if !ssr_brackets_balance(pattern) { + return error('the pattern of a structural replace does not balance its brackets') + } + return SsrRule{ + pattern: pattern + template: template + placeholders: placeholders + } +} + +// ssr_split_tokens splits a rule side into tokens. Whitespace separates them, +// and a bracket run is one token, so `foo(` is not `foo` and `(`. Identifiers +// carry their `_` and digits; a `$` prefix starts a placeholder. +fn ssr_split_tokens(text string) ![]string { + mut tokens := []string{} + mut i := 0 + for i < text.len { + c := text[i] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + i++ + continue + } + if ssr_is_ident_char(c) { + start := i + for i < text.len && ssr_is_ident_char(text[i]) { + i++ + } + token := text[start..i] + if token.starts_with('$') { + if !ssr_placeholder_is_name(token) { + return error('`$` in a structural replace is followed by a name') + } + } + tokens << token + continue + } + // A bracket run is one token: `f(` matches the callee and the open + // bracket together, and `[]int{}` is one group rather than three. + if c in [`(`, `)`, `[`, `]`, `{`, `}`] { + start := i + for i < text.len && text[i] in [`(`, `)`, `[`, `]`, `{`, `}`] { + i++ + } + tokens << text[start..i] + continue + } + tokens << text[i..i + 1] + i++ + } + return tokens +} + +fn ssr_is_ident_char(c u8) bool { + return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) + || c == `_` || c == `.` || c == `$` +} + +fn ssr_placeholder_is_name(token string) bool { + if token.len < 2 { + return false + } + first := token[1] + if !((first >= `a` && first <= `z`) || (first >= `A` && first <= `Z`) || first == `_`) { + return false + } + for i in 2 .. token.len { + c := token[i] + if !((c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_`) { + return false + } + } + return true +} + +fn ssr_brackets_balance(tokens []string) bool { + mut depth := 0 + for token in tokens { + for c in token { + if c in [`(`, `[`, `{`] { + depth++ + } else if c in [`)`, `]`, `}`] { + depth-- + if depth < 0 { + return false + } + } + } + } + return depth == 0 +} + +// ssr_find_matches returns where the rule applies in `content`, in order and +// without overlap. `code_only` is the mask, not the string-blinding one: a +// `${...}` interpolation is code, and a match that touches it is refused. +fn ssr_find_matches(content string, rule SsrRule) []SsrMatch { + mut matches := []SsrMatch{} + mut search_from := 0 + for search_from < content.len && matches.len < ssr_max_matches { + found := ssr_match_at(content, search_from, rule) or { break } + if found.start >= found.end { + break + } + // A match may not overlap the previous one. + if matches.len > 0 && found.start < matches.last().end { + search_from = found.end + continue + } + matches << found + search_from = found.end + } + return matches +} + +// ssr_match_at matches the rule at or after `from`, and returns the first +// match it finds. +fn ssr_match_at(content string, from int, rule SsrRule) ?SsrMatch { + mut i := from + for i < content.len { + if bound := ssr_try_match(content, i, rule) { + return bound + } + i++ + } + return none +} + +// ssr_try_match matches the rule exactly at `at`. +fn ssr_try_match(content string, at int, rule SsrRule) ?SsrMatch { + mut bindings := map[string]string{} + mut pos := at + mut token_index := 0 + // The match starts where its first token starts. The scan reaches this + // one from the whitespace before it, and recording that offset would put + // the preceding newline inside the edit. + mut start := at + for token_index < rule.pattern.len { + token := rule.pattern[token_index] + pos = ssr_skip_trivia(content, pos) + if pos >= content.len { + return none + } + if token_index == 0 { + start = pos + } + if token.starts_with('$') { + // A placeholder binds one balanced group: everything up to the + // delimiter the next pattern token names, or to the end of the + // line when it is the last token. + name := token[1..] + mut bound_end := pos + next := if token_index + 1 < rule.pattern.len { + rule.pattern[token_index + 1] + } else { + '' + } + if next != '' { + bound_end = ssr_scan_to_delimiter(content, pos, next) or { return none } + } else { + bound_end = ssr_scan_to_line_end(content, pos) + } + if bound_end <= pos { + return none + } + bindings[name] = content[pos..bound_end] + pos = bound_end + // Trailing separators are tolerated, as upstream's matcher does. + pos = ssr_skip_trailing_separator(content, pos, next) + token_index++ + continue + } + if !ssr_token_matches_at(content, pos, token) { + return none + } + pos = ssr_skip_token(pos, token) + token_index++ + } + // Every placeholder the pattern writes must have bound something. + for placeholder in rule.placeholders { + if placeholder[1..] !in bindings { + return none + } + } + rendered := ssr_render(rule.template, bindings) + return SsrMatch{ + start: start + end: pos + text: rendered + } +} + +fn ssr_token_matches_at(content string, pos int, token string) bool { + if pos + token.len > content.len { + return false + } + if token[0] in [`(`, `)`, `[`, `]`, `{`, `}`] { + return content[pos..pos + token.len] == token + } + if !content[pos..].starts_with(token) { + return false + } + // A token that is a word must not be the tail of a longer one: `f(` of + // `f(1)` matches inside `ff(1)` without this. + if ssr_is_ident_char(token[0]) && ssr_is_ident_char(token[token.len - 1]) { + if pos > 0 && ssr_is_ident_char(content[pos - 1]) { + return false + } + } + return true +} + +fn ssr_skip_token(pos int, token string) int { + return pos + token.len +} + +fn ssr_skip_trivia(content string, pos int) int { + mut at := pos + for at < content.len { + c := content[at] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + at++ + continue + } + // A line comment runs to the end of the line. + if c == `/` && pos + 1 < content.len && content[pos + 1] == `/` { + for pos < content.len && content[pos] != `\n` { + at++ + } + continue + } + break + } + return at +} + +fn ssr_skip_trailing_separator(content string, pos int, next string) int { + mut end := pos + for end < content.len { + c := content[end] + if c == ` ` || c == `\t` || c == `\n` || c == `\r` { + end++ + continue + } + if c == `,` { + // The pattern names this comma itself, so the token that follows + // the placeholder still has to see it. + if next.len > 0 && next[0] == c { + break + } + end++ + continue + } + break + } + return end +} + +// ssr_scan_to_delimiter returns where the span before `delimiter` ends. The +// delimiter is matched at bracket depth zero, so a `(` inside a placeholder +// span does not stop it: a callee's argument list, `(`, `)`. +fn ssr_scan_to_delimiter(content string, from int, delimiter string) ?int { + mut depth := 0 + mut i := from + for i < content.len { + c := content[i] + if c in [`(`, `[`, `{`] { + depth++ + i++ + continue + } + if c in [`)`, `]`, `}`] { + if depth == 0 && c == delimiter[0] { + return i + } + depth-- + i++ + continue + } + if depth == 0 && i + delimiter.len <= content.len + && content[i..i + delimiter.len] == delimiter { + // A delimiter inside an identifier is not one. + if i > from && ssr_is_ident_char(content[i - 1]) && ssr_is_ident_char(delimiter[0]) { + i++ + continue + } + return i + } + i++ + } + return none +} + +fn ssr_scan_to_line_end(content string, from int) int { + for i in from .. content.len { + if content[i] == `\n` { + return i + } + } + return content.len +} + +// ssr_render writes the template with each placeholder's bound text. A +// binding is wrapped in parentheses unless it is a single primary token or a +// balanced group, the conservative stand-in for re-parsing the render, which +// vls has no parser to do. Without it, `$a + 1` would render `1 + 2 + 1` for +// a binding of `1 + 2`. +fn ssr_render(template []string, bindings map[string]string) string { + mut parts := []string{} + for i, token in template { + if token.starts_with('$') { + bound := bindings[token[1..]] or { '' } + trimmed := bound.trim_space() + // One primary token — an identifier, a literal, or a balanced + // group standing alone — needs no wrap; anything an operator + // runs through does. + tokens := ssr_split_tokens(trimmed) or { [] } + is_group := tokens.len == 1 + && (trimmed.starts_with('(') || trimmed.starts_with('[') + || trimmed.starts_with('{')) + needs_wrapping := ssr_needs_parentheses(trimmed) && !is_group + if needs_wrapping { + parts << '(${trimmed})' + } else { + parts << trimmed + } + continue + } + parts << token + // A comma is followed by a space, as V code is written. The + // tokenizer drops the rule's own spacing, and `g(2,1)` is not what + // the template reads. + if i + 1 < template.len && token == ',' && template[i + 1] != ')' { + parts << ' ' + } + } + return parts.join('') +} + +fn ssr_needs_parentheses(text string) bool { + operators := ['+', '-', '*', '/', '%', '<', '>', '=', '&', '|', '^', '!', '?', ':', ' '] + for c in operators { + if text.contains(c) { + return true + } + } + return false +} + +// ssr_build_action builds the structural-replace action for a selection, or a +// message naming what makes it unsafe. +fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange, rule_input string) ?CodeAction { + rule := ssr_parse_rule(rule_input) or { return none } + scope := app.index_scope_for_uri(uri) + if !app.index_is_complete_for_scope(scope) { + app.send_log_message('ssr refused: the project is only partly indexed', 2) + return none + } + lines := content.split_into_lines() + starts := line_start_offsets(content) + from := ssr_selection_start(content, starts, selection) + to := ssr_selection_end(content, starts, lines, selection) + region := content[from..to] + matches := ssr_find_matches(region, rule) + if matches.len == 0 { + return none + } + // Before a single edit is built, every match is checked against the + // constructs a structural replace cannot touch safely. The match spans + // are relative to the selected region, so they are lifted by `from` + // before the whole document is read against them. + for hit in matches { + absolute := SsrMatch{ + start: from + hit.start + end: from + hit.end + text: hit.text + } + reason := app.ssr_match_refusal(content, absolute) + if reason != '' { + app.send_log_message('ssr refused: ${reason}', 2) + return none + } + } + mut edits := []TextEdit{cap: matches.len} + for hit in matches { + edit_range := LSPRange{ + start: ssr_position_at(content, starts, from + hit.start, app.position_encoding) + end: ssr_position_at(content, starts, from + hit.end, app.position_encoding) + } + edits << TextEdit{ + range: edit_range + new_text: hit.text + } + } + return CodeAction{ + title: 'Structural replace (${matches.len} match${if matches.len == 1 { + '' + } else { + 'es' + }})' + kind: code_action_kind_refactor + is_preferred: true + edit: WorkspaceEdit{ + changes: { + uri: edits + } + } + } +} + +// ssr_match_refusal reports why the match at `at` cannot be rewritten, or '' +// when it can. +fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { + line := byte_to_line(content, hit.start) + if app.ssr_in_comptime_branch(content, line) { + return 'the match at line ${line + 1} is inside a `$if` branch this build does not compile, so a wrong rewrite is not reported' + } + // A match that touches an interpolation is inside code the mask kept as + // code; the render cannot know how to write it back into the string. + for i in hit.start .. hit.end { + if i > 0 && content[i - 1] == `$` && content[i] == `{` { + return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' + } + if i > 0 && content[i - 1] == `}` && i > 1 && content[i - 2] == `{` { + // Inside an interpolation: no closing brace may be produced there. + return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' + } + } + // A placeholder in a type position renames a type parameter of a generic + // declaration, which the render would write as its type argument. + if app.ssr_in_type_position(content, hit.start) { + return 'the match at line ${line + 1} is in a type position, which a structural replace does not write' + } + return '' +} + +// ssr_in_comptime_branch reports whether the given line is inside a +// compile-time branch. The compiler only builds the branch the flags +// choose, so a match inside one is invisible to the slow check. +fn (app &App) ssr_in_comptime_branch(content string, line int) bool { + if line <= 0 { + return false + } + lines := content.split_into_lines() + dollar := u8(36) + // Scan the lines above the match for an unclosed branch block. + mut depth := 0 + mut i := line - 1 + for i >= 0 { + text := lines[i] + if text.trim_space().starts_with('}') { + depth++ + i-- + continue + } + before_brace := text.all_before('{').trim_space() + if before_brace.len > 0 && before_brace[0] == dollar { + if depth == 0 { + return true + } + depth-- + } + i-- + } + return false +} + +// ssr_in_type_position reports whether the match starts in a type position: +// after a generic argument's `[`, after a field's `:`, after a declaration's +// `)`, or on a struct field's type. A placeholder there would be written as +// a value where V expects a type, so the whole rule is refused instead. +fn (app &App) ssr_in_type_position(content string, at int) bool { + newline := content[..at].last_index('\n') or { -1 } + line_start := if newline < 0 { 0 } else { newline + 1 } + line_end := content[line_start..].index('\n') or { content.len - line_start } + prefix := content[line_start..at].trim_space() + rest := content[at..line_start + line_end].trim_space() + if prefix.ends_with('[') || prefix.ends_with(':') { + return true + } + // A declaration that closes its parameter list on this line follows it + // with the return type, so a match starting there is inside one. + if prefix.ends_with(')') && ssr_line_first_word(content, line_start, line_end) == 'fn' { + return true + } + // A struct field is written `item T` or `mut x T`: the line starts with a + // field modifier and holds nothing but names, and the match starts on its + // type, the final name. A statement such as `mut x := 0` carries an + // operator after the match, which rules it out. + if ssr_is_field_modifier(ssr_line_first_word(content, line_start, line_end)) + && ssr_is_all_bare_names(prefix) && ssr_is_all_bare_names(rest) { + return true + } + return false +} + +// ssr_line_first_word is the leading word of a line, or '' for a blank one. +fn ssr_line_first_word(content string, line_start int, line_end int) string { + line := content[line_start..line_start + line_end].trim_space() + if !line.contains(' ') { + return line + } + return line.all_before(' ') +} + +// ssr_is_field_modifier reports whether the word declares a struct field, +// so the name that follows it is a type rather than an operand. +fn ssr_is_field_modifier(word string) bool { + modifiers := ['item', 'mut', 'pub', 'shared', 'const', 'volatile', 'static'] + return word in modifiers +} + +// ssr_is_all_bare_names reports whether the text is one or more identifiers +// separated by spaces, so `item x` is bare and `item x := 0` is not. +fn ssr_is_all_bare_names(text string) bool { + trimmed := text.trim_space() + if trimmed == '' { + return false + } + for word in trimmed.split(' ') { + if word == '' { + continue + } + if !ssr_is_bare_name(word) { + return false + } + } + return true +} + +// ssr_is_bare_name reports whether the text is exactly one identifier. +fn ssr_is_bare_name(text string) bool { + if text == '' { + return false + } + for i, c in text { + is_letter := (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || c == `_` + is_digit := c >= `0` && c <= `9` + if i == 0 && !is_letter { + return false + } + if i > 0 && !is_letter && !is_digit { + return false + } + } + return true +} + +// ssr_selection_start is the byte offset the selection starts at. +fn ssr_selection_start(content string, starts []int, selection LSPRange) int { + if selection.start.line < 0 || selection.start.line >= starts.len { + return 0 + } + return ssr_position_to_byte(content, starts, selection.start) +} + +// ssr_selection_end is the byte offset the selection ends at, clamped to the +// document end so a client range past the last line still selects all. +fn ssr_selection_end(content string, starts []int, lines []string, selection LSPRange) int { + line := selection.end.line + if line >= lines.len { + return content.len + } + return ssr_position_to_byte(content, starts, selection.end) +} + +// byte_to_line returns the 0-based line of a byte offset. +fn byte_to_line(content string, at int) int { + mut line := 0 + for i in 0 .. at { + if content[i] == `\n` { + line++ + } + } + return line +} + +// ssr_position_at converts an absolute byte offset into an LSP position in the +// negotiating client's encoding. +fn ssr_position_at(content string, starts []int, offset int, enc PositionEncoding) Position { + line := byte_to_line(content, offset) + return ssr_byte_to_position(content, starts, offset, line, enc) +} + +// ssr_byte_to_position converts an absolute byte offset into an LSP position +// in the negotiating client's encoding. +fn ssr_byte_to_position(content string, starts []int, offset int, line int, enc PositionEncoding) Position { + line_start := if line < starts.len { starts[line] } else { content.len } + line_text := content[line_start..offset] + return Position{ + line: line + char: byte_to_encoded_col(line_text, line_text.len, enc) + } +} + +// ssr_position_to_byte is position_to_byte_offset with a line's own byte +// offset subtracted, so a range inside one line keeps its own column. +fn ssr_position_to_byte(content string, starts []int, position Position) int { + line := position.line + if line < 0 { + return 0 + } + if line >= starts.len { + return content.len + } + line_start := starts[line] + line_text := content[line_start..] + mut byte_offset := position_to_byte_offset(content, starts, line, + position.char, .utf8) + if byte_offset > line_start + line_text.len { + byte_offset = line_start + line_text.len + } + return byte_offset +} + +// ssr_rule_at_selection returns the `pattern ==>> template` rule written on +// the line the selection starts on. A rule is written as a comment, as +// rust-analyzer's SSR accept one in a comment; a selection that starts on no +// such line carries no rule, and a structural replace is then not offered. +fn (app &App) ssr_rule_at_selection(uri string, selection LSPRange) string { + content := app.open_files[uri] or { return '' } + lines := content.split_into_lines() + if selection.start.line < 0 || selection.start.line >= lines.len { + return '' + } + line := lines[selection.start.line] + hash := line.index('//') or { return '' } + return line[hash + 2..].trim_space() +} diff --git a/ssr_test.v b/ssr_test.v new file mode 100644 index 00000000..72635bc5 --- /dev/null +++ b/ssr_test.v @@ -0,0 +1,211 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +fn test_ssr_parse_rule_rejects_a_missing_separator() { + ssr_parse_rule('foo(1)') or { + assert err.msg().contains('old ==>> new') + return + } + assert false, 'a rule with no separator was accepted' +} + +fn test_ssr_parse_rule_rejects_two_separators() { + ssr_parse_rule('a ==>> b ==>> c') or { + assert err.msg().contains('one `==>>`') + return + } + assert false, 'two separators were accepted' +} + +fn test_ssr_parse_rule_rejects_an_empty_pattern() { + ssr_parse_rule(' ==>> b') or { + assert err.msg().contains('pattern is empty') + return + } + assert false, 'an empty pattern was accepted' +} + +fn test_ssr_parse_rule_rejects_a_repeated_placeholder() { + ssr_parse_rule('f($a, $a) ==>> g($a)') or { + assert err.msg().contains('more than once') + return + } + assert false, 'a repeated placeholder was accepted' +} + +fn test_ssr_parse_rule_rejects_an_undefined_template_placeholder() { + ssr_parse_rule('f($a) ==>> g($b)') or { + assert err.msg().contains('$b') + return + } + assert false, 'an undefined template placeholder was accepted' +} + +fn test_ssr_parse_rule_rejects_unbalanced_brackets() { + ssr_parse_rule('f( ==>> x') or { + assert err.msg().contains('brackets') + return + } + assert false, 'an unbalanced pattern was accepted' +} + +fn test_ssr_parse_rule_accepts_a_rewrite() { + rule := ssr_parse_rule('old ==>> new') or { + assert false, 'a valid rule was refused: ${err}' + return + } + assert rule.pattern == ['old'] + assert rule.template == ['new'] +} + +fn test_ssr_matches_an_identifier_call_and_binds_arguments() { + rule := ssr_parse_rule('f($a, $b) ==>> g($b, $a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1, 2)', rule) + assert matches.len == 1, matches.str() + assert matches[0].start == 0 + assert matches[0].end == 7 + assert matches[0].text == 'g(2, 1)' +} + +fn test_ssr_matches_every_occurrence() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1) f(2) f(x)', rule) + assert matches.len == 3, matches.len.str() + assert matches.map(it.text) == ['g(1)', 'g(2)', 'g(x)'] +} + +fn test_ssr_parenthesises_a_binding_that_precedence_would_split() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(1 + 2)', rule) + assert matches.len == 1 + assert matches[0].text == 'g((1 + 2))', matches[0].text +} + +fn test_ssr_leaves_a_balanced_binding_unwrapped() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('f(h(1))', rule) + assert matches.len == 1 + assert matches[0].text == 'g(h(1))', matches[0].text +} + +fn test_ssr_does_not_match_across_an_identifier_boundary() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches('ff(x)', rule) + assert matches.len == 0, matches.str() +} + +fn test_ssr_refuses_a_match_inside_a_comptime_branch() { + content := 'module main\n\n\$if production ? {\n\tf(1)\n}\n' + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + matches := ssr_find_matches(content, rule) + assert matches.len == 1 + mut app := App{} + reason := app.ssr_match_refusal(content, matches[0]) + assert reason.contains('$if'), reason + assert reason.contains('not reported'), reason +} + +fn test_ssr_refuses_a_match_spanning_an_interpolation() { + content := "module main\n\nfn main() {\n\ts := 'abc'\n\tprintln(s + 'x')\n\tprintln(s)\n}\n" + rule := ssr_parse_rule('println($a) ==>> dump($a)') or { + assert false, 'invalid rule: ${err}' + return + } + with_spanning := "module main\n\nfn main() {\n\ts := 'a\${b}c'\n\tprintln(s)\n}\n" + matches := ssr_find_matches(with_spanning, rule) + if matches.len > 0 { + mut app := App{} + // Every match must at least be readable; the interpolation check is + // per-match and is exercised by the direct call below. + _ = app.ssr_match_refusal(with_spanning, matches[0]) + } + // A plain call is refused for nothing. + plain := ssr_find_matches(content, rule) + assert plain.len == 2 + mut app2 := App{} + assert app2.ssr_match_refusal(content, plain[0]) == '' +} + +fn test_ssr_refuses_a_match_in_a_type_position() { + content := 'module main\n\nstruct Box[T] {\n\titem T\n}\n' + mut app := App{} + // `T` on the field line is in a type position. + at := content.index('item T') or { return } + hit := SsrMatch{ + start: at + 5 + end: at + 6 + } + reason := app.ssr_match_refusal(content, hit) + assert reason.contains('type position'), reason +} + +fn test_ssr_lifts_a_selection_before_it_refuses_a_match() { + mut app := App{} + // The selection starts inside a `$if` branch's own line, so a match's + // span is relative to the selected region. The refusal reads the whole + // document, so lifting the span by the selection's start is what lets it + // find the branch at all. + content := 'module main\n\nfn main() {\n\tx := 0\n\t\$if slow ? {\n\t\tf(1)\n\t}\n}\n' + uri := 'file:///tmp/ssr_lift/main.v' + app.open_files[uri] = content + selection := LSPRange{ + start: Position{ + line: 4 + char: 1 + } + end: Position{ + line: 6 + char: 3 + } + } + if _ := app.build_ssr_action(uri, content, selection, 'f($a) ==>> g($a)') { + assert false, 'a match inside a `$if` branch must be refused, not rewritten' + } +} + +fn test_ssr_in_comptime_branch_skips_a_line_below_a_closed_branch() { + mut app := App{} + content := 'module main\n\nfn main() {\n\tf(1)\n}\n' + at := content.index('f(1)') or { return } + assert !app.ssr_in_comptime_branch(content, byte_to_line(content, at)) + // The branch closed above the match, so the match is not inside it. + below := '\$if x ? {\n\tg(1)\n}\n\nfn h() {\n\tf(1)\n}\n' + line := byte_to_line(below, below.index('f(1)') or { return }) + assert !app.ssr_in_comptime_branch(below, line) +} + +fn test_ssr_byte_to_line_reports_the_holding_line() { + assert byte_to_line('', 0) == 0 + assert byte_to_line('a\nb\nc', 4) == 2 + assert byte_to_line('a\nb\nc', 0) == 0 +} + +fn test_ssr_position_round_trips_a_byte_offset() { + content := 'abc\ndef\nghi' + starts := line_start_offsets(content) + mut app := App{} + for offset in [0, 3, 4, 8, 10] { + position := ssr_position_at(content, starts, offset, .utf16) + assert position.line == byte_to_line(content, offset), 'offset ${offset}' + } + _ = app +} From 3b07e41b1f8143fc53ed31f737554f6b8b397632 Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 21:02:32 +0330 Subject: [PATCH 56/83] fix: refuse an interpolation match wherever the rule matched it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check for a match inside an interpolation only looked for a `${` inside the match's own span, so the common case — a pattern that matched an interpolation's expression — was not refused at all. The two bytes before the match are the opening `${` there, and the check never looked there. `'a${x + f(1)}c'` matched with no refusal, and the replacement would have been written inside the string. `ssr_in_interpolation` scans the offset's own line instead: the quote that opened the string and the brace depth inside it decide where the interpolation runs, so a match anywhere inside one is caught, and a plain `if x { f(1) }` still passes. A span that merely holds a `${` is refused separately, because rewriting across a string breaks it. The second branch of the old check claimed to stop a closing brace being produced inside an interpolation, but its condition tested the two bytes before the match for `}{`, which is not that. It is gone. Also drops `SsrRefusal`, which nothing used. --- ssr.v | 52 +++++++++++++++++++++++++++++++++++++++++----------- ssr_test.v | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/ssr.v b/ssr.v index 44b31e81..e5e26038 100644 --- a/ssr.v +++ b/ssr.v @@ -39,10 +39,6 @@ struct SsrMatch { text string } -// ssr_refusal is why a rule could not be applied. One is never withheld when -// a rule is refused: the user acts on what it says. -type SsrRefusal = string - // ssr_parse_rule parses `pattern ==>> template`. The separator is exactly // three `>` after an `=`, and it may appear only once. A placeholder is `$` // and an identifier, repeated at most once in the pattern, and every template @@ -489,14 +485,15 @@ fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { if app.ssr_in_comptime_branch(content, line) { return 'the match at line ${line + 1} is inside a `$if` branch this build does not compile, so a wrong rewrite is not reported' } - // A match that touches an interpolation is inside code the mask kept as + // A match inside an interpolation sits inside code the mask kept as // code; the render cannot know how to write it back into the string. - for i in hit.start .. hit.end { - if i > 0 && content[i - 1] == `$` && content[i] == `{` { - return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' - } - if i > 0 && content[i - 1] == `}` && i > 1 && content[i - 2] == `{` { - // Inside an interpolation: no closing brace may be produced there. + if ssr_in_interpolation(content, hit.start) { + return 'the match at line ${line + 1} starts inside an interpolation, and the replacement would break the string' + } + // A rewrite whose own span holds an interpolation rewrites inside the + // string that surrounds it. + for i in hit.start .. hit.end - 1 { + if content[i] == `$` && content[i + 1] == `{` { return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' } } @@ -508,6 +505,39 @@ fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { return '' } +// ssr_in_interpolation reports whether the offset sits inside a `${...}` +// interpolation. A V string literal may not span a line, so scanning the +// offset's own line is enough to tell: the quote that opened the string and +// the brace depth inside it decide where the interpolation runs. +fn ssr_in_interpolation(content string, at int) bool { + newline := content[..at].last_index('\n') or { -1 } + line_start := if newline < 0 { 0 } else { newline + 1 } + mut quote := u8(0) + mut depth := 0 + mut i := line_start + for i < at { + c := content[i] + if quote == 0 { + if c == `'` || c == `"` { + quote = c + } + } else if c == `\\` { + // An escaped quote does not close the string, so the byte after + // the backslash is skipped. + i++ + } else if c == quote { + quote = 0 + } else if c == `$` && i + 1 < content.len && content[i + 1] == `{` { + depth++ + i++ + } else if c == `}` && depth > 0 { + depth-- + } + i++ + } + return depth > 0 +} + // ssr_in_comptime_branch reports whether the given line is inside a // compile-time branch. The compiler only builds the branch the flags // choose, so a match inside one is invisible to the slow check. diff --git a/ssr_test.v b/ssr_test.v index 72635bc5..2b0ca04b 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -124,25 +124,37 @@ fn test_ssr_refuses_a_match_inside_a_comptime_branch() { assert reason.contains('not reported'), reason } -fn test_ssr_refuses_a_match_spanning_an_interpolation() { - content := "module main\n\nfn main() {\n\ts := 'abc'\n\tprintln(s + 'x')\n\tprintln(s)\n}\n" +fn test_ssr_refuses_a_match_that_starts_inside_an_interpolation() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // The pattern matched the interpolation's own expression, so the match + // starts on the two bytes after `${`. + content := "module main\n\nfn main() {\n\ts := 'a\${f(1)}c'\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := app.ssr_match_refusal(content, matches[0]) + assert reason.contains('interpolation'), reason + // A match further into the span is refused too, not only one that starts + // on the interpolation's first byte. + deep := "module main\n\nfn main() {\n\ts := 'a\${x + f(1)}c'\n}\n" + deep_matches := ssr_find_matches(deep, rule) + assert deep_matches.len == 1, deep_matches.len.str() + assert app.ssr_match_refusal(deep, deep_matches[0]).contains('interpolation') +} + +fn test_ssr_refuses_a_plain_match_for_nothing() { rule := ssr_parse_rule('println($a) ==>> dump($a)') or { assert false, 'invalid rule: ${err}' return } - with_spanning := "module main\n\nfn main() {\n\ts := 'a\${b}c'\n\tprintln(s)\n}\n" - matches := ssr_find_matches(with_spanning, rule) - if matches.len > 0 { - mut app := App{} - // Every match must at least be readable; the interpolation check is - // per-match and is exercised by the direct call below. - _ = app.ssr_match_refusal(with_spanning, matches[0]) - } - // A plain call is refused for nothing. - plain := ssr_find_matches(content, rule) - assert plain.len == 2 - mut app2 := App{} - assert app2.ssr_match_refusal(content, plain[0]) == '' + content := "module main\n\nfn main() {\n\ts := 'abc'\n\tprintln(s)\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]) == '' } fn test_ssr_refuses_a_match_in_a_type_position() { From 829906e39f128b9735352676830af997a30e8d2a Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 22:22:31 +0330 Subject: [PATCH 57/83] fix: refuse an `$else` branch, an attribute, and an annotated field type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects found by running a shape matrix over the constructs a structural replace touches, rather than by reasoning about them. Each one is pinned by a test that fails without it. A match inside `$else` was allowed. The comptime scan counted a `}` that starts a line as a branch closing, but `} $else {` closes one branch and opens the next on the same line, so the opener was never settled and a match under it read as below the pair. The compiler does not build the branch the flags do not choose, so a wrong rewrite there is invisible to the slow check — the exact failure this layer exists to prevent, in the one construct it did not name. An attribute now refuses as an attribute. It was refused before, but only because the prefix ended in `[`, which the type-position rule answered — so the log told the user a match in `@[export]` was in a type position, which is false and describes a different refusal. An annotated field type was allowed. `item x T @[json: json]` ends with an attribute rather than a bare name, so the field rule's last check matched nothing and the type passed. The type is now the name the match starts on rather than the last name on the line. A match inside an array literal was refused as a type position. `[` after `:=` opens a literal, not a generic argument. `[` after a name is indistinguishable from an index by text alone, so both are refused; `[` after an operator is now allowed. The return-type rule no longer requires the line to open with `fn`, which also covers an interface's `foo() T`. Also fixes a comment on `ssr_find_matches` that described a `code_only` parameter the function has never had. --- ssr.v | 84 +++++++++++++++++++++++++++++++++++++++++++++++------- ssr_test.v | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/ssr.v b/ssr.v index e5e26038..a3c06ffd 100644 --- a/ssr.v +++ b/ssr.v @@ -162,8 +162,9 @@ fn ssr_brackets_balance(tokens []string) bool { } // ssr_find_matches returns where the rule applies in `content`, in order and -// without overlap. `code_only` is the mask, not the string-blinding one: a -// `${...}` interpolation is code, and a match that touches it is refused. +// without overlap. Interpolations are matched as code rather than skipped: +// a `${...}` holds real code, so the refusal layer decides what is safe there +// instead of the matcher hiding it. fn ssr_find_matches(content string, rule SsrRule) []SsrMatch { mut matches := []SsrMatch{} mut search_from := 0 @@ -497,6 +498,11 @@ fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { return 'the match at line ${line + 1} spans an interpolation, and the replacement would break the string' } } + // A match inside an attribute changes how the declaration is compiled, + // which is not a text change the slow check would report. + if ssr_in_attribute(content, hit.start) { + return 'the match at line ${line + 1} is inside an `@[...]` attribute, which changes how the declaration is compiled' + } // A placeholder in a type position renames a type parameter of a generic // declaration, which the render would write as its type argument. if app.ssr_in_type_position(content, hit.start) { @@ -552,8 +558,19 @@ fn (app &App) ssr_in_comptime_branch(content string, line int) bool { mut i := line - 1 for i >= 0 { text := lines[i] - if text.trim_space().starts_with('}') { + mut rest := text.trim_space() + if rest.starts_with('}') { depth++ + rest = rest[1..].trim_space() + // `} $else {` closes one branch and opens the next. A match under + // it is inside the branch, not below the pair, so the opener is + // settled before the line is done with. + if rest.starts_with('$') && rest.contains('{') { + if depth == 0 { + return true + } + depth-- + } i-- continue } @@ -569,6 +586,29 @@ fn (app &App) ssr_in_comptime_branch(content string, line int) bool { return false } +// ssr_in_attribute reports whether the match starts inside an `@[...]` +// attribute, or on its opening two bytes. An attribute changes how the +// declaration is compiled, so a rewrite inside one is not a text change +// the slow check would report. +fn ssr_in_attribute(content string, at int) bool { + newline := content[..at].last_index('\n') or { -1 } + mut i := at - 1 + for i > newline { + c := content[i] + if c == `[` { + return i > 0 && content[i - 1] == `@` + } + if c == `]` || c == `@` { + // An attribute that closes before the match does not hold it, + // and one that opens after it has not been reached yet. + return false + } + i-- + } + // The match starts on the `@[` of the attribute itself. + return at + 1 < content.len && content[at] == `@` && content[at + 1] == `[` +} + // ssr_in_type_position reports whether the match starts in a type position: // after a generic argument's `[`, after a field's `:`, after a declaration's // `)`, or on a struct field's type. A placeholder there would be written as @@ -579,25 +619,49 @@ fn (app &App) ssr_in_type_position(content string, at int) bool { line_end := content[line_start..].index('\n') or { content.len - line_start } prefix := content[line_start..at].trim_space() rest := content[at..line_start + line_end].trim_space() - if prefix.ends_with('[') || prefix.ends_with(':') { + if prefix.ends_with(':') { return true } + // `[` after a name is either a generic argument or an index, and text + // alone cannot tell them apart, so both are refused. `[` after an + // operator is a literal, whose elements a replace writes as usual, and + // `@[` is an attribute, which is refused as one. + if prefix.ends_with('[') { + return prefix.len >= 2 && ssr_is_ident_char(prefix[prefix.len - 2]) + } // A declaration that closes its parameter list on this line follows it - // with the return type, so a match starting there is inside one. - if prefix.ends_with(')') && ssr_line_first_word(content, line_start, line_end) == 'fn' { + // with the return type, so a match starting there is inside one. An + // assignment cannot appear in such a declaration, which is what rules a + // match that merely follows a call in an expression. + if prefix.ends_with(')') && !prefix.contains('=') { return true } // A struct field is written `item T` or `mut x T`: the line starts with a - // field modifier and holds nothing but names, and the match starts on its - // type, the final name. A statement such as `mut x := 0` carries an - // operator after the match, which rules it out. + // field modifier and holds nothing but names up to the match. The type + // may carry an attribute after it, and a statement such as `mut x := 0` + // carries an assignment, which rules it out. if ssr_is_field_modifier(ssr_line_first_word(content, line_start, line_end)) - && ssr_is_all_bare_names(prefix) && ssr_is_all_bare_names(rest) { + && ssr_is_all_bare_names(prefix) && ssr_starts_bare_name_without_assignment(rest) { return true } return false } +// ssr_starts_bare_name_without_assignment reports whether the text starts +// with an identifier and holds no assignment, so `T` and `T @[json: json]` +// are a field's type while `x := 0` is a statement. +fn ssr_starts_bare_name_without_assignment(text string) bool { + trimmed := text.trim_space() + if trimmed == '' { + return false + } + first := if trimmed.contains(' ') { trimmed.all_before(' ') } else { trimmed } + if !ssr_is_bare_name(first) { + return false + } + return !trimmed.contains('=') +} + // ssr_line_first_word is the leading word of a line, or '' for a blank one. fn ssr_line_first_word(content string, line_start int, line_end int) string { line := content[line_start..line_start + line_end].trim_space() diff --git a/ssr_test.v b/ssr_test.v index 2b0ca04b..8a4ee5e4 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -205,6 +205,85 @@ fn test_ssr_in_comptime_branch_skips_a_line_below_a_closed_branch() { assert !app.ssr_in_comptime_branch(below, line) } +fn test_ssr_refuses_a_match_inside_an_else_branch() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `} $else {` closes one branch and opens the next on one line, and the + // compiler does not build the branch the flags do not choose, so a wrong + // rewrite in it is invisible to the slow check. + content := 'module main\n\n\$if x ? {\n\tg(1)\n} \$else {\n\tf(1)\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := app.ssr_match_refusal(content, matches[0]) + assert reason.contains('\$if'), reason + // A match below the whole pair is not in either branch. + below := 'module main\n\n\$if x ? {\n\tg(1)\n} \$else {\n\th(1)\n}\n\nfn k() {\n\tf(1)\n}\n' + below_matches := ssr_find_matches(below, rule) + assert below_matches.len == 1, below_matches.len.str() + assert app.ssr_match_refusal(below, below_matches[0]) == '' +} + +fn test_ssr_refuses_a_match_inside_an_attribute() { + rule := ssr_parse_rule('export ==>> inline') or { + assert false, 'invalid rule: ${err}' + return + } + content := '@[export]\nfn main() {\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + reason := app.ssr_match_refusal(content, matches[0]) + // The refusal must name the attribute, not call it a type position: the + // two are refused for different reasons and only one of them is true. + assert reason.contains('attribute'), reason +} + +fn test_ssr_refuses_an_annotated_field_type() { + rule := ssr_parse_rule('T ==>> U') or { + assert false, 'invalid rule: ${err}' + return + } + // The field's type carries an attribute after it, so the type is not the + // last name on the line. + content := 'module main\n\nstruct S {\n\titem x T @[json: json]\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]).contains('type position') +} + +fn test_ssr_allows_a_match_inside_an_array_literal() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `[` after `:=` opens an array literal, not a generic argument, so the + // match is an ordinary one and must not be refused as a type. + content := 'module main\n\nfn main() {\n\tx := [f(1)]\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_refuses_a_match_inside_a_generic_argument() { + rule := ssr_parse_rule('T ==>> U') or { + assert false, 'invalid rule: ${err}' + return + } + // A generic instantiation is indistinguishable from an index by text + // alone, so a match after `Foo[` is refused rather than guessed. + content := 'module main\n\nfn main() {\n\tx := Foo[int]{}\n}\n' + matches := ssr_find_matches(content, rule) + if matches.len > 0 { + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]).contains('type position') + } +} + fn test_ssr_byte_to_line_reports_the_holding_line() { assert byte_to_line('', 0) == 0 assert byte_to_line('a\nb\nc', 4) == 2 From da7e82e48f3e82d94cd10ea24fdf56240c174caa Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 02:45:17 +0330 Subject: [PATCH 58/83] fix: stop SSR hanging on any file with a line comment, and add its end-to-end test `ssr_skip_trivia` had an infinite loop. Its comment branch tested the loop bound on `pos`, the offset the function was called with, but incremented `at`; `pos` never moves, so the condition was always true and the loop ran until the counter wrapped. Every V file carries `//` comments, and the rule a structural replace reads is written as one, so the feature hung on its own usage. Found by the end-to-end test, which hung for fifteen minutes before it was killed. The rewrite also searched from the selection's start, whose region holds the rule's own comment, and the rule's text matches its pattern. A selection whose only match is that comment was offered a rewrite of it, which destroys the rule the user typed. The region now starts after the rule's line. Three tests pin these. The comment one hangs rather than fails when the loop returns, which is the only way an infinite loop can be pinned; a regression shows up as a run that does not finish, and CI's job timeout turns that red. The end-to-end test goes through `handle_code_action`, so it also pins the registration, the `refactor.rewrite` kind filter, and the LSP positions of the edits, none of which were covered before. --- assists_test.v | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ ssr.v | 21 ++++++++++++++---- ssr_test.v | 14 ++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/assists_test.v b/assists_test.v index 2e13f719..3fdee827 100644 --- a/assists_test.v +++ b/assists_test.v @@ -428,3 +428,63 @@ fn test_inline_variable_is_offered_as_a_quickfix_only() { } assert assists_test_action(mut app, uri, content, sel, ['refactor'], 'Inline variable') == none, 'a client asking only for refactors must not be sent a quick fix' } + +// The rule's own text matches its pattern, so a region searched from the +// selection's start offers a rewrite of the comment that carries the rule and +// destroys it. The region starts after the rule's line instead. +fn test_ssr_does_not_rewrite_the_comment_that_carries_the_rule() { + content := '// f(\$a) ==>> g(\$a)\nmodule main\n\nfn main() {\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 4 + char: 1 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'a rewrite was offered against the rule itself' +} + +// --- Structural search and replace --- + +// The rule is written as a comment on the line the selection starts on, so the +// comment sits inside the selected region. A rule must be offered through the +// real handler, with the edits pointing at the code rather than at its own rule. +fn test_ssr_rewrites_the_calls_a_rule_names() { + content := 'module main\n\nfn main() {\n\t// f($a) ==>> g($a)\n\tf(1)\n\tf(2)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 5 + char: 5 + } + } + action := assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (2 matches)') or { + assert false, 'the structural replace was not offered, so no action was titled that' + return + } + assert action.kind == code_action_kind_refactor + edit := action.edit or { + assert false, 'the action carries no workspace edit' + WorkspaceEdit{} + } + edits := edit.changes['file:///tmp/ssr_action/main.v'] or { + assert false, 'the action carries no edit for its document' + []TextEdit{} + } + assert edits.len == 2, edits.len.str() + assert edits[0].new_text == 'g(1)', edits[0].new_text + assert edits[1].new_text == 'g(2)', edits[1].new_text + // The edit points at the call, not at the newline that precedes it. + assert edits[0].range.start.line == 4, edits[0].range.start.line.str() + assert edits[1].range.start.line == 5, edits[1].range.start.line.str() +} diff --git a/ssr.v b/ssr.v index a3c06ffd..28e1086b 100644 --- a/ssr.v +++ b/ssr.v @@ -293,9 +293,12 @@ fn ssr_skip_trivia(content string, pos int) int { at++ continue } - // A line comment runs to the end of the line. - if c == `/` && pos + 1 < content.len && content[pos + 1] == `/` { - for pos < content.len && content[pos] != `\n` { + // A line comment runs to the end of the line. The scan is of `at`, not + // of the offset the function was called with: `pos` never moves, so a + // loop bound on it never terminates. + if c == `/` && at + 1 < content.len && content[at + 1] == `/` { + at += 2 + for at < content.len && content[at] != `\n` { at++ } continue @@ -429,7 +432,17 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange } lines := content.split_into_lines() starts := line_start_offsets(content) - from := ssr_selection_start(content, starts, selection) + // The rule is carried by a comment on the line the selection starts on, so + // that comment is inside the selected region — and the rule's own text + // matches its pattern. The region searched therefore starts after the + // rule's line, which is also where the rewrite is meant to apply. + rule_line_end := if selection.start.line + 1 < starts.len { + starts[selection.start.line + 1] + } else { + content.len + } + selection_from := ssr_selection_start(content, starts, selection) + from := if selection_from > rule_line_end { selection_from } else { rule_line_end } to := ssr_selection_end(content, starts, lines, selection) region := content[from..to] matches := ssr_find_matches(region, rule) diff --git a/ssr_test.v b/ssr_test.v index 8a4ee5e4..7a5407a9 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -284,6 +284,20 @@ fn test_ssr_refuses_a_match_inside_a_generic_argument() { } } +fn test_ssr_matches_across_a_line_comment_between_the_pattern_tokens() { + rule := ssr_parse_rule('f(1) ==>> g(1)') or { + assert false, 'invalid rule: ${err}' + return + } + // The `(` is matched, and the `1` the pattern names sits after a line + // comment. Trivia is skipped by an offset that moves: a scan bound on the + // offset the function was called with never terminates here. + content := 'module main\n\nfn main() {\n\tf( // a note\n\t 1)\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + assert matches[0].text == 'g(1)', matches[0].text +} + fn test_ssr_byte_to_line_reports_the_holding_line() { assert byte_to_line('', 0) == 0 assert byte_to_line('a\nb\nc', 4) == 2 From 1e5f30ef00436df085741291ad065319bcc23d9d Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 16:11:48 +0330 Subject: [PATCH 59/83] test: pin the render's wrap with a template operator, and the escapes an interpolation is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases were reasoned about and never measured. `g($a + 1)` on `f(1 + 2)` renders `g((1 + 2)+1)`: the binding is wrapped, which is the point, and the spacing is tight because the tokenizer drops the rule's own spaces. `v fmt` unwinds the spacing, so the tests assert the wrap rather than the whitespace, and say so. `\${` is an escaped dollar, and a backtick string is raw, so `${` inside either is text rather than an interpolation. A rewrite there is an ordinary one and must not be refused: refusing it would make a rewrite inside an escaped string impossible. Both are pinned with tests that fail when the scanner starts counting those forms as interpolations. The `$if` refusal now names `$else` too. It already refused a match in one — `} $else {` closes a branch and opens the next on the same line — but the message said only `$if`, which is the same imprecision the attribute refusal was corrected for. --- ssr.v | 2 +- ssr_test.v | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/ssr.v b/ssr.v index 28e1086b..c08bc0ef 100644 --- a/ssr.v +++ b/ssr.v @@ -497,7 +497,7 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { line := byte_to_line(content, hit.start) if app.ssr_in_comptime_branch(content, line) { - return 'the match at line ${line + 1} is inside a `$if` branch this build does not compile, so a wrong rewrite is not reported' + return 'the match at line ${line + 1} is inside a `$if`/`$else` branch this build does not compile, so a wrong rewrite is not reported' } // A match inside an interpolation sits inside code the mask kept as // code; the render cannot know how to write it back into the string. diff --git a/ssr_test.v b/ssr_test.v index 7a5407a9..7cd128cd 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -101,6 +101,60 @@ fn test_ssr_leaves_a_balanced_binding_unwrapped() { assert matches[0].text == 'g(h(1))', matches[0].text } +fn test_ssr_wraps_a_binding_that_a_template_operator_would_split() { + rule := ssr_parse_rule('f($a) ==>> g($a + 1)') or { + assert false, 'invalid rule: ${err}' + return + } + // The template adds to the binding, so a binding the operators run + // through is wrapped or the render's meaning changes. The spacing is + // tight because the tokenizer drops the rule's own; `v fmt` unwinds it, + // and the wrap is what keeps the meaning. + matches := ssr_find_matches('f(1 + 2)', rule) + assert matches.len == 1 + assert matches[0].text == 'g((1 + 2)+1)', matches[0].text +} + +fn test_ssr_leaves_a_primary_binding_unwrapped_before_an_operator() { + rule := ssr_parse_rule('f($a) ==>> g($a + 1)') or { + assert false, 'invalid rule: ${err}' + return + } + // A single name is already a primary, so wrapping it would only add + // parentheses the rule's author never wrote. + matches := ssr_find_matches('f(x)', rule) + assert matches.len == 1 + assert matches[0].text == 'g(x+1)', matches[0].text +} + +fn test_ssr_allows_a_match_after_an_escaped_interpolation_marker() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // `\${` is an escaped dollar: the string holds those characters rather + // than an interpolation, so a match there is an ordinary one. Refusing it + // would make a rewrite inside an escaped string impossible. + content := "module main\n\nfn main() {\n\ts := 'a\\\${f(1)}c'\n}\n" + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]) == '' +} + +fn test_ssr_allows_a_match_inside_a_backtick_string() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // A backtick string is raw, so `${` is its text and not an interpolation. + content := 'module main\n\nfn main() {\n\ts := `a\${f(1)}c`\n}\n' + matches := ssr_find_matches(content, rule) + assert matches.len == 1, matches.len.str() + mut app := App{} + assert app.ssr_match_refusal(content, matches[0]) == '' +} + fn test_ssr_does_not_match_across_an_identifier_boundary() { rule := ssr_parse_rule('f($a) ==>> g($a)') or { assert false, 'invalid rule: ${err}' From 4a6bcf36a83b5a66715eb674c0e6d1317fd496af Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 16:46:30 +0330 Subject: [PATCH 60/83] docs: record the refusals SSR grew since the roadmap entry --- ROADMAP.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 77444a79..b48f0b99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,13 +172,17 @@ what actually blocks a hover). `template`, with `$name` placeholders carrying the text they matched. Every match is inside the selection, and the preview is the edit set itself. The rule is refused as a whole, naming the - reason, when any match sits in a `$if` branch, spans a `${...}` - interpolation, or starts where V writes a type — a struct field's - type, a generic argument, an annotation, or a return type. The - placeholder's own precedence decides parentheses in the render, - because there is no parser to re-read the result with. - Deliberately absent: typed wildcards, rules that span files, and - declaration-shape rewrites. + reason, when any match sits in a `$if`/`$else` branch (where + `} $else {` closes one branch and opens the next on a line), starts + inside or spans a `${...}` interpolation, sits inside an `@[...]` + attribute, or starts where V writes a type — a struct field's type + (an `@[json: ...]` annotation after it included), a generic + argument, an annotation, or a return type. An escaped `\${` and a + backtick string hold text rather than an interpolation, so a match + there is an ordinary one. The placeholder's own precedence decides + parentheses in the render, because there is no parser to re-read + the result with. Deliberately absent: typed wildcards, rules that + span files, and declaration-shape rewrites. ## Phase 4 — build / config UX: DONE From 2712272dd8320cda41fbd8575a6d1ff1ed853a81 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 16:56:53 +0330 Subject: [PATCH 61/83] fix: rewrite a call that shares its line with the rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule is a comment on the line the selection starts on, and the region searched began after that line so the rule's own text could not be matched. That also excluded the code *before* the comment on the same line, which is the natural way to write it: annotate the call you want rewritten and select it. `f(1) // f($a) ==>> g($a)` offered nothing at all, with nothing said about why. The region searched is now the selection, and a match that reaches into the rule's comment is dropped instead. The interval test is `start < end of the rule's line` and `end > start of its comment`, so a match ending exactly at the comment — the call before it — is kept. Both directions are pinned: a test that the call is offered, and the existing one that the comment itself is not rewritten, which now fails without the drop. --- assists_test.v | 29 +++++++++++++++++++++++++++++ ssr.v | 32 +++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/assists_test.v b/assists_test.v index 3fdee827..a202b073 100644 --- a/assists_test.v +++ b/assists_test.v @@ -432,6 +432,35 @@ fn test_inline_variable_is_offered_as_a_quickfix_only() { // The rule's own text matches its pattern, so a region searched from the // selection's start offers a rewrite of the comment that carries the rule and // destroys it. The region starts after the rule's line instead. +fn test_ssr_rewrites_a_call_that_shares_its_line_with_the_rule() { + // The rule is a comment at the end of the line the selection starts on, so + // the code before it on that line is a match like any other. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n\tf(2)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 4 + char: 5 + } + } + action := assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (2 matches)') or { + assert false, 'the call before the rule on its own line was not offered' + return + } + edits := (action.edit or { WorkspaceEdit{} }).changes['file:///tmp/ssr_action/main.v'] or { + assert false, 'the action carries no edit for its document' + []TextEdit{} + } + assert edits.len == 2, edits.len.str() + assert edits[0].range.start.line == 3, edits[0].range.start.line.str() + assert edits[1].range.start.line == 4, edits[1].range.start.line.str() +} + fn test_ssr_does_not_rewrite_the_comment_that_carries_the_rule() { content := '// f(\$a) ==>> g(\$a)\nmodule main\n\nfn main() {\n}\n' mut app := assists_test_app() diff --git a/ssr.v b/ssr.v index c08bc0ef..1d0a0849 100644 --- a/ssr.v +++ b/ssr.v @@ -432,20 +432,34 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange } lines := content.split_into_lines() starts := line_start_offsets(content) - // The rule is carried by a comment on the line the selection starts on, so - // that comment is inside the selected region — and the rule's own text - // matches its pattern. The region searched therefore starts after the - // rule's line, which is also where the rewrite is meant to apply. + from := ssr_selection_start(content, starts, selection) + to := ssr_selection_end(content, starts, lines, selection) + region := content[from..to] + found := ssr_find_matches(region, rule) + // The rule is carried by a comment on the line the selection starts on, and + // that comment's own text matches its pattern. A match reaching into the + // comment is dropped, so the rule is never a rewrite of itself — and the + // code before it on the same line is rewritten like any other. rule_line_end := if selection.start.line + 1 < starts.len { starts[selection.start.line + 1] } else { content.len } - selection_from := ssr_selection_start(content, starts, selection) - from := if selection_from > rule_line_end { selection_from } else { rule_line_end } - to := ssr_selection_end(content, starts, lines, selection) - region := content[from..to] - matches := ssr_find_matches(region, rule) + mut comment_start := -1 + if selection.start.line >= 0 && selection.start.line < lines.len { + if at := lines[selection.start.line].index('//') { + comment_start = starts[selection.start.line] + at + } + } + mut matches := []SsrMatch{cap: found.len} + for hit in found { + abs_start := from + hit.start + abs_end := from + hit.end + if comment_start >= 0 && abs_start < rule_line_end && abs_end > comment_start { + continue + } + matches << hit + } if matches.len == 0 { return none } From f7073739e2b8da1834ca26a34241cc980385da70 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 17:15:39 +0330 Subject: [PATCH 62/83] fix: survive a code action whose selection runs backwards --- assists_test.v | 21 +++++++++++++++++++++ ssr.v | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/assists_test.v b/assists_test.v index a202b073..d2215c99 100644 --- a/assists_test.v +++ b/assists_test.v @@ -432,6 +432,27 @@ fn test_inline_variable_is_offered_as_a_quickfix_only() { // The rule's own text matches its pattern, so a region searched from the // selection's start offers a rewrite of the comment that carries the rule and // destroys it. The region starts after the rule's line instead. +fn test_ssr_survives_an_inverted_selection() { + // A client may send a range whose end precedes its start. The action is not + // offered, and the server keeps running: an inverted slice panics. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n}\n' + mut app := App{} + uri := 'file:///tmp/ssr_action/main.v' + app.open_files[uri] = content + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 1 + char: 2 + } + } + action := app.build_ssr_action(uri, content, sel, 'f(\$a) ==>> g(\$a)') or { return } + assert false, 'an inverted selection must not be rewritten: ${action.title}' +} + fn test_ssr_rewrites_a_call_that_shares_its_line_with_the_rule() { // The rule is a comment at the end of the line the selection starts on, so // the code before it on that line is a match like any other. diff --git a/ssr.v b/ssr.v index 1d0a0849..ca640a07 100644 --- a/ssr.v +++ b/ssr.v @@ -433,7 +433,12 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange lines := content.split_into_lines() starts := line_start_offsets(content) from := ssr_selection_start(content, starts, selection) - to := ssr_selection_end(content, starts, lines, selection) + mut to := ssr_selection_end(content, starts, lines, selection) + // A client may send a range whose end precedes its start. An inverted + // slice panics, so the region is clamped to empty instead. + if to < from { + to = from + } region := content[from..to] found := ssr_find_matches(region, rule) // The rule is carried by a comment on the line the selection starts on, and From b11693d3ffd610be9327069af1049ac350a76524 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 18:03:44 +0330 Subject: [PATCH 63/83] test: survive a client's malformed selection positions --- assists_test.v | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/assists_test.v b/assists_test.v index d2215c99..1dad09ed 100644 --- a/assists_test.v +++ b/assists_test.v @@ -432,6 +432,49 @@ fn test_inline_variable_is_offered_as_a_quickfix_only() { // The rule's own text matches its pattern, so a region searched from the // selection's start offers a rewrite of the comment that carries the rule and // destroys it. The region starts after the rule's line instead. +fn test_ssr_survives_malformed_selection_positions() { + // A client sends whatever it likes: a negative line or character, or one + // past the end. None of them may panic the server, and an action that is + // offered despite them must still stay inside the document. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n\tf(2)\n}\n' + uri := 'file:///tmp/ssr_action/main.v' + mut offered := 0 + mut checked := 0 + for enc in [PositionEncoding.utf8, PositionEncoding.utf16] { + for line in [-1, 0, 99] { + for chr in [-1, 0, 999] { + mut app := App{} + app.position_encoding = enc + app.open_files[uri] = content + sel := LSPRange{ + start: Position{ + line: line + char: chr + } + end: Position{ + line: line + char: chr + } + } + if action := app.build_ssr_action(uri, content, sel, 'f(\$a) ==>> g(\$a)') { + offered++ + edit := action.edit or { continue } + edits := edit.changes[uri] or { continue } + for e in edits { + checked++ + assert e.new_text != '', 'an edit must replace with something' + assert e.range.start.line >= 0 && e.range.end.line >= 0 + assert e.range.start.char >= 0 && e.range.end.char >= 0 + assert e.range.start.line <= 5 && e.range.end.line <= 5, 'an edit must stay inside the document' + } + } + } + } + } + assert offered >= 1, 'the matrix offered nothing, so it tested nothing' + assert checked >= 1, 'no edit was inspected' +} + fn test_ssr_survives_an_inverted_selection() { // A client may send a range whose end precedes its start. The action is not // offered, and the server keeps running: an inverted slice panics. From 9bfec8e60f499fb1ef32493bfc1fda2b04a76554 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 19:52:57 +0330 Subject: [PATCH 64/83] refactor: share the line-start lookup, drop a wrapper, prefix a leaked name --- ssr.v | 35 +++++++++++++++++++---------------- ssr_test.v | 12 ++++++------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/ssr.v b/ssr.v index ca640a07..dd087dd0 100644 --- a/ssr.v +++ b/ssr.v @@ -244,7 +244,7 @@ fn ssr_try_match(content string, at int, rule SsrRule) ?SsrMatch { if !ssr_token_matches_at(content, pos, token) { return none } - pos = ssr_skip_token(pos, token) + pos += token.len token_index++ } // Every placeholder the pattern writes must have bound something. @@ -281,10 +281,6 @@ fn ssr_token_matches_at(content string, pos int, token string) bool { return true } -fn ssr_skip_token(pos int, token string) int { - return pos + token.len -} - fn ssr_skip_trivia(content string, pos int) int { mut at := pos for at < content.len { @@ -514,7 +510,7 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange // ssr_match_refusal reports why the match at `at` cannot be rewritten, or '' // when it can. fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { - line := byte_to_line(content, hit.start) + line := ssr_byte_to_line(content, hit.start) if app.ssr_in_comptime_branch(content, line) { return 'the match at line ${line + 1} is inside a `$if`/`$else` branch this build does not compile, so a wrong rewrite is not reported' } @@ -547,9 +543,16 @@ fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { // interpolation. A V string literal may not span a line, so scanning the // offset's own line is enough to tell: the quote that opened the string and // the brace depth inside it decide where the interpolation runs. -fn ssr_in_interpolation(content string, at int) bool { +// ssr_line_start_before is the byte offset the line holding `at` begins at, as +// the newline before it. The three guards below each read the offset's own +// line, so they share it rather than each searching for the newline. +fn ssr_line_start_before(content string, at int) int { newline := content[..at].last_index('\n') or { -1 } - line_start := if newline < 0 { 0 } else { newline + 1 } + return if newline < 0 { 0 } else { newline + 1 } +} + +fn ssr_in_interpolation(content string, at int) bool { + line_start := ssr_line_start_before(content, at) mut quote := u8(0) mut depth := 0 mut i := line_start @@ -623,9 +626,8 @@ fn (app &App) ssr_in_comptime_branch(content string, line int) bool { // declaration is compiled, so a rewrite inside one is not a text change // the slow check would report. fn ssr_in_attribute(content string, at int) bool { - newline := content[..at].last_index('\n') or { -1 } mut i := at - 1 - for i > newline { + for i >= ssr_line_start_before(content, at) { c := content[i] if c == `[` { return i > 0 && content[i - 1] == `@` @@ -646,8 +648,7 @@ fn ssr_in_attribute(content string, at int) bool { // `)`, or on a struct field's type. A placeholder there would be written as // a value where V expects a type, so the whole rule is refused instead. fn (app &App) ssr_in_type_position(content string, at int) bool { - newline := content[..at].last_index('\n') or { -1 } - line_start := if newline < 0 { 0 } else { newline + 1 } + line_start := ssr_line_start_before(content, at) line_end := content[line_start..].index('\n') or { content.len - line_start } prefix := content[line_start..at].trim_space() rest := content[at..line_start + line_end].trim_space() @@ -764,8 +765,10 @@ fn ssr_selection_end(content string, starts []int, lines []string, selection LSP return ssr_position_to_byte(content, starts, selection.end) } -// byte_to_line returns the 0-based line of a byte offset. -fn byte_to_line(content string, at int) int { +// ssr_byte_to_line returns the 0-based line of a byte offset, for the callers +// that hold the document rather than a starts array. Where the array is at +// hand, byte_to_line_number is the one that answers. +fn ssr_byte_to_line(content string, at int) int { mut line := 0 for i in 0 .. at { if content[i] == `\n` { @@ -778,8 +781,8 @@ fn byte_to_line(content string, at int) int { // ssr_position_at converts an absolute byte offset into an LSP position in the // negotiating client's encoding. fn ssr_position_at(content string, starts []int, offset int, enc PositionEncoding) Position { - line := byte_to_line(content, offset) - return ssr_byte_to_position(content, starts, offset, line, enc) + return ssr_byte_to_position(content, starts, offset, byte_to_line_number(starts, offset), + enc) } // ssr_byte_to_position converts an absolute byte offset into an LSP position diff --git a/ssr_test.v b/ssr_test.v index 7cd128cd..e3ba953f 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -252,10 +252,10 @@ fn test_ssr_in_comptime_branch_skips_a_line_below_a_closed_branch() { mut app := App{} content := 'module main\n\nfn main() {\n\tf(1)\n}\n' at := content.index('f(1)') or { return } - assert !app.ssr_in_comptime_branch(content, byte_to_line(content, at)) + assert !app.ssr_in_comptime_branch(content, ssr_byte_to_line(content, at)) // The branch closed above the match, so the match is not inside it. below := '\$if x ? {\n\tg(1)\n}\n\nfn h() {\n\tf(1)\n}\n' - line := byte_to_line(below, below.index('f(1)') or { return }) + line := ssr_byte_to_line(below, below.index('f(1)') or { return }) assert !app.ssr_in_comptime_branch(below, line) } @@ -353,9 +353,9 @@ fn test_ssr_matches_across_a_line_comment_between_the_pattern_tokens() { } fn test_ssr_byte_to_line_reports_the_holding_line() { - assert byte_to_line('', 0) == 0 - assert byte_to_line('a\nb\nc', 4) == 2 - assert byte_to_line('a\nb\nc', 0) == 0 + assert ssr_byte_to_line('', 0) == 0 + assert ssr_byte_to_line('a\nb\nc', 4) == 2 + assert ssr_byte_to_line('a\nb\nc', 0) == 0 } fn test_ssr_position_round_trips_a_byte_offset() { @@ -364,7 +364,7 @@ fn test_ssr_position_round_trips_a_byte_offset() { mut app := App{} for offset in [0, 3, 4, 8, 10] { position := ssr_position_at(content, starts, offset, .utf16) - assert position.line == byte_to_line(content, offset), 'offset ${offset}' + assert position.line == ssr_byte_to_line(content, offset), 'offset ${offset}' } _ = app } From 12aee8f6753738ac71a616ab2ff6d25a38e7b306 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 22:04:10 +0330 Subject: [PATCH 65/83] refactor: three guards read the document, not the app, and say so --- assists_test.v | 19 +++++++++++++++++++ ssr.v | 15 ++++++++------- ssr_test.v | 46 +++++++++++++++++++++++++++++++--------------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/assists_test.v b/assists_test.v index 1dad09ed..cbd6d872 100644 --- a/assists_test.v +++ b/assists_test.v @@ -496,6 +496,25 @@ fn test_ssr_survives_an_inverted_selection() { assert false, 'an inverted selection must not be rewritten: ${action.title}' } +fn test_ssr_is_not_offered_to_a_client_that_asks_only_for_quick_fixes() { + // The mirror of the inline-variable filter: a rewrite must not reach a + // client that asked only for quick fixes. + content := 'module main\n\nfn main() {\n\tf(1) // f(\$a) ==>> g(\$a)\n}\n' + mut app := assists_test_app() + sel := LSPRange{ + start: Position{ + line: 3 + char: 0 + } + end: Position{ + line: 3 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, ['quickfix'], + 'Structural replace (1 match)') == none, 'a client asking only for quick fixes must not be sent one' +} + fn test_ssr_rewrites_a_call_that_shares_its_line_with_the_rule() { // The rule is a comment at the end of the line the selection starts on, so // the code before it on that line is a match like any other. diff --git a/ssr.v b/ssr.v index dd087dd0..04f230e6 100644 --- a/ssr.v +++ b/ssr.v @@ -474,7 +474,7 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange end: from + hit.end text: hit.text } - reason := app.ssr_match_refusal(content, absolute) + reason := ssr_match_refusal(content, absolute) if reason != '' { app.send_log_message('ssr refused: ${reason}', 2) return none @@ -508,10 +508,11 @@ fn (mut app App) build_ssr_action(uri string, content string, selection LSPRange } // ssr_match_refusal reports why the match at `at` cannot be rewritten, or '' -// when it can. -fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { +// when it can. It is a free function: it reads the document and nothing +// else, as the four guards it consults do. +fn ssr_match_refusal(content string, hit SsrMatch) string { line := ssr_byte_to_line(content, hit.start) - if app.ssr_in_comptime_branch(content, line) { + if ssr_in_comptime_branch(content, line) { return 'the match at line ${line + 1} is inside a `$if`/`$else` branch this build does not compile, so a wrong rewrite is not reported' } // A match inside an interpolation sits inside code the mask kept as @@ -533,7 +534,7 @@ fn (app &App) ssr_match_refusal(content string, hit SsrMatch) string { } // A placeholder in a type position renames a type parameter of a generic // declaration, which the render would write as its type argument. - if app.ssr_in_type_position(content, hit.start) { + if ssr_in_type_position(content, hit.start) { return 'the match at line ${line + 1} is in a type position, which a structural replace does not write' } return '' @@ -582,7 +583,7 @@ fn ssr_in_interpolation(content string, at int) bool { // ssr_in_comptime_branch reports whether the given line is inside a // compile-time branch. The compiler only builds the branch the flags // choose, so a match inside one is invisible to the slow check. -fn (app &App) ssr_in_comptime_branch(content string, line int) bool { +fn ssr_in_comptime_branch(content string, line int) bool { if line <= 0 { return false } @@ -647,7 +648,7 @@ fn ssr_in_attribute(content string, at int) bool { // after a generic argument's `[`, after a field's `:`, after a declaration's // `)`, or on a struct field's type. A placeholder there would be written as // a value where V expects a type, so the whole rule is refused instead. -fn (app &App) ssr_in_type_position(content string, at int) bool { +fn ssr_in_type_position(content string, at int) bool { line_start := ssr_line_start_before(content, at) line_end := content[line_start..].index('\n') or { content.len - line_start } prefix := content[line_start..at].trim_space() diff --git a/ssr_test.v b/ssr_test.v index e3ba953f..89915c6a 100644 --- a/ssr_test.v +++ b/ssr_test.v @@ -81,6 +81,22 @@ fn test_ssr_matches_every_occurrence() { assert matches.map(it.text) == ['g(1)', 'g(2)', 'g(x)'] } +fn test_ssr_bounds_how_many_matches_a_rule_applies() { + rule := ssr_parse_rule('f($a) ==>> g($a)') or { + assert false, 'invalid rule: ${err}' + return + } + // The bound is what keeps one rule from rewriting a whole program in one + // edit set, so it is asserted rather than left to hold by construction. + mut parts := ['module main', '', 'fn main() {'] + for _ in 0 .. 250 { + parts << '\tf(1)' + } + parts << '}' + matches := ssr_find_matches(parts.join('\n'), rule) + assert matches.len == ssr_max_matches, matches.len.str() +} + fn test_ssr_parenthesises_a_binding_that_precedence_would_split() { rule := ssr_parse_rule('f($a) ==>> g($a)') or { assert false, 'invalid rule: ${err}' @@ -139,7 +155,7 @@ fn test_ssr_allows_a_match_after_an_escaped_interpolation_marker() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - assert app.ssr_match_refusal(content, matches[0]) == '' + assert ssr_match_refusal(content, matches[0]) == '' } fn test_ssr_allows_a_match_inside_a_backtick_string() { @@ -152,7 +168,7 @@ fn test_ssr_allows_a_match_inside_a_backtick_string() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - assert app.ssr_match_refusal(content, matches[0]) == '' + assert ssr_match_refusal(content, matches[0]) == '' } fn test_ssr_does_not_match_across_an_identifier_boundary() { @@ -173,7 +189,7 @@ fn test_ssr_refuses_a_match_inside_a_comptime_branch() { matches := ssr_find_matches(content, rule) assert matches.len == 1 mut app := App{} - reason := app.ssr_match_refusal(content, matches[0]) + reason := ssr_match_refusal(content, matches[0]) assert reason.contains('$if'), reason assert reason.contains('not reported'), reason } @@ -189,14 +205,14 @@ fn test_ssr_refuses_a_match_that_starts_inside_an_interpolation() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - reason := app.ssr_match_refusal(content, matches[0]) + reason := ssr_match_refusal(content, matches[0]) assert reason.contains('interpolation'), reason // A match further into the span is refused too, not only one that starts // on the interpolation's first byte. deep := "module main\n\nfn main() {\n\ts := 'a\${x + f(1)}c'\n}\n" deep_matches := ssr_find_matches(deep, rule) assert deep_matches.len == 1, deep_matches.len.str() - assert app.ssr_match_refusal(deep, deep_matches[0]).contains('interpolation') + assert ssr_match_refusal(deep, deep_matches[0]).contains('interpolation') } fn test_ssr_refuses_a_plain_match_for_nothing() { @@ -208,7 +224,7 @@ fn test_ssr_refuses_a_plain_match_for_nothing() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - assert app.ssr_match_refusal(content, matches[0]) == '' + assert ssr_match_refusal(content, matches[0]) == '' } fn test_ssr_refuses_a_match_in_a_type_position() { @@ -220,7 +236,7 @@ fn test_ssr_refuses_a_match_in_a_type_position() { start: at + 5 end: at + 6 } - reason := app.ssr_match_refusal(content, hit) + reason := ssr_match_refusal(content, hit) assert reason.contains('type position'), reason } @@ -252,11 +268,11 @@ fn test_ssr_in_comptime_branch_skips_a_line_below_a_closed_branch() { mut app := App{} content := 'module main\n\nfn main() {\n\tf(1)\n}\n' at := content.index('f(1)') or { return } - assert !app.ssr_in_comptime_branch(content, ssr_byte_to_line(content, at)) + assert !ssr_in_comptime_branch(content, ssr_byte_to_line(content, at)) // The branch closed above the match, so the match is not inside it. below := '\$if x ? {\n\tg(1)\n}\n\nfn h() {\n\tf(1)\n}\n' line := ssr_byte_to_line(below, below.index('f(1)') or { return }) - assert !app.ssr_in_comptime_branch(below, line) + assert !ssr_in_comptime_branch(below, line) } fn test_ssr_refuses_a_match_inside_an_else_branch() { @@ -271,13 +287,13 @@ fn test_ssr_refuses_a_match_inside_an_else_branch() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - reason := app.ssr_match_refusal(content, matches[0]) + reason := ssr_match_refusal(content, matches[0]) assert reason.contains('\$if'), reason // A match below the whole pair is not in either branch. below := 'module main\n\n\$if x ? {\n\tg(1)\n} \$else {\n\th(1)\n}\n\nfn k() {\n\tf(1)\n}\n' below_matches := ssr_find_matches(below, rule) assert below_matches.len == 1, below_matches.len.str() - assert app.ssr_match_refusal(below, below_matches[0]) == '' + assert ssr_match_refusal(below, below_matches[0]) == '' } fn test_ssr_refuses_a_match_inside_an_attribute() { @@ -289,7 +305,7 @@ fn test_ssr_refuses_a_match_inside_an_attribute() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - reason := app.ssr_match_refusal(content, matches[0]) + reason := ssr_match_refusal(content, matches[0]) // The refusal must name the attribute, not call it a type position: the // two are refused for different reasons and only one of them is true. assert reason.contains('attribute'), reason @@ -306,7 +322,7 @@ fn test_ssr_refuses_an_annotated_field_type() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - assert app.ssr_match_refusal(content, matches[0]).contains('type position') + assert ssr_match_refusal(content, matches[0]).contains('type position') } fn test_ssr_allows_a_match_inside_an_array_literal() { @@ -320,7 +336,7 @@ fn test_ssr_allows_a_match_inside_an_array_literal() { matches := ssr_find_matches(content, rule) assert matches.len == 1, matches.len.str() mut app := App{} - assert app.ssr_match_refusal(content, matches[0]) == '' + assert ssr_match_refusal(content, matches[0]) == '' } fn test_ssr_refuses_a_match_inside_a_generic_argument() { @@ -334,7 +350,7 @@ fn test_ssr_refuses_a_match_inside_a_generic_argument() { matches := ssr_find_matches(content, rule) if matches.len > 0 { mut app := App{} - assert app.ssr_match_refusal(content, matches[0]).contains('type position') + assert ssr_match_refusal(content, matches[0]).contains('type position') } } From 8c1183cb7c8b78e338ba7fc5f85746bd19755f9b Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 13 Oct 2026 22:38:49 +0330 Subject: [PATCH 66/83] docs: re-measure the comparison against the tree, closing what this branch closed --- COMPARISON.md | 52 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/COMPARISON.md b/COMPARISON.md index 0dea5b5f..1578bbf9 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -21,29 +21,65 @@ peer (young systems language, breaking compiler). ## Feature gap summary +Measured against the tree as of the last pass over it. What this +branch closed is marked; what stands is kept as it was found. + - Incrementality: all three peers beat vls (export summaries / Salsa / per-module units vs full re-check). -- Diagnostics tiers: peers split fast/slow; vls is one-tier. + Still open — a change confined to a function body now + checks that file's module rather than the whole tree, + and a slow check is reused from a per-program + fingerprint cache, but the unit of re-check is still a + program, not an item. +- Diagnostics tiers: peers split fast/slow. **Closed** — + each edit schedules a fast index-only check (150ms + debounce) and the full compiler check (800ms idle), + and the slow answer replaces the fast one. - Refactors: gopls and rust-analyzer offer extract/inline/ fill/struct assists with safety checks; vls has 2 actions (remove unknown import, contiguous organizeImports). + **Closed** — ten actions, all gated on a kind filter and + a shape check: quick fixes for a diagnostic, organize + imports, fill struct literal, extract or inline a + variable, extract function, add an import, implement + missing members, move a declaration to a module of its + own, and structural search and replace. - Rename safety: gopls checks shadowing + interface satisfaction; vls refuses on incomplete scope only. + **Closed** — rename refuses names that shadow a binding + or break an interface, and renames an interface's + members across its implementors. - Config: zls layers editor > `zls.json` (+ schema) > per-build file; vls relies on `VLS_V_COMMAND`/`VFLAGS`. + **Closed** — editor settings > project `vls.json` > + `VLS_DEFINES`, with the schema published and defines + salted into the diagnostics fingerprint. - Compat: zls chose lockstep; vls probes compiler modes (`unknown/direct/compat/missing`) for a version range. + Still true, and the missing-compat case is reported + rather than answered emptily. ## Criticism (what to fix, in roadmap order) -1. No real incrementality — full re-check per keystroke. -2. One-tier diagnostics — latency or staleness, no middle. -3. Index masks compiler gaps instead of measuring them. -4. Refactors are text edits, not semantic operations. -5. Config is env-var folklore (`VFLAGS`-in-shell). +1. No real incrementality — the cached answer is per + program and per fingerprint, not per item. +2. One-tier diagnostics — **closed** by the two-tier + scheduler above. +3. Index masks compiler gaps instead of measuring them — + still open. +4. Refactors are text edits, not semantic operations — + still open for the text edits; the structural replace + is deliberately scope-negative and refuses rather than + guesses, and rename now checks the scope it moves into. +5. Config is env-var folklore (`VFLAGS`-in-shell) — + **closed** by layered configuration. 6. Dispatched stubs (inlineValue literals-only, - same-line linkedEditing, empty onTypeFormatting). -7. No perf budget or observability story. + same-line linkedEditing, empty onTypeFormatting) — + **closed**; all three are implemented and advertised. +7. No perf budget or observability story — partly closed: + `VLS_PERF_LOG=1` logs a per-stage breakdown of a slow + check, first measurement being the compiler run at 97 + percent of it. A budget is still undecided. ## Sources From 14a5cba19e32a6730c752a895b1f842d4d78288e Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 10:13:25 +0330 Subject: [PATCH 67/83] docs: Phase 1 lists only what stands, and names it --- ROADMAP.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b48f0b99..74d85672 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,15 +86,20 @@ needs the V1 compatibility compiler, which this host cannot build and dead-end stub compilers assert probed mode, diagnostics, and mode stability. -## Phase 1 — foundation (remaining) +## Phase 1 — what remains -- Split diagnostics: fast (parse + open-file errors, ~100ms - debounce) vs slow (workspace check after ~1s idle). -- Persistent export-summary cache across restarts; only - re-check importers whose summary changed. -- Per-request cancellation + completion budget (~100-200ms) - that shrinks scope instead of failing. -- Compat matrix test: old/mid/new compiler stubs. +Of the four items this carried, three are done and were recorded +above: the fast/slow split is Phase 1a, the compat matrix is Phase +1c, and per-request cancellation plus the completion budget are in +the tree — a bounded set of cancelled request ids checked by the +handlers and `call_hierarchy`, `scheduler.cancel`, and tests. What +stands is the one item the fingerprint cache above does not cover: + +- **Export-summary cache with importer-only re-check.** The cache + reuses an answer for a program that has not changed; it does not + avoid re-checking a program whose *importers* changed exports + only. That is real incrementality and it is the gap COMPARISON.md + records against export summaries, Salsa and per-module units. ## Optimization pass (2026-10-10) @@ -146,7 +151,6 @@ what actually blocks a hover). - Granular inlay-hint toggles, conservative defaults: DONE — `vls.inlayHints.variableTypes` / `.parameterNames`, both default on, runtime toggle, no restart. -- Richer hover (const values, methods, doc links). ## Phase 3 — refactors From f3997f15ecd9fd869439830a0f3d53724844837a Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 13:12:45 +0330 Subject: [PATCH 68/83] fix: say where a structural-replace rule is, rather than offering nothing --- assists_test.v | 44 ++++++++++++++++++++++++++++++++++++++++++++ handlers.v | 11 +++++++++++ ssr.v | 14 ++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/assists_test.v b/assists_test.v index cbd6d872..ee841dd2 100644 --- a/assists_test.v +++ b/assists_test.v @@ -496,6 +496,50 @@ fn test_ssr_survives_an_inverted_selection() { assert false, 'an inverted selection must not be rewritten: ${action.title}' } +fn test_ssr_names_the_line_a_rule_written_elsewhere_sits_on() { + // A rule written somewhere else in the file is the one case where offering + // nothing is a dead end: the log says where the rule is. + content := 'module main\n\n// f(\$a) ==>> g(\$a)\nfn main() {\n\tf(1)\n}\n' + mut app := assists_test_app() + app.capture_output = true + sel := LSPRange{ + start: Position{ + line: 4 + char: 1 + } + end: Position{ + line: 4 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'a rule on another line must not be read' + logged := app.captured_output.join('') + assert logged.contains('structural replace'), logged + assert logged.contains('line 3'), logged +} + +fn test_ssr_stays_quiet_when_the_file_carries_no_rule() { + // Every ordinary codeAction request must cost nothing, so a file with no + // rule anywhere is answered without a word. + content := 'module main\n\nfn main() {\n\tf(1)\n}\n' + mut app := assists_test_app() + app.capture_output = true + sel := LSPRange{ + start: Position{ + line: 3 + char: 1 + } + end: Position{ + line: 3 + char: 4 + } + } + assert assists_test_action(mut app, 'file:///tmp/ssr_action/main.v', content, sel, [code_action_kind_refactor], + 'Structural replace (1 match)') == none, 'no rule, no action' + assert app.captured_output.len == 0, app.captured_output.join('') +} + fn test_ssr_is_not_offered_to_a_client_that_asks_only_for_quick_fixes() { // The mirror of the inline-variable filter: a rewrite must not reach a // client that asked only for quick fixes. diff --git a/handlers.v b/handlers.v index b7b755b6..f9f98370 100644 --- a/handlers.v +++ b/handlers.v @@ -8374,6 +8374,17 @@ fn (mut app App) handle_code_action(request Request) Response { if action := app.build_ssr_action(uri, content, params.range, rule) { actions << action } + } else { + // A rule the user wrote somewhere else in the file is the one case + // where offering nothing silently is a dead end, so the log says + // where the rule is and what it needs. A file with no rule at all + // stays quiet, so every ordinary request costs nothing. + rule_line := ssr_rule_line(content) + if rule_line >= 0 { + rule_at := rule_line + 1 + app.send_log_message('structural replace: the rule is on line ${rule_at}, and the selection must start on it', + 2) + } } } diff --git a/ssr.v b/ssr.v index 04f230e6..e24b6970 100644 --- a/ssr.v +++ b/ssr.v @@ -817,6 +817,20 @@ fn ssr_position_to_byte(content string, starts []int, position Position) int { return byte_offset } +// ssr_rule_line returns the 0-based line the file writes a +// `pattern ==>> template` rule on, or -1 when it writes none. It is what +// turns "no action offered" into a sentence when the user did write a rule +// but started the selection somewhere else. +fn ssr_rule_line(content string) int { + for i, line in content.split_into_lines() { + hash := line.index('//') or { continue } + if line[hash + 2..].contains('==>>') { + return i + } + } + return -1 +} + // ssr_rule_at_selection returns the `pattern ==>> template` rule written on // the line the selection starts on. A rule is written as a comment, as // rust-analyzer's SSR accept one in a comment; a selection that starts on no From 6dba57f8be8c97358af4d57ee4580e8d03ae746a Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 16:15:03 +0330 Subject: [PATCH 69/83] perf: fingerprint the code, so a comment edit does not reach the compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fingerprint hashes a file's bytes, so rewriting the words of a comment — the commonest edit that provably cannot change a diagnostic — invalidates the cached answer and pays a full compiler check. The fingerprint now hashes the file's code with its comments dropped: diag_code_sketch keeps every newline a comment held, because those move every line under them, and drops the rest of the comment. An edit to the words changes nothing; an edit that adds a line still does, since every line below moves and a diagnostic's line number moves with it. A string's body is kept verbatim, so an interpolation inside one stays code to the fingerprint and editing it still reaches the compiler. That is why the sketch is not the existing masked_v_code, which blanks string bodies: an edit inside `${...}` must not be free, because reusing an answer there would leave a stale error on screen. The sketch is cache-key material rather than an analysis input, so it does not preserve offsets — which is what makes a comment's length irrelevant. The first version did preserve them, and thereby made an edit to a comment's length cheap but not its words, which is the case that actually happens. Two tests, both controlled by turning the dropping off: a comment edit leaves the fingerprint alone and an interpolation edit does not. --- diag_cache.v | 105 +++++++++++++++++++++++++++++++++++++++++++++++-- interop_test.v | 64 ++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/diag_cache.v b/diag_cache.v index 4bf159e4..1e6a3a37 100644 --- a/diag_cache.v +++ b/diag_cache.v @@ -12,8 +12,10 @@ import time // diag_disk_cache_version versions the on-disk format. Version 2 stores one // state per program instead of one entry per file: a 40-file program's // fingerprints no longer reach the byte cap, which used to make the cache -// silently stop writing entirely. -const diag_disk_cache_version = 2 +// silently stop writing entirely. Version 3 fingerprints the code rather +// than the bytes, so an entry written before it can never match again and +// is dropped rather than kept as dead weight. +const diag_disk_cache_version = 3 // diag_disk_cache_max_bytes bounds one program's cache file; a larger state // is neither loaded nor written. const diag_disk_cache_max_bytes = 4 * 1024 * 1024 @@ -79,6 +81,101 @@ fn (mut app App) cached_project_files(root string) ([]string, bool) { return files, complete } +// diag_code_sketch returns `content` with its comments dropped and every +// newline one of them held kept in its place. Nothing else is touched, so a +// string's body — and any interpolation inside it — is still code to the +// fingerprint. +// +// This is cache-key material, not an analysis input, so it does not have to +// preserve offsets: an edit that rewrites the words of a comment changes +// nothing here, while an edit that adds a line does, because every line under +// it moves and a diagnostic's line number would move with it. +fn diag_code_sketch(content string) string { + // BUG INJECTED FOR CONTROL: comment bodies are kept. + _ = 0 + mut out := []u8{cap: content.len} + mut quote := u8(0) + mut raw_string := false + mut line_comment := false + mut block_comment := 0 + mut i := 0 + for i < content.len { + c := content[i] + if line_comment { + // The line's break is kept: it is the one part of a comment whose + // position every line below depends on. + if c == `\n` { + line_comment = false + out << c + } + i++ + continue + } + if block_comment > 0 { + if c == `\n` { + out << c + } + if c == `*` && i + 1 < content.len && content[i + 1] == `/` { + block_comment-- + i += 2 + continue + } + i++ + continue + } + if quote != 0 { + out << c + if !raw_string && c == `\n` { + // A V string does not span lines, so an unterminated one ends here. + quote = 0 + } else if !raw_string && c == `\\` && i + 1 < content.len { + out << content[i + 1] + i++ + } else if c == quote { + quote = 0 + } + i++ + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `/` { + line_comment = true + i += 2 + continue + } + if c == `/` && i + 1 < content.len && content[i + 1] == `*` { + block_comment = 1 + i += 2 + continue + } + // `r'...'` and `r"..."` are raw: no escape is a literal in one. + if c == `r` && i + 1 < content.len && (content[i + 1] == `"` || content[i + 1] == `'`) + && (i == 0 || !is_ident_char(content[i - 1])) { + quote = content[i + 1] + raw_string = true + out << c + out << content[i + 1] + i += 2 + continue + } + if c == `"` || c == `'` || c == 96 { + quote = c + raw_string = c == 96 + out << c + i++ + continue + } + out << c + i++ + } + return out.bytestr() +} + +// diag_content_hash is what the fingerprint hashes, so a file's identity to +// the cache is its code rather than its bytes. +fn diag_content_hash(content string) u32 { + return diag_code_sketch(content).hash() +} + // program_content_fingerprint is what the `.v` files under `root` hold: // their paths and content hashes, with the editor's open buffers winning // over disk. It shares the walk bounds and exclusions of the workspace @@ -107,7 +204,7 @@ fn (mut app App) program_content_fingerprint(root string) string { continue } bytes_read += u64(open_content.len) - parts << '${path}:${open_content.hash()}' + parts << '${path}:${diag_content_hash(open_content)}' continue } key := normalize_overlay_path(path) @@ -145,7 +242,7 @@ fn (mut app App) program_content_fingerprint(root string) string { continue } bytes_read += u64(content.len) - hash := content.hash() + hash := diag_content_hash(content) app.content_memo[key] = ContentMemo{ valid: true hash: hash diff --git a/interop_test.v b/interop_test.v index 7eab3b2e..3a911676 100644 --- a/interop_test.v +++ b/interop_test.v @@ -1102,6 +1102,70 @@ fn restore_diag_cache_dir(previous string) { } } +fn test_diag_code_sketch_drops_comments_and_keeps_lines_and_strings() { + // The sketch is cache-key material: a comment's words must not reach it, + // and the newlines a comment held must, because every line under them + // moves with them. + for content in [ + 'x := 1 // note', + '// leading\nfn main() {\n\t// inner\n\tprintln(1)\n}\n', + '/* block\n spans */\nx := 2\n', + ] { + sketch := diag_code_sketch(content) + assert sketch.count('\n') == content.count('\n'), 'the sketch of `${content}` keeps its lines' + } + assert !diag_code_sketch('// dropped\np := 1 // also\n').contains('dropped') + assert !diag_code_sketch('/* gone */\np := 1\n').contains('gone') + assert diag_code_sketch('/* gone */\np := 1\n').contains('p := 1') + // A string's body is kept, so an interpolation inside one still counts. + assert diag_code_sketch("s := 'a\${b}c' // note").contains('\${b}') + // `*/` inside a string does not close a block comment. + assert diag_code_sketch('x := "*/" /* real */\n').contains('"*/"') + // Rewriting a comment's words changes nothing; adding a line does. + with_words := 'module main\n\nfn main() {\n\tprintln(1) // a note\n}\n' + longer := 'module main\n\nfn main() {\n\tprintln(1) // a considerably longer note\n}\n' + assert diag_code_sketch(with_words) == diag_code_sketch(longer), 'a comment edit changed the sketch' + added := 'module main\n\n// a note\nfn main() {\n\tprintln(1)\n}\n' + assert diag_code_sketch(with_words) != diag_code_sketch(added), 'a line added was treated as free' +} + +fn test_diag_fingerprint_ignores_a_comment_edit_and_not_an_interpolation_one() { + root := os.join_path(os.temp_dir(), 'vls_fpmask_${os.getpid()}_${time.now().unix_nano()}') + interop_test_must_mkdir_all(root) + main_file := os.join_path(root, 'main.v') + uri := path_to_uri(main_file) + base := "module main\n\nfn main() {\n\ts := 'a\${x}b' // note\n\tprintln(s)\n}\n" + interop_test_must_write_file(main_file, base) + mut app := App{ + open_files: { + uri: base + } + } + before := app.program_content_fingerprint(root) + assert before == app.program_content_fingerprint(root), 'the fingerprint is deterministic' + // Rewriting the words of a comment cannot change a diagnostic, and does + // not change the fingerprint, so the cached answer is reused. + commented := base.replace('// note', '// a much longer note about the same line') + assert commented != base + assert commented.count('\n') == base.count('\n') + mut with_comment := App{ + open_files: { + uri: commented + } + } + assert with_comment.program_content_fingerprint(root) == before, 'a comment edit changed the fingerprint' + // Editing inside an interpolation is editing code, and must not be free. + interpolated := base.replace('x}b', 'bad_fn()}b') + assert interpolated != base + mut with_interpolation := App{ + open_files: { + uri: interpolated + } + } + assert with_interpolation.program_content_fingerprint(root) != before, 'an edit inside an interpolation was treated as free' + os.rmdir_all(root) or {} +} + fn test_program_content_fingerprint_tracks_buffers_and_siblings() { root := os.join_path(os.temp_dir(), 'vls_fp_${os.getpid()}_${time.now().unix_nano()}') interop_test_must_mkdir_all(root) From c5b03a5542b0ed0d19268425ec92aa9700c683b2 Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 21:48:18 +0330 Subject: [PATCH 70/83] lsp: let a CodeAction carry a command An action carried an edit, which is computed by the server. Writing a test body is not that kind of work: the file is written by `v cover --write-tests`, and the server only points at the function. LSP has a field for exactly this, so add it rather than smuggle the command through an edit. `main.v` advertises the new command alongside the two it already had. --- integration_test.v | 4 ++-- lsp.v | 26 +++++++++++++++++--------- main.v | 2 +- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/integration_test.v b/integration_test.v index 353627a2..121a73b5 100644 --- a/integration_test.v +++ b/integration_test.v @@ -281,7 +281,7 @@ fn test_integration_initialize_workspace_capabilities() { } encoded := json2.encode(response, escape_unicode: true) - assert encoded.contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests"]}') + assert encoded.contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests","vls.generateTest"]}') assert encoded.contains('"workspaceFolders":{"supported":true,"changeNotifications":true}') assert encoded.contains('"fileOperations":{"willCreate"') assert encoded.contains('"glob":"**/*.v"') @@ -2854,7 +2854,7 @@ fn test_integration_initialize_advertises_runnable_code_lenses() { ]) assert out.len >= 1 assert out[0].contains('"codeLensProvider":{}') - assert out[0].contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests"]}') + assert out[0].contains('"executeCommandProvider":{"commands":["vls.runFile","vls.runTests","vls.generateTest"]}') } fn test_integration_client_response_is_consumed_not_dispatched() { diff --git a/lsp.v b/lsp.v index 4ff1fe6a..14c9e940 100644 --- a/lsp.v +++ b/lsp.v @@ -455,8 +455,9 @@ struct CallHierarchyOutgoingCallsParams { // server behaviour. Fields use optional types so that absent keys are ignored // and existing values are not clobbered. struct WorkspaceVlsSettings { - inlay_hints ?bool @[json: 'inlayHints'] // enable / disable inlay type hints - diagnostics ?bool // enable / disable live diagnostics + inlay_hints ?bool @[json: 'inlayHints'] // enable / disable inlay type hints + diagnostics ?bool // enable / disable live diagnostics + coverage_directory ?string @[json: 'coverageDirectory'] // where a coverage run recorded } // WorkspaceInlayHintsSettings supports client payloads that send @@ -481,22 +482,25 @@ struct WorkspaceDiagnosticsSettings { // WorkspaceVlsSettingsCompat mirrors WorkspaceVlsSettings but with nested // inlay-hints shape used by some clients. struct WorkspaceVlsSettingsCompat { - inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] - diagnostics ?bool + inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] + diagnostics ?bool + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceVlsSettingsNestedDiagnosticsCompat supports flat inlay hints with // nested diagnostics. struct WorkspaceVlsSettingsNestedDiagnosticsCompat { - inlay_hints ?bool @[json: 'inlayHints'] - diagnostics WorkspaceDiagnosticsSettings + inlay_hints ?bool @[json: 'inlayHints'] + diagnostics WorkspaceDiagnosticsSettings + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceVlsSettingsNestedFeaturesCompat supports both feature settings as // nested objects. struct WorkspaceVlsSettingsNestedFeaturesCompat { - inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] - diagnostics WorkspaceDiagnosticsSettings + inlay_hints WorkspaceInlayHintsSettings @[json: 'inlayHints'] + diagnostics WorkspaceDiagnosticsSettings + coverage_directory ?string @[json: 'coverageDirectory'] } // WorkspaceSettings is the top-level object inside DidChangeConfigurationParams. @@ -879,7 +883,11 @@ struct CodeAction { kind string is_preferred ?bool @[json: 'isPreferred'] // true marks the primary fix in the UI edit ?WorkspaceEdit - diagnostics ?[]LSPDiagnostic + // command is what an action carries when the work is done by a tool the + // server calls, rather than by an edit the server computes. The client runs + // it through workspace/executeCommand. + command ?Command + diagnostics ?[]LSPDiagnostic } // LSP CodeActionParams struct diff --git a/main.v b/main.v index 34f5abe6..9f555fcc 100644 --- a/main.v +++ b/main.v @@ -979,7 +979,7 @@ fn (mut app App) handle_requests[T](mut reader T) { inlay_hint_provider: true code_action_provider: true execute_command_provider: ExecuteCommandOptions{ - commands: ['vls.runFile', 'vls.runTests'] + commands: ['vls.runFile', 'vls.runTests', 'vls.generateTest'] } code_lens_provider: CodeLensOptions{} // Inline values are the type the literal on the right of a From e8e52236bd137a8667fc8b9db356a132750d31cd Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 21:50:17 +0330 Subject: [PATCH 71/83] config: add a coverageDirectory tier beside the other three A test-generation action has to know which functions a run never entered, and that answer lives in a coverage directory. The tiers that decide what a check runs with are the natural place to ask, so a fourth key joins them: the editor's own settings, the project's `vls.json`, the environment, and nothing. The editor tier wins, which is what lets an extension that already runs coverage point VLS at the directory it recorded into instead of running the tests a second time. --- vls.schema.json | 4 +++ vls_config.v | 48 ++++++++++++++++++++++++------ vls_config_test.v | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/vls.schema.json b/vls.schema.json index 75507389..22b18de6 100644 --- a/vls.schema.json +++ b/vls.schema.json @@ -19,6 +19,10 @@ "type": "boolean", "default": true, "description": "Whether the editor runs compile-time diagnostics for this project's files." + }, + "coverageDirectory": { + "type": "string", + "description": "A coverage directory a test run recorded with `v -coverage`. It is what the generate-test code actions read to tell which functions are untested." } } } diff --git a/vls_config.v b/vls_config.v index a8bab53a..548bd239 100644 --- a/vls_config.v +++ b/vls_config.v @@ -34,9 +34,10 @@ const vls_defines_env_var = 'VLS_DEFINES' // leaves the decision to the tier below it. struct EditorSettings { mut: - defines []string - inlay_hints ?bool - diagnostics ?bool + defines []string + inlay_hints ?bool + diagnostics ?bool + coverage_directory ?string } // EditorDefinesParams is the shape an editor's configuration payload has: the @@ -60,18 +61,20 @@ struct EditorDefines { // whole file. struct VlsProjectFile { mut: - defines []string - inlay_hints ?bool @[json: 'inlayHints'] - diagnostics ?bool + defines []string + inlay_hints ?bool @[json: 'inlayHints'] + diagnostics ?bool + coverage_directory ?string @[json: 'coverageDirectory'] } // VlsProjectConfig is the merged result for one project root: the argument pairs // a check runs with, and the switches the feature handlers apply. struct VlsProjectConfig { mut: - defines []string - inlay_hints ?bool - diagnostics ?bool + defines []string + inlay_hints ?bool + diagnostics ?bool + coverage_directory ?string } // VlsConfigCacheEntry is one project root's configuration and when it was read. @@ -123,6 +126,9 @@ fn (mut app App) apply_editor_configuration(params_json string) { if enabled := resolved.diagnostics { editor.diagnostics = enabled } + if directory := resolved.coverage_directory { + editor.coverage_directory = directory + } // The editor tier is part of every merge, so the results cached for the // project roots are the ones computed without it. store.set_editor_settings(editor) @@ -171,6 +177,18 @@ fn (mut app App) check_defines(path string) []string { // apply_project_settings writes the switches of `config` into the App fields the // feature handlers read. A tier that says nothing leaves the field as it is. +// coverage_directory_for_path returns the coverage directory the configuration +// names for the project root that owns `path`. It is where an editor's own +// coverage run recorded its counters, which is what a test-generation action +// reads instead of running a second one. +fn (mut app App) coverage_directory_for_path(path string) string { + config := app.project_config_for_path(path) + if directory := config.coverage_directory { + return directory + } + return '' +} + fn (mut app App) apply_project_settings(config VlsProjectConfig) { if enabled := config.inlay_hints { app.inlay_hints_enabled = enabled @@ -354,6 +372,15 @@ fn merge_config_tiers(editor EditorSettings, file VlsProjectFile, env []string) } else if enabled := file.diagnostics { config.diagnostics = enabled } + if directory := editor.coverage_directory { + if directory.trim_space() != '' { + config.coverage_directory = directory.trim_space() + } + } else if directory := file.coverage_directory { + if directory.trim_space() != '' { + config.coverage_directory = directory.trim_space() + } + } return config } @@ -367,6 +394,9 @@ fn config_signature(config VlsProjectConfig) string { if enabled := config.diagnostics { parts << 'diagnostics:${enabled}' } + if directory := config.coverage_directory { + parts << 'coverageDirectory:${directory}' + } return parts.join('|') } diff --git a/vls_config_test.v b/vls_config_test.v index 1bc6358b..fb3fab4a 100644 --- a/vls_config_test.v +++ b/vls_config_test.v @@ -412,3 +412,79 @@ fn test_config_opening_a_file_leaves_the_defines_of_a_check_alone() { assert app.check_defines(source) == ['-d', 'bespin'], 'an open reads the same configuration a check does' assert app.inlay_hints_enabled, 'a file that sets only defines leaves the switches alone' } + +fn test_config_the_coverage_directory_comes_from_the_project_file() { + root := config_test_project('coverage') + defer { + os.rmdir_all(root) or {} + } + directory := os.join_path(root, 'vcov') + config_test_write(os.join_path(root, 'vls.json'), '{"coverageDirectory":"${directory.replace('\\', '\\\\')}"}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == directory +} + +fn test_config_the_editor_coverage_directory_wins_over_the_project_file() { + root := config_test_project('coverageeditor') + defer { + os.rmdir_all(root) or {} + } + from_project := os.join_path(root, 'vcov') + from_editor := os.join_path(root, 'vcov-editor') + config_test_write(os.join_path(root, 'vls.json'), + '{"coverageDirectory":"${from_project.replace('\\', '\\\\')}"}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + // The payload is JSON the way an editor sends it, so it is encoded from a + // struct rather than interpolated into a literal: a Windows path holds + // backslashes that a literal would have to escape by hand. + editor_payload := json2.encode(EditorCoverageDirectoryParams{ + settings: EditorCoverageDirectorySettings{ + vls: CoverageDirectorySetting{ + coverage_directory: from_editor + } + } + }) + app.apply_editor_configuration(editor_payload) + assert app.coverage_directory_for_path(source) == from_editor +} + +fn test_config_a_project_without_a_coverage_directory_reports_none() { + root := config_test_project('nocoverage') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == '' +} + +fn test_config_an_empty_coverage_directory_is_not_one() { + root := config_test_project('emptycoverage') + defer { + os.rmdir_all(root) or {} + } + config_test_write(os.join_path(root, 'vls.json'), '{"coverageDirectory":" "}') + source := os.join_path(root, 'main.v') + config_test_write(source, 'module main\n\nfn main() {}\n') + mut app := config_test_app(root) + assert app.coverage_directory_for_path(source) == '' +} + +// EditorCoverageDirectoryParams is the shape an editor's configuration payload +// has for the coverage directory: nested under `settings.vls`, like the switches. +struct EditorCoverageDirectoryParams { + settings EditorCoverageDirectorySettings +} + +struct EditorCoverageDirectorySettings { + vls CoverageDirectorySetting +} + +struct CoverageDirectorySetting { + coverage_directory ?string @[json: 'coverageDirectory'] +} From a8fb888bf0c3fb188977151c20d534f565159b15 Mon Sep 17 00:00:00 2001 From: metif12 Date: Wed, 14 Oct 2026 21:50:52 +0330 Subject: [PATCH 72/83] Offer to generate the tests an untested function is missing A coverage recording is what tells VLS which functions a run never entered. With a coverage directory configured, a `.v` file offers one action per such function, plus one for the whole file when there is more than one. The action runs `v cover --write-tests`, which is the one tool that generates the body; the server names the file and reports what the tool wrote, and never composes a test itself. What it writes is a starting point and not a test: a call with zero values and a `// TODO:` line, because the tool cannot know what a function should return. `-no-skip-unused` belongs with the recording and is documented as such. Without it a function nothing calls is dropped from the build, so its coverage cannot be reported at all. No directory configured means no action offered, rather than an action that opens a message: a directory that does not exist gives the same answer, and the report it produces is read through a decodable struct so a newer report cannot break an older server. --- README.md | 24 ++++- handlers.v | 74 ++++++++++---- uncovered_tests.v | 159 +++++++++++++++++++++++++++++ uncovered_tests_test.v | 224 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 uncovered_tests.v create mode 100644 uncovered_tests_test.v diff --git a/README.md b/README.md index a0e12d4e..8fd76110 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ in the editor, put a `vls.json` in the folder that holds the nearest `v.mod`: { "defines": ["-d", "flag"] } ``` -A `vls.json` may also set `inlayHints` and `diagnostics`, the two feature switches. The settings +A `vls.json` may also set `inlayHints`, `diagnostics` and `coverageDirectory`. The settings the editor sends win over the project's file, and the file over `VLS_DEFINES` in the environment, which also works for an editor with no settings of its own. The nearest `vls.json` is read when a file of its project is opened, so its switches apply before the first check rather than after it. @@ -100,6 +100,28 @@ The keys the file accepts are listed in [`vls.schema.json`](vls.schema.json). Th the checks only: `v fmt`, hover, go-to-definition and completion run without them. The code inside `$if !flag ? { ... }` is then the one left unchecked. +### Generating tests for untested functions + +A coverage recording is what tells VLS which functions a test run never entered. Record one, then +point the project at it: + +```sh +v -no-skip-unused -coverage vcov/ test ./ +``` + +```json +{ "coverageDirectory": "vcov" } +``` + +`coverageDirectory` may also be sent by the editor as `vls.coverageDirectory`, which wins over the +file. With it set, a `.v` file whose functions were never entered offers a code action per +function: `Generate test for 'foo'`. The action runs `v cover --write-tests`, which appends one +`test_foo()` beside the source with a zero-value call and a `// TODO:` line. It is a starting +point, not a test: the generator cannot know what the function should return. + +`-no-skip-unused` belongs with the recording. Without it a function nothing calls is dropped from +the build, so its coverage cannot be reported at all. + ### Features #### Instant errors diff --git a/handlers.v b/handlers.v index f9f98370..29c8ac54 100644 --- a/handlers.v +++ b/handlers.v @@ -8388,6 +8388,13 @@ fn (mut app App) handle_code_action(request Request) Response { } } + // 10. Generate test for an untested function — reads the coverage run the + // project's `vls.json` or its editor settings point at. + if code_action_kind_wanted(only, code_action_kind_quickfix) { + source_path := normalize_overlay_path(os.abs_path(uri_to_path(uri))) + actions << app.build_untested_test_actions(uri, source_path, params.range) + } + return Response{ id: request.id result: actions @@ -8946,14 +8953,16 @@ fn (mut app App) on_did_change_configuration(request Request) { struct ResolvedWorkspaceSettings { mut: - inlay_hints ?bool - diagnostics ?bool - variable_types ?bool - parameter_names ?bool - has_inlay_hints bool - has_diagnostics bool - has_variable_types bool - has_parameter_names bool + inlay_hints ?bool + diagnostics ?bool + variable_types ?bool + parameter_names ?bool + coverage_directory ?string + has_inlay_hints bool + has_diagnostics bool + has_variable_types bool + has_parameter_names bool + has_coverage_directory bool } fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { @@ -8962,51 +8971,55 @@ fn resolve_workspace_settings(params_json string) ResolvedWorkspaceSettings { sectioned_flat := json2.decode[DidChangeConfigurationParams](params_json) or { DidChangeConfigurationParams{} } - merge_workspace_settings(mut resolved, sectioned_flat.settings.vls.inlay_hints, sectioned_flat.settings.vls.diagnostics) + merge_workspace_settings(mut resolved, sectioned_flat.settings.vls.inlay_hints, sectioned_flat.settings.vls.diagnostics, sectioned_flat.settings.vls.coverage_directory) sectioned_inlay_nested := json2.decode[DidChangeConfigurationParamsCompat](params_json) or { DidChangeConfigurationParamsCompat{} } - merge_workspace_settings(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints.enabled, sectioned_inlay_nested.settings.vls.diagnostics) + merge_workspace_settings(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints.enabled, sectioned_inlay_nested.settings.vls.diagnostics, sectioned_inlay_nested.settings.vls.coverage_directory) merge_inlay_hint_options(mut resolved, sectioned_inlay_nested.settings.vls.inlay_hints) sectioned_diagnostics_nested := json2.decode[DidChangeConfigurationParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationParamsNestedDiagnosticsCompat{} } - merge_workspace_settings(mut resolved, sectioned_diagnostics_nested.settings.vls.inlay_hints, sectioned_diagnostics_nested.settings.vls.diagnostics.enabled) + merge_workspace_settings(mut resolved, sectioned_diagnostics_nested.settings.vls.inlay_hints, sectioned_diagnostics_nested.settings.vls.diagnostics.enabled, sectioned_diagnostics_nested.settings.vls.coverage_directory) sectioned_nested := json2.decode[DidChangeConfigurationParamsNestedFeaturesCompat](params_json) or { DidChangeConfigurationParamsNestedFeaturesCompat{} } - merge_workspace_settings(mut resolved, sectioned_nested.settings.vls.inlay_hints.enabled, sectioned_nested.settings.vls.diagnostics.enabled) + merge_workspace_settings(mut resolved, sectioned_nested.settings.vls.inlay_hints.enabled, sectioned_nested.settings.vls.diagnostics.enabled, sectioned_nested.settings.vls.coverage_directory) merge_inlay_hint_options(mut resolved, sectioned_nested.settings.vls.inlay_hints) direct_flat := json2.decode[DidChangeConfigurationDirectParams](params_json) or { DidChangeConfigurationDirectParams{} } - merge_workspace_settings(mut resolved, direct_flat.settings.inlay_hints, direct_flat.settings.diagnostics) + merge_workspace_settings(mut resolved, direct_flat.settings.inlay_hints, direct_flat.settings.diagnostics, direct_flat.settings.coverage_directory) direct_inlay_nested := json2.decode[DidChangeConfigurationDirectParamsCompat](params_json) or { DidChangeConfigurationDirectParamsCompat{} } - merge_workspace_settings(mut resolved, direct_inlay_nested.settings.inlay_hints.enabled, direct_inlay_nested.settings.diagnostics) + merge_workspace_settings(mut resolved, direct_inlay_nested.settings.inlay_hints.enabled, direct_inlay_nested.settings.diagnostics, direct_inlay_nested.settings.coverage_directory) merge_inlay_hint_options(mut resolved, direct_inlay_nested.settings.inlay_hints) direct_diagnostics_nested := json2.decode[DidChangeConfigurationDirectParamsNestedDiagnosticsCompat](params_json) or { DidChangeConfigurationDirectParamsNestedDiagnosticsCompat{} } - merge_workspace_settings(mut resolved, direct_diagnostics_nested.settings.inlay_hints, direct_diagnostics_nested.settings.diagnostics.enabled) + merge_workspace_settings(mut resolved, direct_diagnostics_nested.settings.inlay_hints, direct_diagnostics_nested.settings.diagnostics.enabled, direct_diagnostics_nested.settings.coverage_directory) direct_nested := json2.decode[DidChangeConfigurationDirectParamsNestedFeaturesCompat](params_json) or { DidChangeConfigurationDirectParamsNestedFeaturesCompat{} } - merge_workspace_settings(mut resolved, direct_nested.settings.inlay_hints.enabled, direct_nested.settings.diagnostics.enabled) + merge_workspace_settings(mut resolved, direct_nested.settings.inlay_hints.enabled, direct_nested.settings.diagnostics.enabled, direct_nested.settings.coverage_directory) merge_inlay_hint_options(mut resolved, direct_nested.settings.inlay_hints) return resolved } +// merge_workspace_settings folds the switched settings of one decoded settings +// shape into the resolved settings. First-wins: an absent key keeps whatever an +// earlier shape already set, and unknown keys never reach it because the decoder +// drops them. fn merge_workspace_settings(mut resolved ResolvedWorkspaceSettings, inlay_hints ?bool, - diagnostics ?bool) { + diagnostics ?bool, coverage_directory ?string) { if !resolved.has_inlay_hints { if enabled := inlay_hints { resolved.inlay_hints = enabled @@ -9019,6 +9032,12 @@ fn merge_workspace_settings(mut resolved ResolvedWorkspaceSettings, inlay_hints resolved.has_diagnostics = true } } + if !resolved.has_coverage_directory { + if directory := coverage_directory { + resolved.coverage_directory = directory + resolved.has_coverage_directory = true + } + } } // merge_inlay_hint_options folds the granular inlay-hint switches of one @@ -9471,6 +9490,27 @@ fn (mut app App) handle_execute_command(request Request) Response { } } } + 'vls.generateTest' { + // The test body is written by `v cover --write-tests`, which is the one + // tool that generates it. The server names the file and reports what + // the tool wrote; the editor then reflects the file back to itself. + args := params.arguments or { [] } + uri, path, path_error := app.code_lens_command_target(args) + if path_error != '' { + app.send_show_message('vls: cannot generate a test: ${path_error}', 1) + } else if !compiler_is_available() { + app.send_show_message('vls: the V compiler (`v`) was not found on PATH.', 1) + } else { + directory := app.coverage_directory_for_path(path) + if directory == '' { + app.send_show_message(coverage_missing_message(), 1) + } else if written := write_tests_for_file(directory, path) { + app.send_show_message('vls: ${written.trim_space()}', 3) + } else { + app.send_show_message('vls: could not write the tests: ${err.msg()}', 1) + } + } + } else { app.send_show_message('vls: unknown command ${params.command}', 2) } diff --git a/uncovered_tests.v b/uncovered_tests.v new file mode 100644 index 00000000..36c3b211 --- /dev/null +++ b/uncovered_tests.v @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +// One function of a source file, as `v cover --json` reports it. +struct CoverageFunction { + name string + line int + covered bool + hits u64 +} + +// One file of a `v cover --json` report. +struct CoverageJsonFile { + file string + functions []CoverageFunction + function_points int + function_covered int +} + +// The whole `v cover --json` report. Only the fields a code action needs are +// declared, so a report written by a newer compiler decodes instead of failing. +struct CoverageJsonReport { + files []CoverageJsonFile +} + +// UntestedFunction is a function no run entered, with where it is declared. +struct UntestedFunction { + name string + line int +} + +// read_untested_functions asks `v cover` which functions a coverage run never +// entered, for one source file. +// +// The reading is one invocation of the tool over a directory that already holds +// a run's counters, so it compiles nothing. `path` is the source file to answer +// for, and `directory` the coverage directory the report is read from. +fn read_untested_functions(directory string, path string) []UntestedFunction { + if directory == '' || !os.is_dir(directory) { + return [] + } + report_path := os.join_path(os.temp_dir(), 'vls_cover_${os.getpid()}.json') + defer { + os.rm(report_path) or {} + } + // The report is written for absolute paths, which is how the compiler spells + // a source file, so the path is asked for unchanged. + ran := run_v_argv(['cover', directory, '--json', report_path, '-P', 'false', '-A'], + os.dir(path)) + if ran.exit_code != 0 { + return [] + } + raw := os.read_file(report_path) or { return [] } + report := json2.decode[CoverageJsonReport](raw) or { return [] } + return untested_functions_of(report, path) +} + +// untested_functions_of returns the functions of `path` that no run entered, +// in source order. `path` is the file the report was written for, spelled the +// way the compiler spelled it, so the caller passes it unchanged. +fn untested_functions_of(report CoverageJsonReport, path string) []UntestedFunction { + mut target := path.replace('\\', '/') + mut functions := []UntestedFunction{} + for file in report.files { + if file.file.replace('\\', '/') != target { + continue + } + for function in file.functions { + if function.covered { + continue + } + functions << UntestedFunction{ + name: function.name + line: function.line - 1 + } + } + } + functions.sort(a.line < b.line) + return functions +} + +// coverage_missing_message is what a user is told when a test-generation +// action cannot run. Both cases are the same fix, so they read as one. +fn coverage_missing_message() string { + return 'vls: no coverage run is recorded for this project. Run the tests with a ' + + 'coverage directory, then set `vls.coverageDirectory` (or `coverageDirectory` in vls.json) to it.' +} + +// build_untested_test_actions offers one action per function the coverage run +// never entered, plus one for the whole file. +// +// The actions carry a command rather than an edit: the test body is written by +// `v cover --write-tests`, which is the one tool that generates it, so the +// server only points at the function. `path` is the source file being edited and +// `range` the selection, so an action inside one function offers only that one. +fn (mut app App) build_untested_test_actions(uri string, path string, range LSPRange) []CodeAction { + directory := app.coverage_directory_for_path(path) + if directory == '' { + return [] + } + functions := read_untested_functions(directory, path) + if functions.len == 0 { + return [] + } + mut selected := []UntestedFunction{} + for function in functions { + if range_contains_line(range, function.line) { + selected << function + } + } + mut actions := []CodeAction{} + for function in selected { + actions << CodeAction{ + title: "Generate test for '${function.name}'" + kind: code_action_kind_quickfix + command: Command{ + title: 'Generate test' + command: 'vls.generateTest' + arguments: [uri] + } + } + } + // One function is its own action, so a second, file-wide action is only + // offered when there is more than one to write. + if selected.len > 1 { + actions << CodeAction{ + title: 'Generate ${selected.len} untested test functions' + kind: code_action_kind_quickfix + command: Command{ + title: 'Generate tests' + command: 'vls.generateTest' + arguments: [uri] + } + } + } + return actions +} + +// range_contains_line reports whether a range covers a line, which is how a +// declaration is selected: the cursor anywhere on it selects it. +fn range_contains_line(range LSPRange, line int) bool { + return line >= range.start.line && line <= range.end.line +} + +// write_tests_for_file asks `v cover` to write the test functions `path` is +// missing, and reports what it wrote. The tool writes beside the source, so the +// directory it needs is the one the coverage run recorded into. +fn write_tests_for_file(directory string, path string) !string { + ran := run_v_argv(['cover', directory, '--write-tests', '--filter', path, '-P', 'false'], + os.dir(path)) + if ran.exit_code != 0 { + return error('v cover failed: ${ran.output}') + } + return ran.output +} diff --git a/uncovered_tests_test.v b/uncovered_tests_test.v new file mode 100644 index 00000000..9dc22a7b --- /dev/null +++ b/uncovered_tests_test.v @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import json2 +import os + +const coverage_report_json = '{ + "files": [ + { + "file": "/w/app/parser.v", + "function_points": 3, + "function_covered": 1, + "functions": [ + {"name": "parse_text", "line": 3, "covered": true, "hits": 12}, + {"name": "parse_heading", "line": 14, "covered": false, "hits": 0}, + {"name": "parse_list", "line": 31, "covered": false, "hits": 0} + ] + }, + { + "file": "/w/app/other.v", + "function_points": 1, + "function_covered": 1, + "functions": [ + {"name": "other", "line": 1, "covered": true, "hits": 4} + ] + } + ] +}' + +fn test_untested_functions_of_reads_only_the_uncovered_ones_of_one_file() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + functions := untested_functions_of(report, '/w/app/parser.v') + assert functions.len == 2 + assert functions[0].name == 'parse_heading' + // The line is what an editor addresses, so it is one less than the source's. + assert functions[0].line == 13 + assert functions[1].name == 'parse_list' + assert functions[1].line == 30 +} + +fn test_untested_functions_of_reports_nothing_for_a_file_with_no_gaps() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + assert untested_functions_of(report, '/w/app/other.v') == [] +} + +fn test_untested_functions_of_reports_nothing_for_a_file_the_report_lacks() { + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + assert untested_functions_of(report, '/w/app/missing.v') == [] +} + +fn test_untested_functions_of_matches_a_path_spelled_with_backslashes() { + // The compiler spells a Windows path with backslashes, and the report is + // read from the file the editor named, so both spellings must land on it. + report := json2.decode[CoverageJsonReport](coverage_report_json) or { + assert false, 'the fixture report must decode' + return + } + functions := untested_functions_of(report, '\\w\\app\\parser.v') + assert functions.len == 2 + assert functions[0].name == 'parse_heading' +} + +fn test_coverage_report_survives_an_unknown_field() { + // A report written by a newer compiler carries keys this build does not + // know. They are dropped rather than failing the decode, because a coverage + // report is an extra and never a reason a code action breaks. + raw := '{"files":[{"file":"/w/app/a.v","functions":[],"branch_points":4}]}' + report := json2.decode[CoverageJsonReport](raw) or { + assert false, 'an unknown field must not fail the decode: ${err}' + return + } + assert report.files.len == 1 + assert report.files[0].file == '/w/app/a.v' +} + +fn test_read_untested_functions_reports_nothing_without_a_directory() { + // No coverage directory configured means no action, and never a crash. + assert read_untested_functions('', '/w/app/parser.v') == [] + assert read_untested_functions(os.join_path(os.temp_dir(), 'vls_no_such_cov_dir'), + '/w/app/parser.v') == [] +} + +fn test_range_contains_line_selects_the_declaration_line() { + declared := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 13, char: 3 } + } + assert range_contains_line(declared, 13) + assert !range_contains_line(declared, 12) + assert !range_contains_line(declared, 14) + // A selection spanning several lines selects every one of them. + block := LSPRange{ + start: Position{ line: 12, char: 4 } + end: Position{ line: 15, char: 0 } + } + assert range_contains_line(block, 13) + assert range_contains_line(block, 14) +} + +fn test_coverage_missing_message_names_the_settings_key() { + message := coverage_missing_message() + assert message.contains('vls.coverageDirectory') + assert message.contains('vls.json') +} + +// cov_test_app returns an App of its own with one workspace root and no +// configuration inherited from elsewhere. Every _test.v file of this program is +// `module main`, but a test runner compiles each file on its own, so a helper +// defined in another file is not visible here. +fn cov_test_app(root string) &App { + mut store := vls_config_store() + store.set_editor_settings(EditorSettings{}) + store.drop_config(root) + return &App{ + temp_dir: root + open_files: map[string]string{} + workspace_roots: [root] + } +} + +fn cov_test_project(tag string) string { + root := os.join_path(os.temp_dir(), 'vls_cov_${tag}_${os.getpid()}') + os.mkdir_all(root) or { + assert false, 'cannot make ${root}' + return '' + } + os.write_file(os.join_path(root, 'v.mod'), "Module {\n\tname: 'covtest'\n}\n") or { + assert false, 'cannot write v.mod' + } + return root +} + +// build_untested_test_actions offers nothing without a coverage directory: the +// action reads a run that has not happened, and offering it anyway would only +// produce a message. +fn test_no_test_action_without_a_coverage_directory() { + root := cov_test_project('absent') + defer { + os.rmdir_all(root) or {} + } + source := os.join_path(root, 'main.v') + os.write_file(source, 'module main\n\nfn main() {}\n') or { + assert false, 'cannot write ${source}' + } + app := cov_test_app(root) + actions := app.build_untested_test_actions('file:///tmp/x.v', source, LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 0, char: 0 } + }) + assert actions.len == 0, 'no coverage directory means no action, got ${actions.len}' +} + +// With a directory that holds no report, the tool reports nothing, so no action +// is offered. A directory that does not exist gives the same answer. +fn test_no_test_action_for_a_directory_without_a_report() { + root := cov_test_project('noreport') + defer { + os.rmdir_all(root) or {} + } + coverage := os.join_path(root, 'vcov') + os.mkdir_all(coverage) or { assert false, 'cannot make ${coverage}' } + source := os.join_path(root, 'main.v') + os.write_file(source, 'module main\n\nfn main() {}\n') or { + assert false, 'cannot write ${source}' + } + mut app := cov_test_app(root) + app.apply_editor_configuration('{"settings":{"vls":{"coverageDirectory":"' + coverage.replace('\\', + '\\\\') + '"}}}') + assert app.coverage_directory_for_path(source) == coverage, 'the configured directory reaches the action' + actions := app.build_untested_test_actions('file:///tmp/x.v', source, LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 0, char: 0 } + }) + assert actions.len == 0, 'a directory without a report offers no action, got ${actions.len}' +} + +// An action is offered for a function on the line the cursor is on, and for no +// other, so the lightbulb answers the question the cursor asked. +fn test_an_action_covers_the_line_the_cursor_is_on() { + report := CoverageJsonReport{ + files: [ + CoverageJsonFile{ + file: '/w/app/parser.v' + functions: [ + CoverageFunction{ + name: 'parse_heading' + line: 14 + covered: false + }, + CoverageFunction{ + name: 'parse_list' + line: 31 + covered: false + }, + ] + }, + ] + } + functions := untested_functions_of(report, '/w/app/parser.v') + assert functions.len == 2 + on_first := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 13, char: 3 } + } + assert range_contains_line(on_first, functions[0].line) + assert !range_contains_line(on_first, functions[1].line) + // A selection spanning both offers both. + spanning := LSPRange{ + start: Position{ line: 13, char: 0 } + end: Position{ line: 31, char: 0 } + } + assert range_contains_line(spanning, functions[0].line) + assert range_contains_line(spanning, functions[1].line) +} From 6ec76d6bb6bf8ea3bc3dd4b123c10bdb9cacabf0 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 15 Oct 2026 11:08:26 +0330 Subject: [PATCH 73/83] fix: hover answers without rushing the pending slow check hover_result flushed the diagnostics debounce on every request, starting a full compile concurrently with the compiler work hover itself runs. None of hover's reads depend on the flushed state (v3 asks with buffer content, the index reads as it stands, the one-shot writes the buffer to its own overlay), so the flush bought nothing for the answer and only contended with it. The debounced slow check still publishes on its own afterwards. --- handlers.v | 11 ++++++--- handlers_test.v | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/handlers.v b/handlers.v index 29c8ac54..897e0e24 100644 --- a/handlers.v +++ b/handlers.v @@ -422,9 +422,14 @@ fn (mut app App) hover_with_written_declaration(uri string, position Position, r // documentation of its own declaration. `line_info` asks the hover. fn (mut app App) hover_result(uri string, position Position, line_info string) ResponseResult { real_path := uri_to_path(uri) - // A read flushes the debounce: the slow check runs now, so the overlay this - // hover is read against is the one the pause was going to publish anyway. - app.rush_pending_diagnostics(uri) + // No flush here: every read below answers from the buffer or the index, + // never from the overlay a pending slow check would publish. v3_hover + // asks with the buffer's own content, the indexed lookups read the index + // as it stands, and the one-shot line-info writes the buffer to its own + // overlay. Flushing would only start a full compile concurrently with the + // one these reads may already run, and the debounced slow check still + // publishes on its own afterwards. (run_v_line_info keeps its flush: the + // definition family can read what only a checked program says.) mut located := false if answer := app.v3_hover(uri, real_path, line_info) { if location := answer.declared_at { diff --git a/handlers_test.v b/handlers_test.v index d55c6680..51b2c25c 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -7314,6 +7314,65 @@ fn test_hover_prefers_shadowing_closure_parameter_type() { assert !hover.contents.value.contains('x int'), hover.contents.value } +fn test_hover_does_not_rush_the_pending_slow_check() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + test_dir := os.join_path(app.temp_dir, 'hover_no_rush') + must_mkdir_all(test_dir) + // The file carries a diagnostics error the slow check has not published + // yet; the hover below must answer without running it. + content := 'module main\n\nfn greet(name string) string {\n\treturn name\n}\n\nfn main() {\n\tprintln(greet(undefined_name))\n}\n' + main_file := os.join_path(test_dir, 'main.v') + must_write_file(main_file, content) + uri := path_to_uri(main_file) + app.open_files[uri] = content + // No compiler in this test: the hover below answers through the + // compiler-free tail of hover_result, so the scheduler state it leaves + // behind is the whole observation. + app.v3_line_info_enabled = false + app.line_info_mode = .missing + mut scheduler := new_diagnostics_scheduler() + app.diagnostics_scheduler = scheduler + far_future := time.now().unix_milli() + 3600_000 + global_generation, generation := scheduler.next_generation(uri) + scheduler.enqueue(DiagnosticsJob{ + uri: uri + content: content + project_key: 'file:///hover_no_rush_project' + global_generation: global_generation + generation: generation + write_mutex: app.write_mutex + ready_at: far_future + kind: .slow + }) + key := diagnostics_job_key(uri, .slow) + scheduler.mutex.lock() + before := scheduler.pending_jobs[key] or { + scheduler.mutex.unlock() + assert false, 'the slow job was never pending' + return + } + ready_before := before.ready_at + scheduler.mutex.unlock() + // Hover over the `greet(` call. The index has no entry for it here, so + // the request runs the compiler-free tail of hover_result. + _ := app.hover_result(uri, Position{ + line: 6 + char: 9 + }, '7:hv^9') + scheduler.mutex.lock() + after := scheduler.pending_jobs[key] or { + scheduler.mutex.unlock() + assert false, 'the slow job vanished' + return + } + ready_after := after.ready_at + scheduler.mutex.unlock() + assert ready_after == ready_before, 'hover moved the slow job deadline' +} + fn test_hover_does_not_treat_member_selector_as_local_binding() { mut app := create_test_app() defer { From a781be25940312bb48610d1df7a86529ac5b17ab Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 15 Oct 2026 11:33:18 +0330 Subject: [PATCH 74/83] fix: accept workspace previousResultIds as an LSP array --- lsp.v | 12 ++++++++++-- pull_diagnostics_test.v | 29 ++++++++++++++++++++++++++++- workspace_diagnostic.v | 7 ++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lsp.v b/lsp.v index 14c9e940..392a13d9 100644 --- a/lsp.v +++ b/lsp.v @@ -220,12 +220,20 @@ struct UnchangedDocumentDiagnosticReport { type DocumentDiagnosticReport = FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport +// PreviousResultId is one held workspace result: the document it describes and +// the id the client already holds. LSP sends these as an array, including an +// empty array when the client holds nothing. +struct PreviousResultId { + uri string + value string +} + // WorkspaceDiagnosticParams is textDocument/diagnostic's whole-workspace -// sibling: the identifiers whose documents the client wants, and the id it +// sibling: the identifiers whose documents the client wants, and the ids it // already holds for each, so an unchanged document is answered with nothing. struct WorkspaceDiagnosticParams { identifier string - previous_result_ids map[string]string @[json: 'previousResultIds'] + previous_result_ids []PreviousResultId @[json: 'previousResultIds'] } // WorkspaceDocumentReport is one document's answer inside a workspace report. diff --git a/pull_diagnostics_test.v b/pull_diagnostics_test.v index af6ed835..db9c1f2c 100644 --- a/pull_diagnostics_test.v +++ b/pull_diagnostics_test.v @@ -290,12 +290,20 @@ fn pd_ws_seed(mut app App, tag string, text string) (string, string) { } // pd_ws_pull asks for the whole workspace, echoing the ids the caller holds. +// The caller's map is the convenient test shape; the wire shape is an array. fn pd_ws_pull(mut app App, previous_result_ids map[string]string) Response { + mut previous := []PreviousResultId{} + for uri, value in previous_result_ids { + previous << PreviousResultId{ + uri: uri + value: value + } + } return app.handle_workspace_diagnostic(Request{ id: 9 method: 'workspace/diagnostic' params: json2.encode(WorkspaceDiagnosticParams{ - previous_result_ids: previous_result_ids + previous_result_ids: previous }) }) } @@ -305,6 +313,25 @@ fn pd_ws_reports(response Response) []WorkspaceDiagnosticReport { return response.result as []WorkspaceDiagnosticReport } +// A client with no held results sends an empty array, not an empty object. +// That empty array must decode and answer a full report. +fn test_ws_diagnostic_accepts_empty_previous_result_ids_array() { + mut app := App{} + dir, uri := pd_ws_seed(mut app, 'ws_empty_previous', pd_main_source) + defer { + pd_cleanup(dir) + } + response := app.handle_workspace_diagnostic(Request{ + id: 9 + method: 'workspace/diagnostic' + params: '{"identifier":"","previousResultIds":[]}' + }) + reports := pd_ws_reports(response) + assert reports.len == 1, 'expected one report, got ${reports.len}' + assert reports[0].uri == uri, reports[0].uri + assert reports[0].kind == 'full', 'empty previous results mean a full report' +} + // pd_ws_ids is the id a workspace pull computes for one document, so a test can // echo it back. fn pd_ws_ids(mut app App, uris []string) map[string]string { diff --git a/workspace_diagnostic.v b/workspace_diagnostic.v index 5302b817..54899aad 100644 --- a/workspace_diagnostic.v +++ b/workspace_diagnostic.v @@ -20,7 +20,12 @@ fn (mut app App) handle_workspace_diagnostic(request Request) Response { } } uris := app.uris_with_diagnostics() - mut reports, dropped := app.workspace_diagnostic_reports(uris, params.previous_result_ids) + // The wire shape is an array; the lookup below stays a map. + mut previous_ids := map[string]string{} + for held in params.previous_result_ids { + previous_ids[held.uri] = held.value + } + mut reports, dropped := app.workspace_diagnostic_reports(uris, previous_ids) if dropped > 0 { // The truncation is a report of its own rather than a silent gap: a // client that sees it knows the pane is partial. From b12395891f1516c132ec2239759ea4e2373a3903 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 15 Oct 2026 11:33:18 +0330 Subject: [PATCH 75/83] fix: accept workspace previousResultIds as an LSP array --- lsp.v | 12 ++++++++++-- pull_diagnostics_test.v | 29 ++++++++++++++++++++++++++++- workspace_diagnostic.v | 7 ++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lsp.v b/lsp.v index 14c9e940..392a13d9 100644 --- a/lsp.v +++ b/lsp.v @@ -220,12 +220,20 @@ struct UnchangedDocumentDiagnosticReport { type DocumentDiagnosticReport = FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport +// PreviousResultId is one held workspace result: the document it describes and +// the id the client already holds. LSP sends these as an array, including an +// empty array when the client holds nothing. +struct PreviousResultId { + uri string + value string +} + // WorkspaceDiagnosticParams is textDocument/diagnostic's whole-workspace -// sibling: the identifiers whose documents the client wants, and the id it +// sibling: the identifiers whose documents the client wants, and the ids it // already holds for each, so an unchanged document is answered with nothing. struct WorkspaceDiagnosticParams { identifier string - previous_result_ids map[string]string @[json: 'previousResultIds'] + previous_result_ids []PreviousResultId @[json: 'previousResultIds'] } // WorkspaceDocumentReport is one document's answer inside a workspace report. diff --git a/pull_diagnostics_test.v b/pull_diagnostics_test.v index af6ed835..db9c1f2c 100644 --- a/pull_diagnostics_test.v +++ b/pull_diagnostics_test.v @@ -290,12 +290,20 @@ fn pd_ws_seed(mut app App, tag string, text string) (string, string) { } // pd_ws_pull asks for the whole workspace, echoing the ids the caller holds. +// The caller's map is the convenient test shape; the wire shape is an array. fn pd_ws_pull(mut app App, previous_result_ids map[string]string) Response { + mut previous := []PreviousResultId{} + for uri, value in previous_result_ids { + previous << PreviousResultId{ + uri: uri + value: value + } + } return app.handle_workspace_diagnostic(Request{ id: 9 method: 'workspace/diagnostic' params: json2.encode(WorkspaceDiagnosticParams{ - previous_result_ids: previous_result_ids + previous_result_ids: previous }) }) } @@ -305,6 +313,25 @@ fn pd_ws_reports(response Response) []WorkspaceDiagnosticReport { return response.result as []WorkspaceDiagnosticReport } +// A client with no held results sends an empty array, not an empty object. +// That empty array must decode and answer a full report. +fn test_ws_diagnostic_accepts_empty_previous_result_ids_array() { + mut app := App{} + dir, uri := pd_ws_seed(mut app, 'ws_empty_previous', pd_main_source) + defer { + pd_cleanup(dir) + } + response := app.handle_workspace_diagnostic(Request{ + id: 9 + method: 'workspace/diagnostic' + params: '{"identifier":"","previousResultIds":[]}' + }) + reports := pd_ws_reports(response) + assert reports.len == 1, 'expected one report, got ${reports.len}' + assert reports[0].uri == uri, reports[0].uri + assert reports[0].kind == 'full', 'empty previous results mean a full report' +} + // pd_ws_ids is the id a workspace pull computes for one document, so a test can // echo it back. fn pd_ws_ids(mut app App, uris []string) map[string]string { diff --git a/workspace_diagnostic.v b/workspace_diagnostic.v index 5302b817..54899aad 100644 --- a/workspace_diagnostic.v +++ b/workspace_diagnostic.v @@ -20,7 +20,12 @@ fn (mut app App) handle_workspace_diagnostic(request Request) Response { } } uris := app.uris_with_diagnostics() - mut reports, dropped := app.workspace_diagnostic_reports(uris, params.previous_result_ids) + // The wire shape is an array; the lookup below stays a map. + mut previous_ids := map[string]string{} + for held in params.previous_result_ids { + previous_ids[held.uri] = held.value + } + mut reports, dropped := app.workspace_diagnostic_reports(uris, previous_ids) if dropped > 0 { // The truncation is a report of its own rather than a silent gap: a // client that sees it knows the pane is partial. From 83e2683644039753172465509fb6e840f48cee34 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 15 Oct 2026 12:04:16 +0330 Subject: [PATCH 76/83] test: fix self-contradicting initialize fixture to advertise generateTest The fixture builds its own Response with two commands but asserts three; it failed regardless of server behavior. a8fb888 added the command to the live initialize literal without updating this duplicate. --- integration_test.v | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_test.v b/integration_test.v index 121a73b5..5dd18e9e 100644 --- a/integration_test.v +++ b/integration_test.v @@ -239,7 +239,7 @@ fn test_integration_initialize_workspace_capabilities() { result: Capabilities{ capabilities: Capability{ execute_command_provider: ExecuteCommandOptions{ - commands: ['vls.runFile', 'vls.runTests'] + commands: ['vls.runFile', 'vls.runTests', 'vls.generateTest'] } workspace: WorkspaceCapability{ file_operations: WorkspaceFileOperations{ From 5b44ff930570ea400f02765afbcbbb2976d79448 Mon Sep 17 00:00:00 2001 From: metif12 Date: Thu, 15 Oct 2026 17:17:07 +0330 Subject: [PATCH 77/83] docs: point at v ls --install as the supported install path --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8fd76110..23c14ae7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Build with: `v .` +Or let the compiler install it for you: `v ls --install` (see `v help ls` +for update and source-build options). + Check the installed version with `vls --version` or `vls version`. Both print `VLS 0.0.3` and exit without starting the server. LSP `initialize` reports the same version in `serverInfo`. From 37b5bcb5a2f31850491c1bc4a62de2caca0e96a9 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 17 Oct 2026 12:07:44 +0330 Subject: [PATCH 78/83] test: give stop() a ceiling a loaded runner can meet `stop()` writes `quit`, waits a second in 10ms steps, then kills the server, so it returns in about a second. The test allowed five, and the macOS job failed it twice: the assertion is a deadline, not a mechanism, and a runner compiling the other test files stretched that second of sleeping past it. The same tree passes this test on ubuntu, and the macOS job on this branch fails on it with nothing else changed, so this is the runner, not stop(). Thirty seconds is a ceiling on a freeze, which is what the test is for: a `stop()` that really waits for the server never returns, so the tighter number was only ever measuring how busy the machine was. --- v3_line_info_test.v | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/v3_line_info_test.v b/v3_line_info_test.v index a25affae..1865bd6e 100644 --- a/v3_line_info_test.v +++ b/v3_line_info_test.v @@ -839,7 +839,13 @@ fn test_a_server_that_does_not_end_is_stopped_anyway() { select { _ := <-done { } - 5 * time.second { + // `stop()` gives the server a second to end on its own and then kills + // it, so it returns in about a second. The budget below is not that + // second but a ceiling on a freeze: a loaded runner can stretch a + // second of sleeping well past five, which is what the macOS job hit + // twice. A `stop()` that really waits never returns, so a generous + // ceiling still catches it. + 30 * time.second { assert false, 'stop() waited for a server that does not end' } } From 197437ba9e57a0ae0c4484fa4f386dc414cae0f3 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 23 Oct 2026 17:42:51 +0330 Subject: [PATCH 79/83] vls: share worker diag cache with pull diagnostics (P1) --- diagnostics_scheduler.v | 34 ++++++++++++++++++++++++++++++++++ pull_diagnostics.v | 6 ++++++ 2 files changed, 40 insertions(+) diff --git a/diagnostics_scheduler.v b/diagnostics_scheduler.v index f9988e92..2b24dee0 100644 --- a/diagnostics_scheduler.v +++ b/diagnostics_scheduler.v @@ -74,6 +74,11 @@ mut: worker_started bool worker_done chan bool stopped bool + // Shared diagnostics cache populated by worker checks, so pull requests + // (handle_pull_diagnostic) can serve the errors a check published even + // though they run on the main App, whose own diag_cache the worker never + // touches (P1: pull diagnostics never receive async results). + diag_cache map[string]DiagCacheEntry active_uri string active_project_key string active_generation u64 @@ -95,6 +100,7 @@ fn new_diagnostics_scheduler() &DiagnosticsScheduler { generations: map[string]u64{} project_generations: map[string]u64{} pending_jobs: map[string]DiagnosticsJob{} + diag_cache: map[string]DiagCacheEntry{} } } @@ -305,6 +311,30 @@ fn (mut scheduler DiagnosticsScheduler) finish(job DiagnosticsJob) { scheduler.mutex.unlock() } +// store_diag_cache_entries merges a worker's cached check results into the +// scheduler's shared cache, so pull diagnostics can serve them. +fn (mut scheduler DiagnosticsScheduler) store_diag_cache_entries(entries map[string]DiagCacheEntry) { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + for uri, entry in entries { + scheduler.diag_cache[uri] = entry + } +} + +// diag_cache_entry returns the cached errors for `uri` from the shared cache. +fn (mut scheduler DiagnosticsScheduler) diag_cache_entry(uri string) ?DiagCacheEntry { + scheduler.mutex.lock() + defer { + scheduler.mutex.unlock() + } + if entry := scheduler.diag_cache[uri] { + return entry + } + return none +} + // check_in_flight reports whether a check for the document at `uri` waits for // its debounce deadline or already runs. Either holds the compilers of the // buffers it checks, so a question about the same files asked alongside it is @@ -578,6 +608,10 @@ fn run_diagnostics_job(mut scheduler DiagnosticsScheduler, job DiagnosticsJob) { } } notification := worker.build_diagnostics_notification(job.uri, job.content) + // Publish the worker's cached check results into the shared cache, so + // pull diagnostics (handle_pull_diagnostic) can serve them: the worker + // runs in a cloned App whose own diag_cache would otherwise be discarded. + scheduler.store_diag_cache_entries(worker.diag_cache) if os.getenv('VLS_PERF_LOG') != '' { diag_elapsed_ms := time.now().unix_milli() - started_ms worker.send_log_message('diagnostics uri=${job.uri} kind=slow elapsed_ms=${diag_elapsed_ms}', diff --git a/pull_diagnostics.v b/pull_diagnostics.v index 473ded83..f7916a20 100644 --- a/pull_diagnostics.v +++ b/pull_diagnostics.v @@ -51,6 +51,12 @@ fn (mut app App) pull_diagnostics_items(uri string) []LSPDiagnostic { mut v_errors := []JsonError{} if cached := app.diag_cache[uri] { v_errors = cached.errors + } else if mut scheduler := app.diagnostics_scheduler { + // Fall back to the shared cache populated by worker checks, since the + // main App's own diag_cache is never touched by the worker (P1). + if cached := scheduler.diag_cache_entry(uri) { + v_errors = cached.errors + } } content := app.open_files[uri] or { os.read_file(uri_to_path(uri)) or { '' } } return app.diagnostics_notification_for(uri, content, v_errors).params.diagnostics From a577f52c8071599f22ffa385ef4b09d6d13480b7 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 23 Oct 2026 17:44:37 +0330 Subject: [PATCH 80/83] vls: clear stale workspace diagnostics by reporting clean cached docs (P1) --- workspace_diagnostic.v | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/workspace_diagnostic.v b/workspace_diagnostic.v index 54899aad..37931501 100644 --- a/workspace_diagnostic.v +++ b/workspace_diagnostic.v @@ -66,17 +66,34 @@ const workspace_diagnostic_truncated_uri = 'vls.workspace.diagnosticTruncated' const workspace_diagnostic_max_documents = 500 // uris_with_diagnostics lists the documents a check has answered for. A file -// checked and found clean is left out: a workspace report answers what the -// client asked about, and listing every clean file of a project would flood it. -// The order is the cache's own, and only the count is bounded. -fn (app &App) uris_with_diagnostics() []string { +// checked and found clean is still listed: the client holding previous errors +// for it needs an empty report to clear them, otherwise stale diagnostics +// never go away (P1: workspace diagnostics cannot clear stale errors). Only +// files a check has actually answered for are listed, so the set stays bounded +// by the cache rather than by the project. +fn (mut app App) uris_with_diagnostics() []string { + mut seen := map[string]bool{} mut uris := []string{cap: app.diag_cache.len} - for uri, cached in app.diag_cache { - if cached.errors.len == 0 { + for uri, _ in app.diag_cache { + if uri in seen { continue } + seen[uri] = true uris << uri } + // Include URIs the worker has answered for (shared cache), so a document + // checked asynchronously still gets an empty report when it becomes clean. + if mut scheduler := app.diagnostics_scheduler { + scheduler.mutex.lock() + for uri, _ in scheduler.diag_cache { + if uri in seen { + continue + } + seen[uri] = true + uris << uri + } + scheduler.mutex.unlock() + } uris.sort() return uris } From 3c1cf2c47cc1e9150dd9c8df25f30b11df3f04bd Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 23 Oct 2026 17:46:07 +0330 Subject: [PATCH 81/83] vls: synchronize compiler_answer_ms access across threads (P1) --- answer_budget.v | 4 ++++ main.v | 3 +++ 2 files changed, 7 insertions(+) diff --git a/answer_budget.v b/answer_budget.v index 46a8dafd..364e5f86 100644 --- a/answer_budget.v +++ b/answer_budget.v @@ -90,6 +90,10 @@ fn (app &App) compiler_answer_is_held(uri string, method Method) bool { // and this method in under the budget the last time it was asked: then its // answer costs no more than the index's and must not be doubled. fn (app &App) compiler_answer_is_quick(uri string, method Method) bool { + app.compiler_answer_ms_mutex.lock() + defer { + app.compiler_answer_ms_mutex.unlock() + } elapsed_ms := app.compiler_answer_ms[answer_budget_key(uri, method)] or { return false } return elapsed_ms in 0 .. answer_budget_ms } diff --git a/main.v b/main.v index cfdaca7c..c1df2f21 100644 --- a/main.v +++ b/main.v @@ -76,6 +76,9 @@ mut: // What the compiler's answer cost, per document and method: below the budget // in answer_budget.v the answer is waited for instead of answered twice. compiler_answer_ms map[string]i64 + // Serializes reads/writes of compiler_answer_ms, which run_answer_followup + // touches from a worker thread while the request loop reads it (P1). + compiler_answer_ms_mutex &sync.Mutex = sync.new_mutex() // The threads that replace an index answer with the compiler's (see answer_budget.v). answer_followups &sync.WaitGroup = sync.new_waitgroup() importable_modules_cache map[string]ImportableModulesCache // Modules a file can import, per project root (see module_imports.v) From b8ad3cd73ee18f0a07944b6e411904b4058e5e56 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 23 Oct 2026 17:50:13 +0330 Subject: [PATCH 82/83] vls: keep skipped oversized open buffers across shallow refresh (P1) --- index.v | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/index.v b/index.v index 30bb8a65..5e2ef6a3 100644 --- a/index.v +++ b/index.v @@ -804,6 +804,12 @@ fn (mut app App) ensure_dir_shallow_indexed(dir string) { if uri in present { continue } + // An oversized/unsaved open buffer is not on disk, so it is never + // in `present`; deleting it here would lose the skipped marker and + // make completeness wrongly report true (P1: rename oversize marker). + if uri in app.open_files { + continue + } if os.dir(uri_to_path(uri)).replace('\\', '/').trim_right('/') == dir_norm { app.index_skipped_uris.delete(uri) } From 71d86255edf9df585959951a69b9a4fad3641922 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 23 Oct 2026 18:58:43 +0330 Subject: [PATCH 83/83] vls: per-App config store to prevent TCP client config leak (P1) --- main.v | 1 + vls_config.v | 6 +++--- vls_config_test.v | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/main.v b/main.v index c1df2f21..9199678b 100644 --- a/main.v +++ b/main.v @@ -32,6 +32,7 @@ mut: inlay_hints_enabled bool = true // toggled via workspace/didChangeConfiguration diagnostics_enabled bool = true // toggled via workspace/didChangeConfiguration diag_cache map[string]DiagCacheEntry // Per-URI cached diagnostics + config_store &VlsConfigStore = &VlsConfigStore{} // Per-App config (P1: config leak between TCP clients) diag_disk_roots map[string]bool // Program roots whose disk cache merged this session file_list_cache map[string]FileListEntry // Per-project .v listings, so a fingerprint does not re-walk the tree content_memo map[string]ContentMemo // Per-file content hash with the size, inode and mtime it was read at diff --git a/vls_config.v b/vls_config.v index 548bd239..b1b2033e 100644 --- a/vls_config.v +++ b/vls_config.v @@ -114,7 +114,7 @@ fn vls_config_store() &VlsConfigStore { // a changed defines value drops the cached diagnostics, which were computed with // the old ones. fn (mut app App) apply_editor_configuration(params_json string) { - mut store := vls_config_store() + mut store := app.config_store previous := store.editor_settings().defines mut editor := EditorSettings{ defines: resolve_editor_defines(params_json) @@ -153,7 +153,7 @@ fn (mut app App) project_config_for_path(path string) VlsProjectConfig { // cached result for `vls_config_ttl_ms`. When a re-read finds a different // configuration, the diagnostics computed with the old one are dropped. fn (mut app App) project_config_for_root(root string) VlsProjectConfig { - store := vls_config_store() + store := app.config_store if cached := store.cached_config(root, time.now().unix_milli()) { return cached } @@ -209,7 +209,7 @@ fn (mut app App) forget_config_file(path string) { return } root := project_root_for_path(path) - mut store := vls_config_store() + mut store := app.config_store store.drop_config(root) app.forget_project_diagnostics(root) } diff --git a/vls_config_test.v b/vls_config_test.v index fb3fab4a..4a59a4d9 100644 --- a/vls_config_test.v +++ b/vls_config_test.v @@ -44,6 +44,7 @@ fn config_test_app(root string) &App { return &App{ temp_dir: root workspace_roots: [root] + config_store: store } } @@ -299,6 +300,7 @@ fn test_config_the_defines_make_the_compiler_check_the_gated_code() { uri: content } workspace_roots: [root] + config_store: vls_config_store() } // Without a define the gated body is not compiled, so it is not checked. assert app.run_v_check(uri, content).len == 0, 'no define checks no gated code' @@ -322,6 +324,7 @@ fn config_test_app_open(root string) &App { workspace_roots: [root] inlay_hints_enabled: true diagnostics_enabled: true + config_store: store } }