From 8c68ac9c0ff7372d10e9bffb952c500bfaf7165e Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 3 Oct 2026 15:01:57 +0330 Subject: [PATCH] fix: keep the authority when encoding a UNC path as a file URI `uri_to_path` already resolves a `file://host/...` URI to a `//host/share/...` UNC path, but `path_to_uri` then treated that as an ordinary absolute path and emitted four slashes: client sends file://server/share/proj/main.v uri_to_path //server/share/proj/main.v path_to_uri file:////server/share/proj/main.v The share ends up in the path instead of the authority. That is not a valid file URI (RFC 8089 puts the host in the authority), and it breaks the round trip in a way that matters: VLS keys open buffers, the index, and every published diagnostic and code lens by URI, so the URI it derives for a file on a network share never matches the one the client sent for the same file. Encode the host as the authority instead: path_to_uri('//server/share/proj/main.v') == 'file://server/share/proj/main.v' Percent-encoding of the path component is unchanged, so a share with a space still encodes correctly and still round-trips. Single-slash absolute paths and Windows drive paths are untouched, so POSIX and local Windows behaviour is identical. --- interop.v | 60 ++++++++------- interop_test.v | 200 +++++++++++++++++++++++++++---------------------- 2 files changed, 145 insertions(+), 115 deletions(-) diff --git a/interop.v b/interop.v index 0aad112f..e973fec3 100644 --- a/interop.v +++ b/interop.v @@ -198,6 +198,15 @@ fn path_to_uri(path string) string { return 'file:///' } mut normalized := os.to_slash(path) + // A UNC path carries its host in the URI authority (RFC 8089), so + // `//server/share/main.v` must become `file://server/share/main.v`. Treating + // it as an ordinary absolute path instead yields `file:////server/share/...`, + // which is not a valid file URI and, worse, no longer round-trips: a client + // that opened the file as `file://server/share/main.v` would never match the + // key VLS derives for it. + if normalized.starts_with('//') { + return 'file:' + percent_encode_path(normalized) + } // Windows drive letter: C:/Users/... -> /C:/Users/... so the URI keeps a // leading slash before the authority-less path. if normalized.len >= 2 && normalized[1] == `:` { @@ -267,7 +276,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 +294,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 } @@ -465,11 +475,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 +527,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 +563,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 +625,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 +738,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 +850,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 +1523,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 } } @@ -1696,7 +1706,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 +1714,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 +1759,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..52f9087f 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' @@ -199,6 +199,26 @@ fn test_overlay_path_lists_can_use_windows_case_rules() { assert !overlay_path_has_descendant_with_case('src', tracked_paths, false) } +fn test_path_to_uri_keeps_the_authority_of_a_unc_path() { + // An editor opens a file on a Windows network share as `file://server/...`, + // and `uri_to_path` already resolves that authority to a `//server/share/...` + // UNC path. Re-encoding that path must reproduce the same URI, because VLS + // keys open buffers, the index, and every published diagnostic by URI. + assert uri_to_path('file://server/share/proj/main.v') == '//server/share/proj/main.v' + assert path_to_uri('//server/share/proj/main.v') == 'file://server/share/proj/main.v' + assert path_to_uri(uri_to_path('file://server/share/proj/main.v')) == 'file://server/share/proj/main.v' + // The share is the authority, not the first path segment: four slashes put it + // in the path and produce a URI no client will match. + assert !path_to_uri('//server/share/proj/main.v').starts_with('file:////') + // Characters that need escaping still are, and the round trip holds. + assert path_to_uri('//server/share/my project/main.v') == 'file://server/share/my%20project/main.v' + assert uri_to_path(path_to_uri('//server/share/my project/main.v')) == '//server/share/my project/main.v' + // A single leading slash stays an ordinary local path, so POSIX paths and + // Windows drive paths are unaffected. + assert path_to_uri('/home/user/project/main.v') == 'file:///home/user/project/main.v' + assert path_to_uri('C:/Users/me/main.v') == 'file:///C:/Users/me/main.v' +} + // --- path_to_uri tests --- fn test_path_to_uri_unix() { @@ -257,7 +277,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' @@ -641,12 +661,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 +681,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 +763,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 +775,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 +819,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 +838,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 +853,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 +868,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 +882,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 +897,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 +937,7 @@ fn test_lsp_range_struct() { line: 0 char: 0 } - end: Position{ + end: Position{ line: 0 char: 10 } @@ -932,7 +952,7 @@ fn test_lsp_range_multiline() { line: 5 char: 10 } - end: Position{ + end: Position{ line: 10 char: 5 } @@ -942,17 +962,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 +985,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 +995,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 +1019,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 +1034,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 +1043,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 +1056,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 +1065,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 +1088,7 @@ fn test_signature_help_struct() { fn test_signature_help_multiple_signatures() { sig := SignatureHelp{ - signatures: [ + signatures: [ SignatureInformation{ label: 'fn overload1(a int)' }, @@ -1096,17 +1116,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 +1147,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 +1182,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 +1191,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 +1208,7 @@ fn test_notification_struct() { notif := Notification{ method: 'textDocument/publishDiagnostics' params: PublishDiagnosticsParams{ - uri: 'file:///test.v' + uri: 'file:///test.v' diagnostics: [] } } @@ -1200,16 +1220,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 +1272,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 +1310,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 +1351,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 +1391,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 +1432,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 +1470,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 +1931,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 +1959,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 +1991,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 +2000,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 +2014,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 +2033,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 }, ]