Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 84 additions & 62 deletions handlers.v
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand All @@ -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
}
}

Expand Down
10 changes: 8 additions & 2 deletions handlers_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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() {
Expand Down
9 changes: 7 additions & 2 deletions integration_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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() {
Expand Down
8 changes: 5 additions & 3 deletions interop.v
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
15 changes: 10 additions & 5 deletions interop_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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"'
}

// ============================================================================
Expand Down
45 changes: 28 additions & 17 deletions lsp.v
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand Down
Loading