diff --git a/internal/format/indent.go b/internal/format/indent.go index 2565666d2f..170873c2a4 100644 --- a/internal/format/indent.go +++ b/internal/format/indent.go @@ -66,8 +66,11 @@ func getIndentationForNodeWorker( // }, { itself contributes nothing. // prop: 1 L3 - The indentation of the second object literal is best understood by // }) looking at the relationship between the list and *first* list item. - listLine, _ := getStartLineAndCharacterForNode(firstListChild, sourceFile) - listIndentsChild := firstListChild != nil && listLine > containingListOrParentStartLine + var listIndentsChild bool + if firstListChild != nil { + listLine, _ := getStartLineAndCharacterForNode(firstListChild, sourceFile) + listIndentsChild = listLine > containingListOrParentStartLine + } actualIndentation := getActualIndentationForListItem(current, sourceFile, options, listIndentsChild) if actualIndentation != -1 { return actualIndentation + indentationDelta diff --git a/internal/fourslash/baselineutil.go b/internal/fourslash/baselineutil.go index b640ef071c..9aee1b1a7b 100644 --- a/internal/fourslash/baselineutil.go +++ b/internal/fourslash/baselineutil.go @@ -51,6 +51,8 @@ func getBaselineExtension(command string) string { return "baseline" case "Auto Imports": return "baseline.md" + case "formatDocument", "formatSelection", "formatOnType": + return "baseline" case "findAllReferences", "goToDefinition", "findRenameLocations": return "baseline.jsonc" default: diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 13cfa1c2bc..8db74e00f2 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -2221,3 +2221,374 @@ func (f *FourslashTest) verifyBaselines(t *testing.T) { } var AnyTextEdits *[]*lsproto.TextEdit + +// VerifyFormatDocument verifies formatting of the entire document. +// It sends a textDocument/formatting request and compares the result with a baseline. +func (f *FourslashTest) VerifyFormatDocument(t *testing.T, options *lsproto.FormattingOptions) { + if options == nil { + options = &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + } + + params := &lsproto.DocumentFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for document formatting request") + } + if !resultOk { + // Check if result is nil - this is valid, just means no formatting needed + resp := resMsg.AsResponse() + if resp.Result == nil { + f.addFormattingResultToBaseline(t, "formatDocument", nil) + return + } + t.Fatalf("Unexpected response type for document formatting request: %T", resp.Result) + } + + f.addFormattingResultToBaseline(t, "formatDocument", result.TextEdits) +} + +// VerifyFormatSelection verifies formatting of a selected range. +// It sends a textDocument/rangeFormatting request and compares the result with a baseline. +// rangeMarker should be obtained from f.Ranges()[index]. +func (f *FourslashTest) VerifyFormatSelection(t *testing.T, rangeMarker *RangeMarker, options *lsproto.FormattingOptions) { + if options == nil { + options = &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + } + + f.ensureActiveFile(t, rangeMarker.FileName()) + formatRange := rangeMarker.LSRange + + params := &lsproto.DocumentRangeFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Range: formatRange, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentRangeFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for range formatting request") + } + if !resultOk { + // Check if result is nil - this is valid, just means no formatting needed + resp := resMsg.AsResponse() + if resp.Result == nil { + f.addFormattingResultToBaseline(t, "formatSelection", nil) + return + } + t.Fatalf("Unexpected response type for range formatting request: %T", resp.Result) + } + + f.addFormattingResultToBaseline(t, "formatSelection", result.TextEdits) +} + +// VerifyFormatOnType verifies on-type formatting (e.g., after typing `;`, `}`, or newline). +// It sends a textDocument/onTypeFormatting request and compares the result with a baseline. +// markerName should be the name of a marker in the test file (e.g., "a" for /*a*/). +func (f *FourslashTest) VerifyFormatOnType(t *testing.T, markerName string, character string, options *lsproto.FormattingOptions) { + if options == nil { + options = &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + } + + f.GoToMarker(t, markerName) + + params := &lsproto.DocumentOnTypeFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Position: f.currentCaretPosition, + Ch: character, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentOnTypeFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for on-type formatting request") + } + if !resultOk { + // Check if result is nil - this is valid, just means no formatting needed + resp := resMsg.AsResponse() + if resp.Result == nil { + // No formatting edits needed + f.addFormattingResultToBaseline(t, "formatOnType", nil) + return + } + t.Fatalf("Unexpected response type for on-type formatting request: %T", resp.Result) + } + + f.addFormattingResultToBaseline(t, "formatOnType", result.TextEdits) +} + +// addFormattingResultToBaseline adds formatting results to the baseline. +// It shows the original file content and the formatted content side by side. +func (f *FourslashTest) addFormattingResultToBaseline(t *testing.T, command string, edits *[]*lsproto.TextEdit) { + script := f.getScriptInfo(f.activeFilename) + originalContent := script.content + + var formattedContent string + if edits == nil || len(*edits) == 0 { + formattedContent = originalContent + } else { + // Apply edits to get formatted content + formattedContent = f.applyEditsToString(originalContent, *edits) + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("// Original (%s):\n", f.activeFilename)) + for _, line := range strings.Split(originalContent, "\n") { + result.WriteString("// " + line + "\n") + } + result.WriteString("\n") + result.WriteString("// Formatted:\n") + for _, line := range strings.Split(formattedContent, "\n") { + result.WriteString("// " + line + "\n") + } + + f.addResultToBaseline(t, command, result.String()) +} + +// applyEditsToString applies text edits to a string and returns the result. +func (f *FourslashTest) applyEditsToString(content string, edits []*lsproto.TextEdit) string { + script := newScriptInfo(f.activeFilename, content) + converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { + return script.lineMap + }) + + // Sort edits in reverse order to avoid affecting positions + // Create a slice with cached position conversions for efficient sorting + type editWithPosition struct { + edit *lsproto.TextEdit + pos core.TextPos + } + editsWithPositions := make([]editWithPosition, len(edits)) + for i, edit := range edits { + editsWithPositions[i] = editWithPosition{ + edit: edit, + pos: converters.LineAndCharacterToPosition(script, edit.Range.Start), + } + } + slices.SortFunc(editsWithPositions, func(a, b editWithPosition) int { + return int(b.pos) - int(a.pos) + }) + + result := content + for _, editWithPos := range editsWithPositions { + start := int(editWithPos.pos) + end := int(converters.LineAndCharacterToPosition(script, editWithPos.edit.Range.End)) + result = result[:start] + editWithPos.edit.NewText + result[end:] + } + + return result +} + +// FormatDocument applies formatting to the entire document and updates the file content. +// This method modifies the file in place, similar to how TypeScript's fourslash works. +func (f *FourslashTest) FormatDocument(t *testing.T) { + options := &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + + params := &lsproto.DocumentFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for document formatting request") + } + if !resultOk { + resp := resMsg.AsResponse() + if resp.Result == nil { + // No formatting needed + return + } + t.Fatalf("Unexpected response type for document formatting request: %T", resp.Result) + } + + if result.TextEdits != nil && len(*result.TextEdits) > 0 { + f.applyTextEdits(t, *result.TextEdits) + } +} + +// FormatSelection applies formatting to the selection between two markers and updates the file content. +func (f *FourslashTest) FormatSelection(t *testing.T, startMarkerName string, endMarkerName string) { + startMarker, ok := f.testData.MarkerPositions[startMarkerName] + if !ok { + t.Fatalf("Start marker '%s' not found", startMarkerName) + } + endMarker, ok := f.testData.MarkerPositions[endMarkerName] + if !ok { + t.Fatalf("End marker '%s' not found", endMarkerName) + } + if startMarker.FileName() != endMarker.FileName() { + t.Fatalf("Markers '%s' and '%s' are in different files", startMarkerName, endMarkerName) + } + + f.ensureActiveFile(t, startMarker.FileName()) + + formatRange := lsproto.Range{ + Start: startMarker.LSPosition, + End: endMarker.LSPosition, + } + + options := &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + + params := &lsproto.DocumentRangeFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Range: formatRange, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentRangeFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for range formatting request") + } + if !resultOk { + resp := resMsg.AsResponse() + if resp.Result == nil { + // No formatting needed + return + } + t.Fatalf("Unexpected response type for range formatting request: %T", resp.Result) + } + + if result.TextEdits != nil && len(*result.TextEdits) > 0 { + f.applyTextEdits(t, *result.TextEdits) + } +} + +// FormatOnType applies on-type formatting at the specified marker position after typing the given character. +func (f *FourslashTest) FormatOnType(t *testing.T, markerName string, character string) { + f.GoToMarker(t, markerName) + + options := &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + + params := &lsproto.DocumentOnTypeFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Position: f.currentCaretPosition, + Ch: character, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentOnTypeFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for on-type formatting request") + } + if !resultOk { + resp := resMsg.AsResponse() + if resp.Result == nil { + // No formatting needed + return + } + t.Fatalf("Unexpected response type for on-type formatting request: %T", resp.Result) + } + + if result.TextEdits != nil && len(*result.TextEdits) > 0 { + f.applyTextEdits(t, *result.TextEdits) + } +} + +// CurrentLineContentIs verifies that the current line (where the caret is positioned) has the expected content. +func (f *FourslashTest) CurrentLineContentIs(t *testing.T, expectedText string) { + script := f.getScriptInfo(f.activeFilename) + lineIndex := int(f.currentCaretPosition.Line) + + if lineIndex >= len(script.lineMap.LineStarts) { + t.Fatalf("Current line index %d is out of bounds (file has %d lines)", lineIndex, len(script.lineMap.LineStarts)) + } + + lineStart := int(script.lineMap.LineStarts[lineIndex]) + var lineEnd int + if lineIndex+1 < len(script.lineMap.LineStarts) { + lineEnd = int(script.lineMap.LineStarts[lineIndex+1]) + // Remove trailing newline characters + for lineEnd > lineStart && (script.content[lineEnd-1] == '\n' || script.content[lineEnd-1] == '\r') { + lineEnd-- + } + } else { + lineEnd = len(script.content) + } + + actualText := script.content[lineStart:lineEnd] + if actualText != expectedText { + t.Fatalf("Expected current line to be:\n %q\nbut got:\n %q", expectedText, actualText) + } +} + +// CurrentFileContentIs verifies that the active file has the expected content. +func (f *FourslashTest) CurrentFileContentIs(t *testing.T, expectedContent string) { + script := f.getScriptInfo(f.activeFilename) + actualContent := script.content + + if actualContent != expectedContent { + t.Fatalf("Expected file content to be:\n%s\n\nbut got:\n%s", expectedContent, actualContent) + } +} + +// FormatDocumentChangesNothing verifies that formatting the document produces no changes. +func (f *FourslashTest) FormatDocumentChangesNothing(t *testing.T) { + script := f.getScriptInfo(f.activeFilename) + originalContent := script.content + + options := &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + } + + params := &lsproto.DocumentFormattingParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Options: options, + } + + resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentFormattingInfo, params) + if resMsg == nil { + t.Fatal("Nil response received for document formatting request") + } + if !resultOk { + resp := resMsg.AsResponse() + if resp.Result == nil { + // No edits means no changes, which is what we expect + return + } + t.Fatalf("Unexpected response type for document formatting request: %T", resp.Result) + } + + if result.TextEdits != nil && len(*result.TextEdits) > 0 { + formattedContent := f.applyEditsToString(originalContent, *result.TextEdits) + if formattedContent != originalContent { + t.Fatalf("Expected formatting to produce no changes, but content changed:\nOriginal:\n%s\n\nFormatted:\n%s", originalContent, formattedContent) + } + } +} diff --git a/internal/fourslash/tests/basicFormatDocument_test.go b/internal/fourslash/tests/basicFormatDocument_test.go new file mode 100644 index 0000000000..dbfeb9ffc4 --- /dev/null +++ b/internal/fourslash/tests/basicFormatDocument_test.go @@ -0,0 +1,24 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestBasicFormatDocument(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `const x = 1 ; +function foo ( a , b ) { +return a + b ; +} +const y = foo( 2 , 3 ) ;` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + f.VerifyFormatDocument(t, &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + }) +} diff --git a/internal/fourslash/tests/basicFormatOnType_test.go b/internal/fourslash/tests/basicFormatOnType_test.go new file mode 100644 index 0000000000..5b86520be6 --- /dev/null +++ b/internal/fourslash/tests/basicFormatOnType_test.go @@ -0,0 +1,23 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestBasicFormatOnType(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `function foo() {/*a*/ +const x=1; +}` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + // Verify formatting after typing opening curly brace + f.VerifyFormatOnType(t, "a", "{", &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + }) +} diff --git a/internal/fourslash/tests/basicFormatSelection_test.go b/internal/fourslash/tests/basicFormatSelection_test.go new file mode 100644 index 0000000000..f90189e85a --- /dev/null +++ b/internal/fourslash/tests/basicFormatSelection_test.go @@ -0,0 +1,24 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestBasicFormatSelection(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `const x = 1; +[|function foo(a,b){return a+b;}|] +const y = 2;` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + // Format only the function declaration + rangeToFormat := f.Ranges()[0] + f.VerifyFormatSelection(t, rangeToFormat, &lsproto.FormattingOptions{ + TabSize: 4, + InsertSpaces: true, + }) +} diff --git a/internal/fourslash/tests/formatDocumentChangesNothing_test.go b/internal/fourslash/tests/formatDocumentChangesNothing_test.go new file mode 100644 index 0000000000..fa6e82d416 --- /dev/null +++ b/internal/fourslash/tests/formatDocumentChangesNothing_test.go @@ -0,0 +1,17 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestFormatDocumentChangesNothing(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `const x = 1; +const y = 2;` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + f.FormatDocumentChangesNothing(t) +} diff --git a/internal/fourslash/tests/formatDocumentInPlace_test.go b/internal/fourslash/tests/formatDocumentInPlace_test.go new file mode 100644 index 0000000000..d8068696ba --- /dev/null +++ b/internal/fourslash/tests/formatDocumentInPlace_test.go @@ -0,0 +1,23 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestFormatDocumentInPlace(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `for (;;) { } +for (var x;x<0;x++) { } +for (var x ;x<0 ;x++) { }` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + f.FormatDocument(t) + // After formatting, verify the formatted content + // Note: trailing space on last line is a known issue (see https://github.com/microsoft/typescript-go/issues/1997) + f.CurrentFileContentIs(t, `for (; ;) { } +for (var x; x < 0; x++) { } +for (var x; x < 0; x++) { } `) +} diff --git a/internal/fourslash/tests/formatOnTypeInPlace_test.go b/internal/fourslash/tests/formatOnTypeInPlace_test.go new file mode 100644 index 0000000000..f671b22c55 --- /dev/null +++ b/internal/fourslash/tests/formatOnTypeInPlace_test.go @@ -0,0 +1,22 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestFormatOnTypeInPlace(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `if (foo) { + if (bar) {/**/} +}` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + f.FormatOnType(t, "", "{") + // On-type formatting may format the opening brace line + f.CurrentFileContentIs(t, `if (foo) { + if (bar) {} +}`) +} diff --git a/internal/fourslash/tests/formatSelectionInPlace_test.go b/internal/fourslash/tests/formatSelectionInPlace_test.go new file mode 100644 index 0000000000..f0e17138e4 --- /dev/null +++ b/internal/fourslash/tests/formatSelectionInPlace_test.go @@ -0,0 +1,21 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestFormatSelectionInPlace(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `const x = 1; +/*1*/function foo(a,b){return a+b;}/*2*/ +const y = 2;` + f := fourslash.NewFourslash(t, nil /*capabilities*/, content) + f.FormatSelection(t, "1", "2") + f.CurrentFileContentIs(t, `const x = 1; +function foo(a, b) { return a + b; } +const y = 2;`) +} diff --git a/testdata/baselines/reference/fourslash/formatDocument/basicFormatDocument.baseline b/testdata/baselines/reference/fourslash/formatDocument/basicFormatDocument.baseline new file mode 100644 index 0000000000..6435d8d7c9 --- /dev/null +++ b/testdata/baselines/reference/fourslash/formatDocument/basicFormatDocument.baseline @@ -0,0 +1,14 @@ +// === formatDocument === +// Original (/basicFormatDocument.ts): +// const x = 1 ; +// function foo ( a , b ) { +// return a + b ; +// } +// const y = foo( 2 , 3 ) ; + +// Formatted: +// const x = 1; +// function foo(a, b) { +// return a + b; +// } +// const y = foo(2, 3); diff --git a/testdata/baselines/reference/fourslash/formatOnType/basicFormatOnType.baseline b/testdata/baselines/reference/fourslash/formatOnType/basicFormatOnType.baseline new file mode 100644 index 0000000000..31eb477c3a --- /dev/null +++ b/testdata/baselines/reference/fourslash/formatOnType/basicFormatOnType.baseline @@ -0,0 +1,10 @@ +// === formatOnType === +// Original (/basicFormatOnType.ts): +// function foo() { +// const x=1; +// } + +// Formatted: +// function foo() { +// const x=1; +// } diff --git a/testdata/baselines/reference/fourslash/formatSelection/basicFormatSelection.baseline b/testdata/baselines/reference/fourslash/formatSelection/basicFormatSelection.baseline new file mode 100644 index 0000000000..5fbd46e63f --- /dev/null +++ b/testdata/baselines/reference/fourslash/formatSelection/basicFormatSelection.baseline @@ -0,0 +1,10 @@ +// === formatSelection === +// Original (/basicFormatSelection.ts): +// const x = 1; +// function foo(a,b){return a+b;} +// const y = 2; + +// Formatted: +// const x = 1; +// function foo(a, b) { return a + b; } +// const y = 2;