kittenipc/kitcom/internal/tsgo/execute/incremental/affectedfileshandler.go
2025-10-15 10:12:44 +03:00

385 lines
15 KiB
Go

package incremental
import (
"context"
"maps"
"slices"
"sync"
"sync/atomic"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ast"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/checker"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/compiler"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
)
type dtsMayChange map[tspath.Path]FileEmitKind
func (c dtsMayChange) addFileToAffectedFilesPendingEmit(filePath tspath.Path, emitKind FileEmitKind) {
c[filePath] = emitKind
}
type updatedSignature struct {
mu sync.Mutex
signature string
kind SignatureUpdateKind
}
type affectedFilesHandler struct {
ctx context.Context
program *Program
hasAllFilesExcludingDefaultLibraryFile atomic.Bool
updatedSignatures collections.SyncMap[tspath.Path, *updatedSignature]
dtsMayChange []dtsMayChange
filesToRemoveDiagnostics collections.SyncSet[tspath.Path]
cleanedDiagnosticsOfLibFiles sync.Once
seenFileAndExportsOfFile collections.SyncMap[tspath.Path, bool]
}
func (h *affectedFilesHandler) getDtsMayChange(affectedFilePath tspath.Path, affectedFileEmitKind FileEmitKind) dtsMayChange {
result := dtsMayChange(map[tspath.Path]FileEmitKind{affectedFilePath: affectedFileEmitKind})
h.dtsMayChange = append(h.dtsMayChange, result)
return result
}
func (h *affectedFilesHandler) isChangedSignature(path tspath.Path) bool {
newSignature, _ := h.updatedSignatures.Load(path)
// This method is called after updating signatures of that path, so signature is present in updatedSignatures
// And is already calculated, so no need to lock and unlock mutex on the entry
oldInfo, _ := h.program.snapshot.fileInfos.Load(path)
return newSignature.signature != oldInfo.signature
}
func (h *affectedFilesHandler) removeSemanticDiagnosticsOf(path tspath.Path) {
h.filesToRemoveDiagnostics.Add(path)
}
func (h *affectedFilesHandler) removeDiagnosticsOfLibraryFiles() {
h.cleanedDiagnosticsOfLibFiles.Do(func() {
for _, file := range h.program.GetSourceFiles() {
if h.program.program.IsSourceFileDefaultLibrary(file.Path()) && !checker.SkipTypeChecking(file, h.program.snapshot.options, h.program.program, true) {
h.removeSemanticDiagnosticsOf(file.Path())
}
}
})
}
func (h *affectedFilesHandler) computeDtsSignature(file *ast.SourceFile) string {
var signature string
h.program.program.Emit(h.ctx, compiler.EmitOptions{
TargetSourceFile: file,
EmitOnly: compiler.EmitOnlyForcedDts,
WriteFile: func(fileName string, text string, writeByteOrderMark bool, data *compiler.WriteFileData) error {
if !tspath.IsDeclarationFileName(fileName) {
panic("File extension for signature expected to be dts, got : " + fileName)
}
signature = h.program.snapshot.computeSignatureWithDiagnostics(file, text, data)
return nil
},
})
return signature
}
func (h *affectedFilesHandler) updateShapeSignature(file *ast.SourceFile, useFileVersionAsSignature bool) bool {
update := &updatedSignature{}
update.mu.Lock()
defer update.mu.Unlock()
// If we have cached the result for this file, that means hence forth we should assume file shape is uptodate
if existing, ok := h.updatedSignatures.LoadOrStore(file.Path(), update); ok {
// Ensure calculations for existing ones are complete before using the value
existing.mu.Lock()
defer existing.mu.Unlock()
return false
}
info, _ := h.program.snapshot.fileInfos.Load(file.Path())
prevSignature := info.signature
if !file.IsDeclarationFile && !useFileVersionAsSignature {
update.signature = h.computeDtsSignature(file)
}
// Default is to use file version as signature
if update.signature == "" {
update.signature = info.version
update.kind = SignatureUpdateKindUsedVersion
}
return update.signature != prevSignature
}
func (h *affectedFilesHandler) getFilesAffectedBy(path tspath.Path) []*ast.SourceFile {
file := h.program.program.GetSourceFileByPath(path)
if file == nil {
return nil
}
if !h.updateShapeSignature(file, false) {
return []*ast.SourceFile{file}
}
if info, _ := h.program.snapshot.fileInfos.Load(file.Path()); info.affectsGlobalScope {
h.hasAllFilesExcludingDefaultLibraryFile.Store(true)
h.program.snapshot.getAllFilesExcludingDefaultLibraryFile(h.program.program, file)
}
if h.program.snapshot.options.IsolatedModules.IsTrue() {
return []*ast.SourceFile{file}
}
// Now we need to if each file in the referencedBy list has a shape change as well.
// Because if so, its own referencedBy files need to be saved as well to make the
// emitting result consistent with files on disk.
seenFileNamesMap := h.forEachFileReferencedBy(
file,
func(currentFile *ast.SourceFile, currentPath tspath.Path) (queueForFile bool, fastReturn bool) {
// If the current file is not nil and has a shape change, we need to queue it for processing
if currentFile != nil && h.updateShapeSignature(currentFile, false) {
return true, false
}
return false, false
},
)
// Return array of values that needs emit
return core.Filter(slices.Collect(maps.Values(seenFileNamesMap)), func(file *ast.SourceFile) bool {
return file != nil
})
}
func (h *affectedFilesHandler) forEachFileReferencedBy(file *ast.SourceFile, fn func(currentFile *ast.SourceFile, currentPath tspath.Path) (queueForFile bool, fastReturn bool)) map[tspath.Path]*ast.SourceFile {
// Now we need to if each file in the referencedBy list has a shape change as well.
// Because if so, its own referencedBy files need to be saved as well to make the
// emitting result consistent with files on disk.
seenFileNamesMap := map[tspath.Path]*ast.SourceFile{}
// Start with the paths this file was referenced by
seenFileNamesMap[file.Path()] = file
queue := slices.Collect(h.program.snapshot.referencedMap.getReferencedBy(file.Path()))
for len(queue) > 0 {
currentPath := queue[len(queue)-1]
queue = queue[:len(queue)-1]
if _, ok := seenFileNamesMap[currentPath]; !ok {
currentFile := h.program.program.GetSourceFileByPath(currentPath)
seenFileNamesMap[currentPath] = currentFile
queueForFile, fastReturn := fn(currentFile, currentPath)
if fastReturn {
return seenFileNamesMap
}
if queueForFile {
for ref := range h.program.snapshot.referencedMap.getReferencedBy(currentFile.Path()) {
queue = append(queue, ref)
}
}
}
}
return seenFileNamesMap
}
// Handles semantic diagnostics and dts emit for affectedFile and files, that are referencing modules that export entities from affected file
// This is because even though js emit doesnt change, dts emit / type used can change resulting in need for dts emit and js change
func (h *affectedFilesHandler) handleDtsMayChangeOfAffectedFile(dtsMayChange dtsMayChange, affectedFile *ast.SourceFile) {
h.removeSemanticDiagnosticsOf(affectedFile.Path())
// If affected files is everything except default library, then nothing more to do
if h.hasAllFilesExcludingDefaultLibraryFile.Load() {
h.removeDiagnosticsOfLibraryFiles()
// When a change affects the global scope, all files are considered to be affected without updating their signature
// That means when affected file is handled, its signature can be out of date
// To avoid this, ensure that we update the signature for any affected file in this scenario.
h.updateShapeSignature(affectedFile, false)
return
}
if h.program.snapshot.options.AssumeChangesOnlyAffectDirectDependencies.IsTrue() {
return
}
// Iterate on referencing modules that export entities from affected file and delete diagnostics and add pending emit
// If there was change in signature (dts output) for the changed file,
// then only we need to handle pending file emit
if !h.program.snapshot.changedFilesSet.Has(affectedFile.Path()) ||
!h.isChangedSignature(affectedFile.Path()) {
return
}
// Since isolated modules dont change js files, files affected by change in signature is itself
// But we need to cleanup semantic diagnostics and queue dts emit for affected files
if h.program.snapshot.options.IsolatedModules.IsTrue() {
h.forEachFileReferencedBy(
affectedFile,
func(currentFile *ast.SourceFile, currentPath tspath.Path) (queueForFile bool, fastReturn bool) {
if h.handleDtsMayChangeOfGlobalScope(dtsMayChange, currentPath /*invalidateJsFiles*/, false) {
return false, true
}
h.handleDtsMayChangeOf(dtsMayChange, currentPath /*invalidateJsFiles*/, false)
if h.isChangedSignature(currentPath) {
return true, false
}
return false, false
},
)
}
invalidateJsFiles := false
var typeChecker *checker.Checker
var done func()
// If exported const enum, we need to ensure that js files are emitted as well since the const enum value changed
if affectedFile.Symbol != nil {
for _, exported := range affectedFile.Symbol.Exports {
if exported.Flags&ast.SymbolFlagsConstEnum != 0 {
invalidateJsFiles = true
break
}
if typeChecker == nil {
typeChecker, done = h.program.program.GetTypeCheckerForFile(h.ctx, affectedFile)
}
aliased := checker.SkipAlias(exported, typeChecker)
if aliased == exported {
continue
}
if (aliased.Flags & ast.SymbolFlagsConstEnum) != 0 {
if slices.ContainsFunc(aliased.Declarations, func(d *ast.Node) bool {
return ast.GetSourceFileOfNode(d) == affectedFile
}) {
invalidateJsFiles = true
break
}
}
}
}
if done != nil {
done()
}
// Go through files that reference affected file and handle dts emit and semantic diagnostics for them and their references
for exportedFromPath := range h.program.snapshot.referencedMap.getReferencedBy(affectedFile.Path()) {
if h.handleDtsMayChangeOfGlobalScope(dtsMayChange, exportedFromPath, invalidateJsFiles) {
return
}
for filePath := range h.program.snapshot.referencedMap.getReferencedBy(exportedFromPath) {
if h.handleDtsMayChangeOfFileAndExportsOfFile(dtsMayChange, filePath, invalidateJsFiles) {
return
}
}
}
}
func (h *affectedFilesHandler) handleDtsMayChangeOfFileAndExportsOfFile(dtsMayChange dtsMayChange, filePath tspath.Path, invalidateJsFiles bool) bool {
if existing, loaded := h.seenFileAndExportsOfFile.LoadOrStore(filePath, invalidateJsFiles); loaded && (existing || !invalidateJsFiles) {
return false
}
if h.handleDtsMayChangeOfGlobalScope(dtsMayChange, filePath, invalidateJsFiles) {
return true
}
h.handleDtsMayChangeOf(dtsMayChange, filePath, invalidateJsFiles)
// Remove the diagnostics of files that import this file and handle all its exports too
for referencingFilePath := range h.program.snapshot.referencedMap.getReferencedBy(filePath) {
if h.handleDtsMayChangeOfFileAndExportsOfFile(dtsMayChange, referencingFilePath, invalidateJsFiles) {
return true
}
}
return false
}
func (h *affectedFilesHandler) handleDtsMayChangeOfGlobalScope(dtsMayChange dtsMayChange, filePath tspath.Path, invalidateJsFiles bool) bool {
if info, ok := h.program.snapshot.fileInfos.Load(filePath); !ok || !info.affectsGlobalScope {
return false
}
// Every file needs to be handled
for _, file := range h.program.snapshot.getAllFilesExcludingDefaultLibraryFile(h.program.program, nil) {
h.handleDtsMayChangeOf(dtsMayChange, file.Path(), invalidateJsFiles)
}
h.removeDiagnosticsOfLibraryFiles()
return true
}
// Handle the dts may change, so they need to be added to pending emit if dts emit is enabled,
// Also we need to make sure signature is updated for these files
func (h *affectedFilesHandler) handleDtsMayChangeOf(dtsMayChange dtsMayChange, path tspath.Path, invalidateJsFiles bool) {
if h.program.snapshot.changedFilesSet.Has(path) {
return
}
file := h.program.program.GetSourceFileByPath(path)
if file == nil {
return
}
h.removeSemanticDiagnosticsOf(path)
// Even though the js emit doesnt change and we are already handling dts emit and semantic diagnostics
// we need to update the signature to reflect correctness of the signature(which is output d.ts emit) of this file
// This ensures that we dont later during incremental builds considering wrong signature.
// Eg where this also is needed to ensure that .tsbuildinfo generated by incremental build should be same as if it was first fresh build
// But we avoid expensive full shape computation, as using file version as shape is enough for correctness.
h.updateShapeSignature(file, true)
// If not dts emit, nothing more to do
if invalidateJsFiles {
dtsMayChange.addFileToAffectedFilesPendingEmit(path, GetFileEmitKind(h.program.snapshot.options))
} else if h.program.snapshot.options.GetEmitDeclarations() {
dtsMayChange.addFileToAffectedFilesPendingEmit(path, core.IfElse(h.program.snapshot.options.DeclarationMap.IsTrue(), FileEmitKindAllDts, FileEmitKindDts))
}
}
func (h *affectedFilesHandler) updateSnapshot() {
if h.ctx.Err() != nil {
return
}
h.updatedSignatures.Range(func(filePath tspath.Path, update *updatedSignature) bool {
if info, ok := h.program.snapshot.fileInfos.Load(filePath); ok {
info.signature = update.signature
if h.program.testingData != nil {
h.program.testingData.UpdatedSignatureKinds[filePath] = update.kind
}
}
return true
})
h.filesToRemoveDiagnostics.Range(func(file tspath.Path) bool {
h.program.snapshot.semanticDiagnosticsPerFile.Delete(file)
return true
})
for _, change := range h.dtsMayChange {
for filePath, emitKind := range change {
h.program.snapshot.addFileToAffectedFilesPendingEmit(filePath, emitKind)
}
}
h.program.snapshot.changedFilesSet = collections.SyncSet[tspath.Path]{}
h.program.snapshot.buildInfoEmitPending.Store(true)
}
func collectAllAffectedFiles(ctx context.Context, program *Program) {
if program.snapshot.changedFilesSet.Size() == 0 {
return
}
handler := affectedFilesHandler{ctx: ctx, program: program}
wg := core.NewWorkGroup(handler.program.program.SingleThreaded())
var result collections.SyncSet[*ast.SourceFile]
program.snapshot.changedFilesSet.Range(func(file tspath.Path) bool {
wg.Queue(func() {
for _, affectedFile := range handler.getFilesAffectedBy(file) {
result.Add(affectedFile)
}
})
return true
})
wg.RunAndWait()
if ctx.Err() != nil {
return
}
// For all the affected files, get all the files that would need to change their dts or js files,
// update their diagnostics
wg = core.NewWorkGroup(program.program.SingleThreaded())
emitKind := GetFileEmitKind(program.snapshot.options)
result.Range(func(file *ast.SourceFile) bool {
// remove the cached semantic diagnostics and handle dts emit and js emit if needed
dtsMayChange := handler.getDtsMayChange(file.Path(), emitKind)
wg.Queue(func() {
handler.handleDtsMayChangeOfAffectedFile(dtsMayChange, file)
})
return true
})
wg.RunAndWait()
// Update the snapshot with the new state
handler.updateSnapshot()
}