diff --git a/handlers.v b/handlers.v index 6022d468..681f986e 100644 --- a/handlers.v +++ b/handlers.v @@ -455,11 +455,6 @@ fn (mut app App) on_did_change(request Request) ?Notification { log('on_did_change() no params') return none } - // If the first content change is an empty text, treat as no-op - if params.content_changes.len > 0 && params.content_changes[0].text == '' { - log('on_did_change() empty text') - return none - } uri := params.text_document.uri mut content := app.open_files[uri] or { '' } for change in params.content_changes { @@ -495,26 +490,56 @@ fn (mut app App) on_did_save(request Request) ?Notification { } uri := params.text_document.uri mut content := app.open_files[uri] or { '' } - if text := params.text { - content = text - app.open_files[uri] = text - app.text = text - app.open_files_generation++ - } if content == '' { - real_path := uri_to_path(uri) - content = os.read_file(real_path) or { - $if debug { log('on_did_save: failed to read file ${real_path}: ${err}') } - return none + if text := params.text { + content = text + app.open_files[uri] = text + app.text = text + app.open_files_generation++ + } else { + real_path := uri_to_path(uri) + content = os.read_file(real_path) or { + $if debug { log('on_did_save: failed to read file ${real_path}: ${err}') } + return none + } + app.open_files[uri] = content + app.text = content + app.open_files_generation++ } - app.open_files[uri] = content - app.text = content - app.open_files_generation++ } notification := app.build_diagnostics_notification(uri, content) return notification } +// on_will_save_wait_until handles willSaveWaitUntil by formatting the document +// before it is saved, returning the edits to apply atomically with the save. +fn (mut app App) on_will_save_wait_until(request Request) Response { + params := json.decode(WillSaveTextDocumentParams, request.params) or { + $if debug { log('Failed to decode WillSaveTextDocumentParams: ${err}') } + return Response{ + id: request.id + result: []TextEdit{} + } + } + uri := params.text_document.uri + content := app.open_files[uri] or { + return Response{ + id: request.id + result: []TextEdit{} + } + } + edits, formatted := app.format_content(uri, content) + if formatted != '' { + app.open_files[uri] = formatted + app.text = formatted + app.open_files_generation++ + } + return Response{ + id: request.id + result: edits + } +} + // handle_prepare_rename handles textDocument/prepareRename by returning the range // and placeholder text for the identifier under the cursor, or an empty result // when the cursor is not on a renameable symbol. @@ -1311,70 +1336,42 @@ fn search_doc_in_vlib_dir(dir string, symbol string) string { return '' } -// handle_formatting handles the LSP formatting request, returning edits to format the document. -fn (mut app App) handle_formatting(request Request) Response { - params := json.decode(DocumentFormattingParams, request.params) or { - log('Failed to decode DocumentFormattingParams: ${err}') - return Response{ - id: request.id - result: []TextEdit{} - } - } - path := params.text_document.uri - real_path := uri_to_path(path) - - // Get the current content of the file - content := app.open_files[path] or { - os.read_file(real_path) or { - log('Failed to read file for formatting: ${err}') - return Response{ - id: request.id - result: []TextEdit{} - } - } - } +// format_content formats the given content via v fmt and returns the TextEdits +// needed to replace the document with its formatted version, plus the formatted +// text. Returns empty edits if the content is already properly formatted. +fn (mut app App) format_content(uri string, content string) ([]TextEdit, string) { + real_path := uri_to_path(uri) - // Write content to a temp file temp_file := os.join_path(os.temp_dir(), 'vls_fmt_${os.getpid()}_${os.file_name(real_path)}') os.write_file(temp_file, content) or { log('Failed to write temp file for formatting: ${err}') - return Response{ - id: request.id - result: []TextEdit{} - } + return []TextEdit{}, '' } - // Run fmt + // With -w flag, v fmt writes the formatted content back to the temp file. + // Read from there instead of relying on stdout capture, which is + // unreliable on Windows MSYS2. result := os.execute(ensure_stderr_captured(build_v_fmt_cmd(temp_file))) - // Clean up temp file + mut formatted := os.read_file(temp_file) or { result.output } + os.rm(temp_file) or { $if debug { log('Failed to remove temp file: ${err}') } } - // Check for errors if result.exit_code != 0 { $if debug { log('v fmt failed with code ${result.exit_code}: ${result.output}') } - return Response{ - id: request.id - result: []TextEdit{} - } + return []TextEdit{}, '' } - // If content is unchanged, return empty edits - if result.output == content { - return Response{ - id: request.id - result: []TextEdit{} - } + if formatted == '' || formatted == content { + return []TextEdit{}, '' } - // Calculate the range of the entire document lines := content.split_into_lines() last_line := lines.len - 1 last_char := if lines.len > 0 { lines[last_line].len } else { 0 } - // Return a single TextEdit that replaces the entire document edit := TextEdit{ range: LSPRange{ start: Position{ @@ -1386,12 +1383,37 @@ fn (mut app App) handle_formatting(request Request) Response { char: last_char } } - new_text: result.output + new_text: formatted } + return [edit], formatted +} +// handle_formatting handles the LSP formatting request, returning edits to format the document. +fn (mut app App) handle_formatting(request Request) Response { + params := json.decode(DocumentFormattingParams, request.params) or { + log('Failed to decode DocumentFormattingParams: ${err}') + return Response{ + id: request.id + result: []TextEdit{} + } + } + path := params.text_document.uri + real_path := uri_to_path(path) + + content := app.open_files[path] or { + os.read_file(real_path) or { + log('Failed to read file for formatting: ${err}') + return Response{ + id: request.id + result: []TextEdit{} + } + } + } + + edits, _ := app.format_content(path, content) return Response{ id: request.id - result: [edit] + result: edits } } diff --git a/handlers_test.v b/handlers_test.v index aab504fc..fd892a27 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -352,7 +352,7 @@ fn test_on_did_change_empty_text() { cleanup_test_app(app) } - // Request with empty text should return none + // Request with empty text (deletion) should be processed and return diagnostics request := Request{ params: json.encode(Params{ content_changes: [ContentChange{ @@ -362,7 +362,13 @@ fn test_on_did_change_empty_text() { } result := app.on_did_change(request) - assert result == none + if notif := result { + assert notif.method == 'textDocument/publishDiagnostics' + assert notif.params.uri == '' + assert notif.params.diagnostics.len == 0 + } else { + assert false, 'expected a notification' + } } fn test_on_did_change_returns_notification() { diff --git a/integration_test.v b/integration_test.v index 45ad6337..3c39ad07 100644 --- a/integration_test.v +++ b/integration_test.v @@ -514,7 +514,7 @@ fn test_integration_diagnostics_empty_file() { }) }) - // Empty content should return none + // Empty content should be processed and return diagnostics for the empty file result := app.on_did_change(Request{ params: json.encode(Params{ text_document: TextDocumentIdentifier{ @@ -526,7 +526,12 @@ fn test_integration_diagnostics_empty_file() { }) }) - assert result == none + if notif := result { + assert notif.method == 'textDocument/publishDiagnostics' + assert notif.params.uri == uri + } else { + assert false, 'expected a notification for empty file' + } } fn test_integration_completion_request() { diff --git a/interop.v b/interop.v index 37146bf4..1b678500 100644 --- a/interop.v +++ b/interop.v @@ -40,8 +40,10 @@ fn ensure_stderr_captured(cmd string) string { } fn shell_quote(s string) string { - escaped := s.replace("'", '\'"\'"\'') - return "'${escaped}'" + // Use double quotes for cross-platform compatibility. + // Windows CMD does not treat single quotes as string delimiters. + escaped := s.replace('"', '\\"') + return '"${escaped}"' } fn build_v_check_cmd_single(file_to_check string) string { @@ -62,7 +64,7 @@ fn build_v_line_info_cmd_single(file_to_check string, line_info string, compile_ } fn build_v_fmt_cmd(temp_file string) string { - return 'v fmt -inprocess ${shell_quote(temp_file)}' + return 'v fmt -inprocess -w ${shell_quote(temp_file)}' } fn execute_in_dir(dir string, cmd string) os.Result { diff --git a/interop_test.v b/interop_test.v index b20af2dc..43506da9 100644 --- a/interop_test.v +++ b/interop_test.v @@ -204,8 +204,13 @@ fn test_execute_in_dir_restores_working_directory() { os.rmdir_all(work_dir) or {} } - result := execute_in_dir(work_dir, 'pwd') - assert result.output.trim_space() == work_dir + mut result := execute_in_dir(work_dir, 'pwd') + $if windows { + // On Windows, os.execute uses cmd.exe which understands echo %cd% + result = execute_in_dir(work_dir, 'echo %cd%') + } + // Use real_path to resolve symlinks (e.g. /tmp -> /private/tmp on macOS) + assert os.real_path(result.output.trim_space()) == os.real_path(work_dir) assert os.getwd() == original } @@ -221,18 +226,18 @@ fn test_execute_in_dir_returns_error_when_directory_missing() { fn test_shell_quote_handles_single_quotes() { quoted := shell_quote("a'b") - assert quoted == '\'a\'"\'"\'b\'' + assert quoted == '"a\'b"' } fn test_build_v_check_cmd_single_quotes_path() { cmd := build_v_check_cmd_single('/tmp/a b/test.v') - assert cmd.contains("'/tmp/a b/test.v'") + assert cmd.contains('"/tmp/a b/test.v"') assert cmd.contains('-vls-mode') } fn test_build_v_fmt_cmd_quotes_temp_file() { cmd := build_v_fmt_cmd('/tmp/fmt file.v') - assert cmd == "v fmt -inprocess '/tmp/fmt file.v'" + assert cmd == 'v fmt -inprocess -w "/tmp/fmt file.v"' } // ============================================================================ diff --git a/lsp.v b/lsp.v index d227d807..14eb96d1 100644 --- a/lsp.v +++ b/lsp.v @@ -647,9 +647,11 @@ struct SaveOptions { // TextDocumentSyncOptions describes document synchronization options. struct TextDocumentSyncOptions { - open_close bool @[json: 'openClose'] - change int // 1 for Full, 2 for Incremental - save SaveOptions // emit {"includeText":true} to receive text in didSave + open_close bool @[json: 'openClose'] + change int // 1 for Full, 2 for Incremental + save SaveOptions // emit {"includeText":true} to receive text in didSave + will_save bool @[json: 'willSave'] + will_save_wait_until bool @[json: 'willSaveWaitUntil'] } // SignatureHelpOptions describes signature help trigger characters. @@ -829,20 +831,22 @@ enum Method { selection_range @['textDocument/selectionRange'] semantic_tokens_range @['textDocument/semanticTokens/range'] range_formatting @['textDocument/rangeFormatting'] - did_change_watched_files @['workspace/didChangeWatchedFiles'] - code_lens @['textDocument/codeLens'] - code_lens_resolve @['codeLens/resolve'] - execute_command @['workspace/executeCommand'] - inline_value @['textDocument/inlineValue'] - linked_editing_range @['textDocument/linkedEditingRange'] - will_create_files @['workspace/willCreateFiles'] - will_rename_files @['workspace/willRenameFiles'] - will_delete_files @['workspace/willDeleteFiles'] - on_type_formatting @['textDocument/onTypeFormatting'] - set_trace @['$/setTrace'] - cancel_request @['$/cancelRequest'] - shutdown @['shutdown'] - exit @['exit'] + will_save @['textDocument/willSave'] + will_save_wait_until @['textDocument/willSaveWaitUntil'] + did_change_watched_files @['workspace/didChangeWatchedFiles'] + code_lens @['textDocument/codeLens'] + code_lens_resolve @['codeLens/resolve'] + execute_command @['workspace/executeCommand'] + inline_value @['textDocument/inlineValue'] + linked_editing_range @['textDocument/linkedEditingRange'] + will_create_files @['workspace/willCreateFiles'] + will_rename_files @['workspace/willRenameFiles'] + will_delete_files @['workspace/willDeleteFiles'] + on_type_formatting @['textDocument/onTypeFormatting'] + set_trace @['$/setTrace'] + cancel_request @['$/cancelRequest'] + shutdown @['shutdown'] + exit @['exit'] } // TextDocumentPositionParams for position-based requests @@ -882,6 +886,13 @@ struct DidSaveTextDocumentParams { text ?string } +// WillSaveTextDocumentParams for willSave/willSaveWaitUntil. +// Reason: 1 = Manual, 2 = AfterDelay, 3 = FocusOut. +struct WillSaveTextDocumentParams { + text_document TextDocumentIdentifier @[json: 'textDocument'] + reason int +} + // ReferenceContext carries the includeDeclaration flag for references requests. struct ReferenceContext { include_declaration bool @[json: 'includeDeclaration'] diff --git a/main.v b/main.v index f9439f92..44086d79 100644 --- a/main.v +++ b/main.v @@ -371,11 +371,13 @@ fn (mut app App) handle_requests(mut reader io.BufferedReader) { result: Capabilities{ capabilities: Capability{ text_document_sync: TextDocumentSyncOptions{ - open_close: true - change: 2 // Incremental - save: SaveOptions{ + open_close: true + change: 2 // Incremental + save: SaveOptions{ include_text: true } + will_save: true + will_save_wait_until: true } completion_provider: CompletionProvider{ trigger_characters: ['.', ' '] @@ -475,6 +477,10 @@ fn (mut app App) handle_requests(mut reader io.BufferedReader) { notification := app.on_did_save(request) or { continue } app.write_notification(notification) } + .will_save_wait_until { + resp := app.on_will_save_wait_until(request) + app.write_response_or_cancelled(request.id, resp) + } .initialized { log('Received initialized notification.') app.on_initialized(request)