Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6574a1d
Initial plan
Copilot Mar 4, 2026
a6aa230
fix: prevent inline type modifier on import type statements
Copilot Mar 4, 2026
a91b70a
fix: address formatting issues in type-only import tests
Copilot Mar 4, 2026
4e16652
update failingTests.txt: remove 3 newly passing type-only import tests
Copilot Mar 4, 2026
1ff5910
Changes before error encountered
Copilot Mar 4, 2026
9c8d1de
fix: modify convertFourslash.mts for organizeImportsTypeOrder and fix…
Copilot Mar 4, 2026
a59cacf
Changes before error encountered
Copilot Mar 4, 2026
999e4ae
Changes before error encountered
Copilot Mar 4, 2026
f151a07
Changes before error encountered
Copilot Mar 4, 2026
31661e1
Align InsertNodeInListAfter and InsertImportSpecifierAtIndex with Typ…
Copilot Mar 5, 2026
56290ac
Fix FindNextToken panic by using tokenFullStart to match TS behavior;…
Copilot Mar 5, 2026
0ae5483
Add missing TS comment in getSmartIndent for list item indentation check
Copilot Mar 5, 2026
5699b9a
Move positionBelongsToNode/isCompletedNode to lsutil, add missing TS …
Copilot Mar 9, 2026
3e0db65
Merge main and update failingTests.txt
Copilot Mar 9, 2026
55aeb04
Revert "Merge main and update failingTests.txt"
Copilot Mar 9, 2026
6cc9854
Merge origin/main, resolve failingTests.txt conflict (10 more tests p…
Copilot Mar 9, 2026
11c8d54
Finish porting omitted indenter code, fix wrong value of excludeJSDoc
andrewbranch Mar 12, 2026
88bb06e
Delete dead import fix code, fix rune iteration bug
andrewbranch Mar 12, 2026
5140e02
Merge branch 'main' into copilot/fix-inline-type-modifier
andrewbranch Mar 12, 2026
b51f496
Update astnav baseline
andrewbranch Mar 12, 2026
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
8 changes: 5 additions & 3 deletions internal/astnav/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,13 +643,15 @@ func FindNextToken(previousToken *ast.Node, parent *ast.Node, file *ast.SourceFi
scanner := scanner.GetScannerForSourceFile(file, startPos)
token := scanner.Token()
tokenFullStart := scanner.TokenFullStart()
tokenStart := scanner.TokenStart()
tokenEnd := scanner.TokenEnd()
flags := scanner.TokenFlags()
if tokenStart == previousToken.End() {
// Use tokenFullStart (which includes leading trivia) to match TS's
// findNextToken behavior where `n.pos === previousToken.end` is checked
// (TS's pos includes trivia, same as Go's Pos()/tokenFullStart).
if tokenFullStart == previousToken.End() {
return file.GetOrCreateToken(token, tokenFullStart, tokenEnd, n, flags)
}
panic(fmt.Sprintf("Expected to find next token at %d, got token %s at %d", previousToken.End(), token, tokenStart))
panic(fmt.Sprintf("Expected to find next token at %d, got token %s at %d", previousToken.End(), token, tokenFullStart))
}
// Case 3: no answer.
return nil
Expand Down
258 changes: 258 additions & 0 deletions internal/format/indent.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package format

import (
"iter"
"slices"
"unicode/utf8"

Expand All @@ -18,6 +19,263 @@ func GetIndentationForNode(n *ast.Node, ignoreActualIndentationRange *core.TextR
return getIndentationForNodeWorker(n, startline, startpos, ignoreActualIndentationRange /*indentationDelta*/, 0, sourceFile /*isNextChild*/, false, options)
}

// GetIndentation computes the expected indentation for a position in a source file.
// This is the Go port of SmartIndenter.getIndentation from TypeScript.
func GetIndentation(position int, sourceFile *ast.SourceFile, options *lsutil.FormatCodeSettings, assumeNewLineBeforeCloseBrace bool) int {
if position > len(sourceFile.Text()) {
return options.BaseIndentSize // past EOF
}

// no indentation when the indent style is set to none,
// so we can return fast
if options.IndentStyle == lsutil.IndentStyleNone {
return 0
}

precedingToken := astnav.FindPrecedingTokenEx(sourceFile, position, nil /*startNode*/, true /*excludeJSDoc*/)

enclosingCommentRange := getRangeOfEnclosingComment(sourceFile, position, precedingToken)
if enclosingCommentRange != nil && enclosingCommentRange.Kind == ast.KindMultiLineCommentTrivia {
return getCommentIndent(sourceFile, position, options, enclosingCommentRange)
}

if precedingToken == nil {
return options.BaseIndentSize
}

// no indentation in string/regex/template literals
if isStringOrRegularExpressionOrTemplateLiteral(precedingToken.Kind) {
tokenStart := scanner.GetTokenPosOfNode(precedingToken, sourceFile, false)
if tokenStart <= position && position < precedingToken.End() {
return 0
}
}

lineAtPosition := scanner.GetECMALineOfPosition(sourceFile, position)

// indentation is first non-whitespace character in a previous line
// for block indentation, we should look for a line which contains something that's not
// whitespace.
currentToken := astnav.GetTokenAtPosition(sourceFile, position)
// For object literals, we want indentation to work just like with blocks.
// If the `{` starts in any position (even in the middle of a line), then
// the following indentation should treat `{` as the start of that line (including leading whitespace).
// ```
// const a: { x: undefined, y: undefined } = {} // leading 4 whitespaces and { starts in the middle of line
// ->
// const a: { x: undefined, y: undefined } = {
// x: undefined,
// y: undefined,
// }
// ---------------------
// const a: {x : undefined, y: undefined } =
// {}
// ->
// const a: { x: undefined, y: undefined } =
// { // leading 5 whitespaces and { starts at 6 column
// x: undefined,
// y: undefined,
// }
// ```
isObjectLiteral := currentToken.Kind == ast.KindOpenBraceToken && currentToken.Parent != nil && currentToken.Parent.Kind == ast.KindObjectLiteralExpression
if options.IndentStyle == lsutil.IndentStyleBlock || isObjectLiteral {
return getBlockIndent(sourceFile, position, options)
}

if precedingToken.Kind == ast.KindCommaToken && precedingToken.Parent != nil && precedingToken.Parent.Kind != ast.KindBinaryExpression {
// previous token is comma that separates items in list - find the previous item and try to derive indentation from it
actualIndentation := getActualIndentationForListItemBeforeComma(precedingToken, sourceFile, options)
if actualIndentation != -1 {
return actualIndentation
}
}

containerList := getListByPosition(position, precedingToken.Parent, sourceFile)
// use list position if the preceding token is before any list items
if containerList != nil && !precedingToken.Loc.ContainedBy(containerList.Loc) {
useTheSameBaseIndentation := currentToken.Parent != nil && (currentToken.Parent.Kind == ast.KindFunctionExpression || currentToken.Parent.Kind == ast.KindArrowFunction)
indentSize := 0
if !useTheSameBaseIndentation {
indentSize = options.IndentSize
}
res := getActualIndentationForListStartLine(containerList, sourceFile, options)
if res == -1 {
return indentSize
}
return res + indentSize
}

return getSmartIndent(sourceFile, position, precedingToken, lineAtPosition, assumeNewLineBeforeCloseBrace, options)
}

func getCommentIndent(sourceFile *ast.SourceFile, position int, options *lsutil.FormatCodeSettings, enclosingCommentRange *ast.CommentRange) int {
previousLine := scanner.GetECMALineOfPosition(sourceFile, position) - 1
commentStartLine := scanner.GetECMALineOfPosition(sourceFile, enclosingCommentRange.Pos())

debug.Assert(commentStartLine >= 0, "commentStartLine >= 0")

if previousLine <= commentStartLine {
lineStarts := scanner.GetECMALineStarts(sourceFile)
return FindFirstNonWhitespaceColumn(int(lineStarts[commentStartLine]), position, sourceFile, options)
}

lineStarts := scanner.GetECMALineStarts(sourceFile)
startPositionOfLine := int(lineStarts[previousLine])
character, column := findFirstNonWhitespaceCharacterAndColumn(startPositionOfLine, position, sourceFile, options)

if column == 0 {
return column
}

firstNonWhitespaceCharacterCode := sourceFile.Text()[startPositionOfLine+character]
if firstNonWhitespaceCharacterCode == '*' {
return column - 1
}
return column
}

func getLeadingCommentRangesOfNode(node *ast.Node, file *ast.SourceFile) iter.Seq[ast.CommentRange] {
if node.Kind == ast.KindJsxText {
return nil
}
return scanner.GetLeadingCommentRanges(&ast.NodeFactory{}, file.Text(), node.Pos())
}

func getRangeOfEnclosingComment(
sourceFile *ast.SourceFile,
position int,
precedingToken *ast.Node,
) *ast.CommentRange {
tokenAtPosition := astnav.GetTokenAtPosition(sourceFile, position)
jsdoc := ast.FindAncestor(tokenAtPosition, (*ast.Node).IsJSDoc)
if jsdoc != nil {
tokenAtPosition = jsdoc.Parent
}
tokenStart := astnav.GetStartOfNode(tokenAtPosition, sourceFile, false /*includeJSDoc*/)
if tokenStart <= position && position < tokenAtPosition.End() {
return nil
}

// Between two consecutive tokens, all comments are either trailing on the former
// or leading on the latter (and none are in both lists).
var trailingRangesOfPreviousToken iter.Seq[ast.CommentRange]
if precedingToken != nil {
trailingRangesOfPreviousToken = scanner.GetTrailingCommentRanges(&ast.NodeFactory{}, sourceFile.Text(), precedingToken.End())
}
leadingRangesOfNextToken := getLeadingCommentRangesOfNode(tokenAtPosition, sourceFile)
commentRanges := core.ConcatenateSeq(trailingRangesOfPreviousToken, leadingRangesOfNextToken)
for commentRange := range commentRanges {
if commentRange.ContainsExclusive(position) ||
position == commentRange.End() &&
(commentRange.Kind == ast.KindSingleLineCommentTrivia || position == len(sourceFile.Text())) {
return &commentRange
}
}
return nil
}

func getBlockIndent(sourceFile *ast.SourceFile, position int, options *lsutil.FormatCodeSettings) int {
// move backwards until we find a line with a non-whitespace character,
// then find the first non-whitespace character for that line.
current := position
for current > 0 {
ch, size := utf8.DecodeRuneInString(sourceFile.Text()[current:])
if !stringutil.IsWhiteSpaceLike(ch) {
break
}
current -= size
}

lineStart := GetLineStartPositionForPosition(current, sourceFile)
return FindFirstNonWhitespaceColumn(lineStart, current, sourceFile, options)
}

func getActualIndentationForListItemBeforeComma(commaToken *ast.Node, sourceFile *ast.SourceFile, options *lsutil.FormatCodeSettings) int {
// previous token is comma that separates items in list - find the previous item and try to derive indentation from it
if commaToken.Parent == nil {
return -1
}
containingList := GetContainingList(commaToken, sourceFile)
if containingList == nil {
return -1
}
commaIndex := core.FindIndex(containingList.Nodes, func(n *ast.Node) bool { return n == commaToken })
if commaIndex > 0 {
return deriveActualIndentationFromList(containingList, commaIndex-1, sourceFile, options)
}
return -1
}

type nextTokenKind int

const (
nextTokenKindUnknown nextTokenKind = 0
nextTokenKindOpenBrace nextTokenKind = 1
nextTokenKindCloseBrace nextTokenKind = 2
)

func nextTokenIsCurlyBraceOnSameLineAsCursor(precedingToken *ast.Node, current *ast.Node, lineAtPosition int, sourceFile *ast.SourceFile) nextTokenKind {
nextToken := astnav.FindNextToken(precedingToken, current, sourceFile)
if nextToken == nil {
return nextTokenKindUnknown
}

if nextToken.Kind == ast.KindOpenBraceToken {
// open braces are always indented at the parent level
return nextTokenKindOpenBrace
} else if nextToken.Kind == ast.KindCloseBraceToken {
// close braces are indented at the parent level if they are located on the same line with cursor
nextTokenStartLine := getStartLineForNode(nextToken, sourceFile)
if lineAtPosition == nextTokenStartLine {
return nextTokenKindCloseBrace
}
return nextTokenKindUnknown
}

return nextTokenKindUnknown
}

func getSmartIndent(sourceFile *ast.SourceFile, position int, precedingToken *ast.Node, lineAtPosition int, assumeNewLineBeforeCloseBrace bool, options *lsutil.FormatCodeSettings) int {
// try to find node that can contribute to indentation and includes 'position' starting from 'precedingToken'
// if such node is found - compute initial indentation for 'position' inside this node
var previous *ast.Node
current := precedingToken

for current != nil {
if lsutil.PositionBelongsToNode(current, position, sourceFile) && ShouldIndentChildNode(options, current, previous, sourceFile, true) {
currentStartLine, currentStartChar := getStartLineAndCharacterForNode(current, sourceFile)
ntk := nextTokenIsCurlyBraceOnSameLineAsCursor(precedingToken, current, lineAtPosition, sourceFile)
var indentationDelta int
if ntk != nextTokenKindUnknown {
// handle cases when codefix is about to be inserted before the close brace
if assumeNewLineBeforeCloseBrace && ntk == nextTokenKindCloseBrace {
indentationDelta = options.IndentSize
}
// else 0
} else {
if lineAtPosition != currentStartLine {
indentationDelta = options.IndentSize
}
}
return getIndentationForNodeWorker(current, currentStartLine, currentStartChar, nil, indentationDelta, sourceFile, true, options)
}

// check if current node is a list item - if yes, take indentation from it
// do not consider parent-child line sharing yet:
// function foo(a
// | preceding node 'a' does share line with its parent but indentation is expected
actualIndentation := getActualIndentationForListItem(current, sourceFile, options, true /*listIndentsChild*/)
if actualIndentation != -1 {
return actualIndentation
}

previous = current
current = current.Parent
}
// no parent was found - return the base indentation of the SourceFile
return options.BaseIndentSize
}

func getIndentationForNodeWorker(
current *ast.Node,
currentStartLine int,
Expand Down
39 changes: 39 additions & 0 deletions internal/format/indent_getindentation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package format_test

import (
"testing"

"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/format"
"github.com/microsoft/typescript-go/internal/ls/lsutil"
"github.com/microsoft/typescript-go/internal/parser"
)

func TestGetIndentationForNamedImportsPosition(t *testing.T) {
t.Parallel()

text := "import {\n type SomeInterface,\n} from \"./exports.js\";"
// Position 9: \n
// Position 10: first space of " type SomeInterface"

sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{
FileName: "/test.ts",
Path: "/test.ts",
}, text, core.ScriptKindTS)

options := lsutil.GetDefaultFormatCodeSettings()

// The line that contains " type SomeInterface" starts at position 9 (the \n).
// The getAdjustedStartPosition with LeadingTriviaOptionNone returns line start.
// Let's test at position 9 (start of line containing the specifier)
lineStart := format.GetLineStartPositionForPosition(14, sourceFile) // 14 is somewhere in " type"

indent := format.GetIndentation(lineStart, sourceFile, options, true)
t.Logf("lineStart=%d, text[lineStart:]=%q", lineStart, text[lineStart:lineStart+10])
t.Logf("GetIndentation at lineStart %d = %d", lineStart, indent)

if indent != 4 {
t.Errorf("Expected indentation 4, got %d", indent)
}
}
4 changes: 4 additions & 0 deletions internal/format/span.go
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,10 @@ func (w *formatSpanWorker) consumeTokenAndAdvanceScanner(currentTokenInfo tokenI
if savePreviousRange != NewTextRangeWithKind(0, 0, 0) {
prevEndLine := scanner.GetECMALineOfPosition(w.sourceFile, savePreviousRange.Loc.End())
indentToken = lastTriviaWasNewLine && tokenStartLine != prevEndLine
} else {
// When there's no previous range (first token), TS sets prevEndLine to undefined.
// tokenStart.line !== undefined is always true in JS, so indentToken = lastTriviaWasNewLine.
indentToken = lastTriviaWasNewLine
}
} else {
indentToken = lineAction == LineActionLineAdded
Expand Down
18 changes: 18 additions & 0 deletions internal/fourslash/_scripts/convertFourslash.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,24 @@ function parseUserPreferences(arg: ts.ObjectLiteralExpression): string | undefin
case "preferTypeOnlyAutoImports":
preferences.push(`PreferTypeOnlyAutoImports: ${prop.initializer.getText()}`);
break;
case "organizeImportsTypeOrder":
if (!ts.isStringLiteralLike(prop.initializer)) {
return undefined;
}
switch (prop.initializer.text) {
case "last":
preferences.push(`OrganizeImportsTypeOrder: lsutil.OrganizeImportsTypeOrderLast`);
break;
case "inline":
preferences.push(`OrganizeImportsTypeOrder: lsutil.OrganizeImportsTypeOrderInline`);
break;
case "first":
preferences.push(`OrganizeImportsTypeOrder: lsutil.OrganizeImportsTypeOrderFirst`);
break;
default:
return undefined;
}
break;
case "autoImportFileExcludePatterns":
const arrayArg = getArrayLiteralExpression(prop.initializer);
if (!arrayArg) {
Expand Down
Loading
Loading