kittenipc/kitcom/internal/tsgo/ls/changetracker.go
2025-10-15 10:12:44 +03:00

337 lines
13 KiB
Go

package ls
import (
"context"
"slices"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ast"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/astnav"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/format"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp/lsproto"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/printer"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/scanner"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/stringutil"
)
type changeNodeOptions struct {
// Text to be inserted before the new node
prefix string
// Text to be inserted after the new node
suffix string
// Text of inserted node will be formatted with this indentation, otherwise indentation will be inferred from the old node
indentation *int
// Text of inserted node will be formatted with this delta, otherwise delta will be inferred from the new node kind
delta *int
leadingTriviaOption
trailingTriviaOption
joiner string
}
type leadingTriviaOption int
const (
leadingTriviaOptionNone leadingTriviaOption = 0
leadingTriviaOptionExclude leadingTriviaOption = 1
leadingTriviaOptionIncludeAll leadingTriviaOption = 2
leadingTriviaOptionJSDoc leadingTriviaOption = 3
leadingTriviaOptionStartLine leadingTriviaOption = 4
)
type trailingTriviaOption int
const (
trailingTriviaOptionNone trailingTriviaOption = 0
trailingTriviaOptionExclude trailingTriviaOption = 1
trailingTriviaOptionExcludeWhitespace trailingTriviaOption = 2
trailingTriviaOptionInclude trailingTriviaOption = 3
)
type trackerEditKind int
const (
trackerEditKindText trackerEditKind = 1
trackerEditKindRemove trackerEditKind = 2
trackerEditKindReplaceWithSingleNode trackerEditKind = 3
trackerEditKindReplaceWithMultipleNodes trackerEditKind = 4
)
type trackerEdit struct {
kind trackerEditKind
lsproto.Range
NewText string // kind == text
*ast.Node // single
nodes []*ast.Node // multiple
options changeNodeOptions
}
type changeTracker struct {
// initialized with
formatSettings *format.FormatCodeSettings
newLine string
ls *LanguageService
ctx context.Context
*printer.EmitContext
*ast.NodeFactory
changes *collections.MultiMap[*ast.SourceFile, *trackerEdit]
// created during call to getChanges
writer *printer.ChangeTrackerWriter
// printer
}
func (ls *LanguageService) newChangeTracker(ctx context.Context) *changeTracker {
emitContext := printer.NewEmitContext()
newLine := ls.GetProgram().Options().NewLine.GetNewLineCharacter()
formatCodeSettings := format.GetDefaultFormatCodeSettings(newLine) // !!! format.GetFormatCodeSettingsFromContext(ctx),
ctx = format.WithFormatCodeSettings(ctx, formatCodeSettings, newLine)
return &changeTracker{
ls: ls,
EmitContext: emitContext,
NodeFactory: &emitContext.Factory.NodeFactory,
changes: &collections.MultiMap[*ast.SourceFile, *trackerEdit]{},
ctx: ctx,
formatSettings: formatCodeSettings,
newLine: newLine,
}
}
// !!! address strada note
// - Note: after calling this, the TextChanges object must be discarded!
func (ct *changeTracker) getChanges() map[string][]*lsproto.TextEdit {
// !!! finishDeleteDeclarations
// !!! finishClassesWithNodesInsertedAtStart
changes := ct.getTextChangesFromChanges()
// !!! changes for new files
return changes
}
func (ct *changeTracker) replaceNode(sourceFile *ast.SourceFile, oldNode *ast.Node, newNode *ast.Node, options *changeNodeOptions) {
if options == nil {
// defaults to `useNonAdjustedPositions`
options = &changeNodeOptions{
leadingTriviaOption: leadingTriviaOptionExclude,
trailingTriviaOption: trailingTriviaOptionExclude,
}
}
ct.replaceRange(sourceFile, ct.getAdjustedRange(sourceFile, oldNode, oldNode, options.leadingTriviaOption, options.trailingTriviaOption), newNode, *options)
}
func (ct *changeTracker) replaceRange(sourceFile *ast.SourceFile, lsprotoRange lsproto.Range, newNode *ast.Node, options changeNodeOptions) {
ct.changes.Add(sourceFile, &trackerEdit{kind: trackerEditKindReplaceWithSingleNode, Range: lsprotoRange, options: options, Node: newNode})
}
func (ct *changeTracker) replaceRangeWithText(sourceFile *ast.SourceFile, lsprotoRange lsproto.Range, text string) {
ct.changes.Add(sourceFile, &trackerEdit{kind: trackerEditKindText, Range: lsprotoRange, NewText: text})
}
func (ct *changeTracker) replaceRangeWithNodes(sourceFile *ast.SourceFile, lsprotoRange lsproto.Range, newNodes []*ast.Node, options changeNodeOptions) {
if len(newNodes) == 1 {
ct.replaceRange(sourceFile, lsprotoRange, newNodes[0], options)
return
}
ct.changes.Add(sourceFile, &trackerEdit{kind: trackerEditKindReplaceWithMultipleNodes, Range: lsprotoRange, nodes: newNodes, options: options})
}
func (ct *changeTracker) insertText(sourceFile *ast.SourceFile, pos lsproto.Position, text string) {
ct.replaceRangeWithText(sourceFile, lsproto.Range{Start: pos, End: pos}, text)
}
func (ct *changeTracker) insertNodeAt(sourceFile *ast.SourceFile, pos core.TextPos, newNode *ast.Node, options changeNodeOptions) {
lsPos := ct.ls.converters.PositionToLineAndCharacter(sourceFile, pos)
ct.replaceRange(sourceFile, lsproto.Range{Start: lsPos, End: lsPos}, newNode, options)
}
func (ct *changeTracker) insertNodesAt(sourceFile *ast.SourceFile, pos core.TextPos, newNodes []*ast.Node, options changeNodeOptions) {
lsPos := ct.ls.converters.PositionToLineAndCharacter(sourceFile, pos)
ct.replaceRangeWithNodes(sourceFile, lsproto.Range{Start: lsPos, End: lsPos}, newNodes, options)
}
func (ct *changeTracker) insertNodeAfter(sourceFile *ast.SourceFile, after *ast.Node, newNode *ast.Node) {
endPosition := ct.endPosForInsertNodeAfter(sourceFile, after, newNode)
ct.insertNodeAt(sourceFile, endPosition, newNode, ct.getInsertNodeAfterOptions(sourceFile, after))
}
func (ct *changeTracker) insertNodesAfter(sourceFile *ast.SourceFile, after *ast.Node, newNodes []*ast.Node) {
endPosition := ct.endPosForInsertNodeAfter(sourceFile, after, newNodes[0])
ct.insertNodesAt(sourceFile, endPosition, newNodes, ct.getInsertNodeAfterOptions(sourceFile, after))
}
func (ct *changeTracker) endPosForInsertNodeAfter(sourceFile *ast.SourceFile, after *ast.Node, newNode *ast.Node) core.TextPos {
if (needSemicolonBetween(after, newNode)) && (rune(sourceFile.Text()[after.End()-1]) != ';') {
// check if previous statement ends with semicolon
// if not - insert semicolon to preserve the code from changing the meaning due to ASI
endPos := ct.ls.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(after.End()))
ct.replaceRange(sourceFile,
lsproto.Range{Start: endPos, End: endPos},
sourceFile.GetOrCreateToken(ast.KindSemicolonToken, after.End(), after.End(), after.Parent),
changeNodeOptions{},
)
}
return core.TextPos(ct.getAdjustedEndPosition(sourceFile, after, trailingTriviaOptionNone))
}
/**
* This function should be used to insert nodes in lists when nodes don't carry separators as the part of the node range,
* i.e. arguments in arguments lists, parameters in parameter lists etc.
* Note that separators are part of the node in statements and class elements.
*/
func (ct *changeTracker) insertNodeInListAfter(sourceFile *ast.SourceFile, after *ast.Node, newNode *ast.Node, containingList []*ast.Node) {
if len(containingList) == 0 {
containingList = format.GetContainingList(after, sourceFile).Nodes
}
index := slices.Index(containingList, after)
if index < 0 {
return
}
if index != len(containingList)-1 {
// any element except the last one
// use next sibling as an anchor
if nextToken := astnav.GetTokenAtPosition(sourceFile, after.End()); nextToken != nil && isSeparator(after, nextToken) {
// for list
// a, b, c
// create change for adding 'e' after 'a' as
// - find start of next element after a (it is b)
// - use next element start as start and end position in final change
// - build text of change by formatting the text of node + whitespace trivia of b
// in multiline case it will work as
// a,
// b,
// c,
// result - '*' denotes leading trivia that will be inserted after new text (displayed as '#')
// a,
// insertedtext<separator>#
// ###b,
// c,
nextNode := containingList[index+1]
startPos := scanner.SkipTriviaEx(sourceFile.Text(), nextNode.Pos(), &scanner.SkipTriviaOptions{StopAfterLineBreak: true, StopAtComments: false})
// write separator and leading trivia of the next element as suffix
suffix := scanner.TokenToString(nextToken.Kind) + sourceFile.Text()[nextNode.End():startPos]
ct.insertNodeAt(sourceFile, core.TextPos(startPos), newNode, changeNodeOptions{suffix: suffix})
}
return
}
afterStart := astnav.GetStartOfNode(after, sourceFile, false)
afterStartLinePosition := format.GetLineStartPositionForPosition(afterStart, sourceFile)
// insert element after the last element in the list that has more than one item
// pick the element preceding the after element to:
// - pick the separator
// - determine if list is a multiline
multilineList := false
// if list has only one element then we'll format is as multiline if node has comment in trailing trivia, or as singleline otherwise
// i.e. var x = 1 // this is x
// | new element will be inserted at this position
separator := ast.KindCommaToken // SyntaxKind.CommaToken | SyntaxKind.SemicolonToken
if len(containingList) != 1 {
// otherwise, if list has more than one element, pick separator from the list
tokenBeforeInsertPosition := astnav.FindPrecedingToken(sourceFile, after.Pos())
separator = core.IfElse(isSeparator(after, tokenBeforeInsertPosition), tokenBeforeInsertPosition.Kind, ast.KindCommaToken)
// determine if list is multiline by checking lines of after element and element that precedes it.
afterMinusOneStartLinePosition := format.GetLineStartPositionForPosition(astnav.GetStartOfNode(containingList[index-1], sourceFile, false), sourceFile)
multilineList = afterMinusOneStartLinePosition != afterStartLinePosition
}
if hasCommentsBeforeLineBreak(sourceFile.Text(), after.End()) || printer.GetLinesBetweenPositions(sourceFile, containingList[0].Pos(), containingList[len(containingList)-1].End()) != 0 {
// in this case we'll always treat containing list as multiline
multilineList = true
}
separatorString := scanner.TokenToString(separator)
end := ct.ls.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(after.End()))
if !multilineList {
ct.replaceRange(sourceFile, lsproto.Range{Start: end, End: end}, newNode, changeNodeOptions{prefix: separatorString})
return
}
// insert separator immediately following the 'after' node to preserve comments in trailing trivia
// !!! formatcontext
ct.replaceRange(sourceFile, lsproto.Range{Start: end, End: end}, sourceFile.GetOrCreateToken(separator, after.End(), after.End()+len(separatorString), after.Parent), changeNodeOptions{})
// use the same indentation as 'after' item
indentation := format.FindFirstNonWhitespaceColumn(afterStartLinePosition, afterStart, sourceFile, ct.formatSettings)
// insert element before the line break on the line that contains 'after' element
insertPos := scanner.SkipTriviaEx(sourceFile.Text(), after.End(), &scanner.SkipTriviaOptions{StopAfterLineBreak: true, StopAtComments: false})
// find position before "\n" or "\r\n"
for insertPos != after.End() && stringutil.IsLineBreak(rune(sourceFile.Text()[insertPos-1])) {
insertPos--
}
insertLSPos := ct.ls.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(insertPos))
ct.replaceRange(
sourceFile,
lsproto.Range{Start: insertLSPos, End: insertLSPos},
newNode,
changeNodeOptions{
indentation: ptrTo(indentation),
prefix: ct.newLine,
},
)
}
func (ct *changeTracker) insertAtTopOfFile(sourceFile *ast.SourceFile, insert []*ast.Statement, blankLineBetween bool) {
if len(insert) == 0 {
return
}
pos := ct.getInsertionPositionAtSourceFileTop(sourceFile)
options := changeNodeOptions{}
if pos != 0 {
options.prefix = ct.newLine
}
if !stringutil.IsLineBreak(rune(sourceFile.Text()[pos])) {
options.suffix = ct.newLine
}
if blankLineBetween {
options.suffix += ct.newLine
}
if len(insert) == 1 {
ct.insertNodeAt(sourceFile, core.TextPos(pos), insert[0], options)
} else {
ct.insertNodesAt(sourceFile, core.TextPos(pos), insert, options)
}
}
func (ct *changeTracker) getInsertNodeAfterOptions(sourceFile *ast.SourceFile, node *ast.Node) changeNodeOptions {
newLineChar := ct.newLine
var options changeNodeOptions
switch node.Kind {
case ast.KindParameter:
// default opts
options = changeNodeOptions{}
case ast.KindClassDeclaration, ast.KindModuleDeclaration:
options = changeNodeOptions{prefix: newLineChar, suffix: newLineChar}
case ast.KindVariableDeclaration, ast.KindStringLiteral, ast.KindIdentifier:
options = changeNodeOptions{prefix: ", "}
case ast.KindPropertyAssignment:
options = changeNodeOptions{suffix: "," + newLineChar}
case ast.KindExportKeyword:
options = changeNodeOptions{prefix: " "}
default:
if !(ast.IsStatement(node) || ast.IsClassOrTypeElement(node)) {
// Else we haven't handled this kind of node yet -- add it
panic("unimplemented node type " + node.Kind.String() + " in changeTracker.getInsertNodeAfterOptions")
}
options = changeNodeOptions{suffix: newLineChar}
}
if node.End() == sourceFile.End() && ast.IsStatement(node) {
options.prefix = "\n" + options.prefix
}
return options
}