Skip to content

fix: preserve CRLF line endings when formatting a document - #531

Open
metif12 wants to merge 1 commit into
vlang:masterfrom
metif12:fix/format-crlf-line-endings
Open

metif12 wants to merge 1 commit into
vlang:masterfrom
metif12:fix/format-crlf-line-endings

Conversation

@metif12

@metif12 metif12 commented Oct 3, 2026

Copy link
Copy Markdown

Problem

v fmt always writes LF line endings, on every platform including Windows. VLS
formats by writing the buffer to a temp file, running v fmt -inprocess -w on
it, and reading the result back:

result := run_v_argv(build_v_fmt_args(temp_file), '')
mut formatted := os.read_file(temp_file) or { result.output }

So a CRLF document came back with every CR stripped, and that output was
returned verbatim as a whole-document TextEdit. Confirmed directly against
v fmt on Windows:

before: 5 CR bytes
v fmt -inprocess -w  ->  Reformatted file
after : 0 CR bytes

Two problems follow, and the second is the worse one:

  1. Format Document silently rewrites the file's line endings. Every line
    shows as changed and the diff is the whole file.

  2. Already-formatted code is reported as changed. format_content returns
    no edits when the formatted text equals the input:

    if formatted == '' || formatted == content {
    	return []TextEdit{}, ''
    }

    With the CRs removed that comparison can never hold for a CRLF document, so
    VLS always returns an edit — even when the only difference is line endings and
    the code itself was already correctly formatted.

Fix

Restore the terminator the document already uses, before the comparison:

formatted = restore_line_endings(content, formatted)

The terminator is taken from the document's first line break, so:

  • an LF document is untouched,
  • output that already contains CRLF is not given a second CR,
  • a document with no line break has no convention to preserve,
  • mixed endings normalize to the first one, which is what a formatter respecting
    the dominant convention does.

Tests

Three new tests in handlers_test.v:

  • test_handle_formatting_preserves_crlf_line_endings — a CRLF buffer through
    textDocument/formatting: the code is formatted and the result has no bare
    LF anywhere.
  • test_handle_formatting_already_formatted_crlf_is_a_no_op — the stronger half
    of the bug: already-formatted CRLF content must produce zero edits.
  • test_restore_line_endings_leaves_lf_documents_alone — pins the LF path, the
    no-line-break case, already-CRLF output, and mixed endings.

I verified these are real regression tests by reverting the one-line fix and
re-running; both behavioural tests fail without it:

handlers_test.v:3498: fn test_handle_formatting_preserves_crlf_line_endings
    assert formatted_text.contains('\r\n')
handlers_test.v:3526: fn test_handle_formatting_already_formatted_crlf_is_a_no_op
   > assert edits.len == 0

There was no line-ending coverage in format_content before.

Validation

V 0137eb5 (the vlang/v revision CI builds from), Windows.

handlers_test.v on this branch's change applied over
#526 +
#527 +
#528 +
#529 +
#530:

OK    compilation_test.v     OK    index_test.v      OK    interop_test.v
OK    integration_test.v     OK    handlers_test.v   OK    lsp_test.v
Summary for all V _test.v files: 6 passed, 6 total.

v fmt -verify . exits 0. On this branch alone, v . builds and the three test
files that compile on master pass; handlers_test.v needs
#526 to compile on master at all.

Note on the diff

As with #529 and #530, this branch runs v fmt over the two files it touches,
since master is not v fmt-clean (#528). The semantic change is restore_line_endings
plus its one call site and three tests; git diff -w shows only that.

@metif12
metif12 force-pushed the fix/format-crlf-line-endings branch from 52d012b to b249635 Compare October 3, 2026 11:39
`v fmt` always writes LF, on every platform including Windows. VLS formats by
writing the buffer to a temp file, running `v fmt -inprocess -w` on it, and
reading the result back, so a CRLF document came back with every CR stripped:

```
module main\r\n\r\nfn main() {\r\n\tprintln('hi')\r\n}\r\n
                        |
                 v fmt -inprocess -w
                        v
module main\n\nfn main() {\n\tprintln('hi')\n}\n
```

That output was then returned verbatim as a whole-document `TextEdit`. Two
problems follow, and the second is the worse one:

1. Format Document silently converts the file's line endings, so every line
   shows as changed and the diff is the entire file.
2. `format_content` returns no edits when the formatted text equals the input.
   With the CRs gone that comparison could never hold for a CRLF document, so
   VLS reported a change even for code that was already correctly formatted.

Restore the terminator the document already uses:

```v
formatted = restore_line_endings(content, formatted)
```

The terminator is taken from the document's first line break. An LF document is
untouched, output that already contains CRLF is not given a second CR, and a
document with no line break has no convention to preserve. A file with mixed
endings is normalized to its first ending, which is what a formatter that
respects the dominant convention does.

This is the same bug reported independently by users on Windows, where CRLF is
the default for `core.autocrlf` and for editors that preserve the file's
endings. The fix is platform-neutral: a CRLF document keeps CRLF on any
platform.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant