Skip to content

Commit 56ed071

Browse files
Re-check only the files an edit affects
A pull re-checked every file of every project in scope, however little had changed since the last one. Watch mode already solves this, so a pull now asks through the same thing it uses: incremental.Program tracks which files changed against the previous program and which files those changes reach, re-checks only those, and answers for the rest from what it cached. That brings its declaration signature comparison with it, so an edit that leaves a file's public shape alone stops at that file rather than travelling to its importers. Kept per project and rebuilt only when the project's program is, chained to the previous one so it can work out the change set. Suggestion diagnostics are no longer reported for files a pull covers: nothing caches them, so asking would re-check every file and undo all of this. A file the editor has open still reports them, since it is pulled directly. Measured on a chain of 300 files, editing the file everything depends on: 154ms to check the project, 20ms to answer the next pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4ce4581 commit 56ed071

5 files changed

Lines changed: 175 additions & 35 deletions

File tree

tsc/internal/ls/diagnostics.go

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,16 @@ func worstCategory(diags []*ast.Diagnostic) diagnostics.Category {
162162
return worst
163163
}
164164

165-
// WorkspaceDiagnosticsForProject checks the whole program at once and returns what each file it
166-
// owns should report. Checking every file in one call lets the program split the work across the
167-
// checkers a build would use, and keeps the checker pool's own coordination rather than repeating
168-
// it per file; the diagnostics come back keyed by the file they belong to.
169-
func (l *LanguageService) WorkspaceDiagnosticsForProject(ctx context.Context, files []*ast.SourceFile) map[*ast.SourceFile][]*lsproto.Diagnostic {
165+
// WorkspaceDiagnosticsForProject checks a project in one call and returns what each of its files
166+
// should report, keyed by file. Checking everything in one call lets the program split the work
167+
// across the checkers a build would use and keeps the pool's own coordination rather than repeating
168+
// it per file.
169+
//
170+
// The program is passed in rather than taken from the language service because a sweep hands over
171+
// the incremental view of it, which re-checks only the files a change reached and serves the rest
172+
// from what it cached last time. Suggestions are left out: nothing caches them, so asking would
173+
// re-check every file and undo that.
174+
func (l *LanguageService) WorkspaceDiagnosticsForProject(ctx context.Context, program compiler.ProgramLike, files []*ast.SourceFile) map[*ast.SourceFile][]*lsproto.Diagnostic {
170175
reports := make(map[*ast.SourceFile][]*lsproto.Diagnostic, len(files))
171176
if l.UserPreferences().EnableValidation.IsFalse() {
172177
for _, file := range files {
@@ -176,23 +181,17 @@ func (l *LanguageService) WorkspaceDiagnosticsForProject(ctx context.Context, fi
176181
}
177182

178183
byFile := make(map[*ast.SourceFile][]*ast.Diagnostic, len(files))
179-
for _, diagnostics := range [][]*ast.Diagnostic{
180-
l.program.GetSyntacticDiagnostics(ctx, nil),
181-
l.program.GetSemanticDiagnostics(ctx, nil),
182-
l.program.GetSuggestionDiagnostics(ctx, nil),
183-
} {
184+
collect := func(diagnostics []*ast.Diagnostic) {
184185
for _, diagnostic := range diagnostics {
185186
if file := diagnostic.File(); file != nil {
186187
byFile[file] = append(byFile[file], diagnostic)
187188
}
188189
}
189190
}
190-
if l.program.Options().GetEmitDeclarations() {
191-
for _, diagnostic := range l.program.GetDeclarationDiagnostics(ctx, nil) {
192-
if file := diagnostic.File(); file != nil {
193-
byFile[file] = append(byFile[file], diagnostic)
194-
}
195-
}
191+
collect(program.GetSyntacticDiagnostics(ctx, nil))
192+
collect(program.GetSemanticDiagnostics(ctx, nil))
193+
if program.Options().GetEmitDeclarations() {
194+
collect(program.GetDeclarationDiagnostics(ctx, nil))
196195
}
197196

198197
for _, file := range files {

tsc/internal/lsp/server.go

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,24 +63,25 @@ func NewServer(opts *ServerOptions) *Server {
6363
}
6464

6565
s := &Server{
66-
r: opts.In,
67-
w: opts.Out,
68-
stderr: opts.Err,
69-
requestQueue: newDynamicQueue[*lsproto.RequestMessage](),
70-
outgoingQueue: newDynamicQueue[*lsproto.Message](),
71-
pendingClientRequests: make(map[jsonrpc.ID]pendingClientRequest),
72-
pendingServerRequests: make(map[jsonrpc.ID]chan *lsproto.ResponseMessage),
73-
cwd: opts.Cwd,
74-
fs: opts.FS,
75-
defaultLibraryPath: opts.DefaultLibraryPath,
76-
typingsLocation: opts.TypingsLocation,
77-
parseCache: opts.ParseCache,
78-
npmInstall: opts.NpmInstall,
79-
spawn: opts.Spawn,
80-
startWatchdog: opts.SetParentProcessID,
81-
initComplete: make(chan struct{}),
82-
progressDelay: opts.ProgressDelay,
83-
workspaceDiagnostics: newWorkspaceDiagnosticsCache(),
66+
r: opts.In,
67+
w: opts.Out,
68+
stderr: opts.Err,
69+
requestQueue: newDynamicQueue[*lsproto.RequestMessage](),
70+
outgoingQueue: newDynamicQueue[*lsproto.Message](),
71+
pendingClientRequests: make(map[jsonrpc.ID]pendingClientRequest),
72+
pendingServerRequests: make(map[jsonrpc.ID]chan *lsproto.ResponseMessage),
73+
cwd: opts.Cwd,
74+
fs: opts.FS,
75+
defaultLibraryPath: opts.DefaultLibraryPath,
76+
typingsLocation: opts.TypingsLocation,
77+
parseCache: opts.ParseCache,
78+
npmInstall: opts.NpmInstall,
79+
spawn: opts.Spawn,
80+
startWatchdog: opts.SetParentProcessID,
81+
initComplete: make(chan struct{}),
82+
progressDelay: opts.ProgressDelay,
83+
workspaceDiagnostics: newWorkspaceDiagnosticsCache(),
84+
workspaceDiagnosticsPrograms: newWorkspaceDiagnosticsPrograms(),
8485
}
8586
s.logger = newLogger(s)
8687

@@ -256,6 +257,10 @@ type Server struct {
256257
// produced the result id a client holds for each file.
257258
workspaceDiagnostics *workspaceDiagnosticsCache
258259

260+
// workspaceDiagnosticsPrograms remembers what each project looked like at the last pull, so a
261+
// pull re-checks only the files a change reached.
262+
workspaceDiagnosticsPrograms *workspaceDiagnosticsPrograms
263+
259264
workspaceDiagnosticsRegistrationMu sync.Mutex
260265
workspaceDiagnosticsRegistered bool
261266
}

tsc/internal/lsp/server_workspacediagnostics_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,3 +930,66 @@ func TestWorkspaceDiagnosticsDependentsAreTransitive(t *testing.T) {
930930
"file:///home/projects/unrelated/index.ts",
931931
})
932932
}
933+
934+
// An edit should cost what it affects, not what the project contains. These files form a chain
935+
// where each consumes the previous file's interface, so widening one breaks its direct importer and
936+
// nothing beyond it.
937+
func TestWorkspaceDiagnosticsRechecksOnlyWhatAnEditAffects(t *testing.T) {
938+
t.Parallel()
939+
940+
if !bundled.Embedded {
941+
t.Skip("bundled files are not embedded")
942+
}
943+
944+
const n = 12
945+
body := func(f int, extra string) string {
946+
prev := ""
947+
if f > 0 {
948+
prev = fmt.Sprintf("import type { I%d } from \"./f%d.js\";\nexport const uses%d: I%d = { a: \"x\", b: %d };\n", f-1, f-1, f, f-1, f)
949+
}
950+
return fmt.Sprintf("%sexport interface I%d { a: string; b: number%s }\n", prev, f, extra)
951+
}
952+
files := map[string]string{"/home/projects/tsconfig.json": `{"compilerOptions":{"strict":true}}`}
953+
for f := range n {
954+
files[fmt.Sprintf("/home/projects/f%d.ts", f)] = body(f, "")
955+
}
956+
957+
client, _ := initWorkspaceDiagnosticsClient(t, files)
958+
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
959+
TextDocument: &lsproto.TextDocumentItem{
960+
Uri: "file:///home/projects/f0.ts", LanguageId: "typescript", Version: 1, Text: body(0, ""),
961+
},
962+
})
963+
964+
first := pullWorkspaceDiagnostics(t, client, &lsproto.WorkspaceDiagnosticParams{
965+
PreviousResultIds: []lsproto.PreviousResultId{},
966+
})
967+
var ids []lsproto.PreviousResultId
968+
for _, item := range first.Items {
969+
if full := item.FullDocumentDiagnosticReport; full != nil && full.ResultId != nil {
970+
ids = append(ids, lsproto.PreviousResultId{Uri: full.Uri, Value: *full.ResultId})
971+
}
972+
}
973+
974+
// Requiring a new member of I0 breaks f1, which builds one, and leaves f2 onwards alone.
975+
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{
976+
TextDocument: lsproto.VersionedTextDocumentIdentifier{Uri: "file:///home/projects/f0.ts", Version: 2},
977+
ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{
978+
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: body(0, "; c: string")}},
979+
},
980+
})
981+
982+
second := pullWorkspaceDiagnostics(t, client, &lsproto.WorkspaceDiagnosticParams{PreviousResultIds: ids})
983+
984+
var reportedInFull []string
985+
for _, item := range second.Items {
986+
if full := item.FullDocumentDiagnosticReport; full != nil {
987+
reportedInFull = append(reportedInFull, string(full.Uri))
988+
}
989+
}
990+
assert.DeepEqual(t, reportedInFull, []string{"file:///home/projects/f1.ts"})
991+
992+
broken := findFullReport(t, second.Items, "file:///home/projects/f1.ts")
993+
assert.Equal(t, len(broken.Items), 1)
994+
assert.Assert(t, strings.Contains(broken.Items[0].Message.AsString(), "c"))
995+
}

tsc/internal/lsp/workspacediagnostics.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ func (s *Server) computeWorkspaceDiagnostics(ctx context.Context, params *lsprot
9898

9999
if run.collected {
100100
s.workspaceDiagnostics.retain(&run.reported)
101+
s.workspaceDiagnosticsPrograms.retain(&run.projects)
101102
if s.logger.IsVerbose() {
102103
stats := s.workspaceDiagnostics.stats()
103104
s.logger.Logf("workspace diagnostics: reported %d files, cached %d files across %d projects",
@@ -122,6 +123,8 @@ type workspaceDiagnosticsRun struct {
122123
previous map[lsproto.DocumentUri]string
123124
// Documents already covered, so a file in several projects is reported once.
124125
reported collections.Set[lsproto.DocumentUri]
126+
// Projects this pull was responsible for, so the ones it no longer covers can be let go of.
127+
projects collections.Set[tspath.Path]
125128

126129
// Reports not yet flushed; without a partial result token this holds all of them.
127130
pending []workspaceDiagnosticReport
@@ -248,7 +251,10 @@ func (r *workspaceDiagnosticsRun) checkProject(snapshot *project.Snapshot, pf wo
248251
files = append(files, file)
249252
}
250253
}
251-
reports := pf.languageService.WorkspaceDiagnosticsForProject(r.ctx, files)
254+
// Ask through the incremental view, so a change is re-checked where it landed rather than
255+
// across the whole project.
256+
program := r.server.workspaceDiagnosticsPrograms.forProject(pf.project.Id(), pf.languageService.GetProgram())
257+
reports := pf.languageService.WorkspaceDiagnosticsForProject(r.ctx, program, files)
252258
if r.ctx.Err() != nil {
253259
return false
254260
}
@@ -264,6 +270,8 @@ func (r *workspaceDiagnosticsRun) checkProject(snapshot *project.Snapshot, pf wo
264270
// emitProject hands a finished project's reports to the client and remembers which program version
265271
// produced each result id, so the next pull can skip the file.
266272
func (r *workspaceDiagnosticsRun) emitProject(pf workspaceDiagnosticsProject) {
273+
// Recorded here rather than while checking, since emitting is what runs in project order.
274+
r.projects.Add(pf.project.Id())
267275
for j, report := range pf.reports {
268276
if pf.files[j] != nil {
269277
r.filesDone++
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package lsp
2+
3+
import (
4+
"sync"
5+
6+
"github.com/microsoft/TypeScript/tsc/internal/collections"
7+
"github.com/microsoft/TypeScript/tsc/internal/compiler"
8+
"github.com/microsoft/TypeScript/tsc/internal/execute/incremental"
9+
"github.com/microsoft/TypeScript/tsc/internal/tspath"
10+
)
11+
12+
// workspaceDiagnosticsPrograms keeps, per project, the incremental view of its program that watch
13+
// mode builds: which files changed since the last one, which files those changes reach, and the
14+
// diagnostics of everything they did not. A sweep asks through that view, so an edit re-checks the
15+
// files it affects instead of the project.
16+
//
17+
// The view is rebuilt when the project's program is, chained to the previous one so it can tell
18+
// what changed. A program that has not been replaced keeps its view, since nothing has changed for
19+
// it to find.
20+
type workspaceDiagnosticsPrograms struct {
21+
mu sync.Mutex
22+
entries map[tspath.Path]workspaceDiagnosticsProgram
23+
}
24+
25+
type workspaceDiagnosticsProgram struct {
26+
// The program the view was built for, to notice when the project has been rebuilt.
27+
program *compiler.Program
28+
incremental *incremental.Program
29+
}
30+
31+
func newWorkspaceDiagnosticsPrograms() *workspaceDiagnosticsPrograms {
32+
return &workspaceDiagnosticsPrograms{entries: map[tspath.Path]workspaceDiagnosticsProgram{}}
33+
}
34+
35+
// forProject returns the incremental view of a project's program, building it from the previous one
36+
// when the project has been rebuilt.
37+
func (p *workspaceDiagnosticsPrograms) forProject(project tspath.Path, program *compiler.Program) *incremental.Program {
38+
p.mu.Lock()
39+
defer p.mu.Unlock()
40+
if entry, ok := p.entries[project]; ok {
41+
if entry.program == program {
42+
return entry.incremental
43+
}
44+
// Chained to the old view, which is what lets it work out the change set. A nil host is
45+
// enough because a pull never emits, which is all the host is for.
46+
next := incremental.NewProgram(program, entry.incremental, nil, nil, false)
47+
p.entries[project] = workspaceDiagnosticsProgram{program: program, incremental: next}
48+
return next
49+
}
50+
first := incremental.NewProgram(program, nil, nil, nil, false)
51+
p.entries[project] = workspaceDiagnosticsProgram{program: program, incremental: first}
52+
return first
53+
}
54+
55+
// retain drops the views of projects a sweep no longer reports on. Each holds the diagnostics of
56+
// every file in its project, so they cannot be kept for projects that have gone away.
57+
func (p *workspaceDiagnosticsPrograms) retain(seen *collections.Set[tspath.Path]) {
58+
p.mu.Lock()
59+
defer p.mu.Unlock()
60+
for project := range p.entries {
61+
if !seen.Has(project) {
62+
delete(p.entries, project)
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)