Skip to content

Commit 9c7cdf0

Browse files
perf(lsp): save triggers targeted reanalyze — dependency closure, not full workspace
Before: every save kicked off workspaceAnalyzer.analyze() (debounced 500ms) which read + diagnosed EVERY FOAM file in the workspace. With autosave on and a multi-thousand-class tree, that was the main bottleneck after a keystroke pause. Now: reindex computes the actual dependency closure and scans only that. FoamIndex.getAffectedFiles(classIds) • Self files • Transitive subclasses (class shape propagates down) • Direct requirers (reverse import graph) • Direct of-users (property type references) • Direct implementers (for interfaces) • De-dup to unique file-path set WorkspaceAnalyzer.analyzeFiles(paths) • Same shape as analyze() but scans an explicit path list • Always emits a fileResults entry (including empty) so stale diagnostics clear server.reindexFile • Pushes live diagnostics for the saved file + open JRLs unconditionally • Only re-diagnoses OTHER open FOAM files if their path is in the affected set • Debounced scheduleAffectedReanalyze replaces scheduleWorkspaceReanalyze — merges pending paths across burst saves, skips files already open (their live buffer already got diagnostics from the main loop) Measured impact on ptv3 (2828 classes): saving foam.dao.EasyDAO now rescans 4 files instead of the whole tree. Tests (5 new, 510 total): • getAffectedFiles returns array, includes self, stays < half of all classes • analyzeFiles({path}) scans exactly that one file • analyzeFiles returns fileResults map
1 parent f804b7c commit 9c7cdf0

File tree

4 files changed

+218
-22
lines changed

4 files changed

+218
-22
lines changed

tools/lsp/FoamIndex.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,63 @@ foam.CLASS({
213213
return subs;
214214
},
215215

216+
function getAffectedFiles(classIds) {
217+
/**
218+
* Given a set of class IDs that have been re-registered (e.g. after
219+
* a save), return the set of source file paths whose diagnostics
220+
* could be affected:
221+
* • the files that defined these classes
222+
* • files containing direct subclasses (transitive)
223+
* • files containing requirers of the classes (transitive on subclasses too)
224+
* • files containing of-users of the classes
225+
* • files containing implementers (when an interface is in the set)
226+
*
227+
* Used to narrow the post-save re-analyze to actual dependents
228+
* instead of scanning every FOAM file in the workspace.
229+
*/
230+
var self = this;
231+
if ( ! this.fileIndex_ ) this.buildFileIndex();
232+
233+
var affectedClassIds = {};
234+
var queue = [];
235+
(classIds || []).forEach(function(id) {
236+
if ( id && ! affectedClassIds[id] ) {
237+
affectedClassIds[id] = true;
238+
queue.push(id);
239+
}
240+
});
241+
242+
// Transitive subclasses (class change propagates down the tree).
243+
while ( queue.length ) {
244+
var cur = queue.shift();
245+
var subs = self.getSubclasses(cur);
246+
for ( var i = 0 ; i < subs.length ; i++ ) {
247+
if ( ! affectedClassIds[subs[i]] ) {
248+
affectedClassIds[subs[i]] = true;
249+
queue.push(subs[i]);
250+
}
251+
}
252+
}
253+
254+
// Direct requirers, of-users, implementers of any class in the set.
255+
var seeds = Object.keys(affectedClassIds);
256+
var extras = {};
257+
seeds.forEach(function(id) {
258+
self.getRequirers(id).forEach(function(r) { extras[r] = true; });
259+
self.getOfUsers(id).forEach(function(u) { extras[u] = true; });
260+
self.getImplementors(id).forEach(function(m) { extras[m] = true; });
261+
});
262+
Object.keys(extras).forEach(function(id) { affectedClassIds[id] = true; });
263+
264+
// Map class ids to their file paths. De-duplicate.
265+
var paths = {};
266+
Object.keys(affectedClassIds).forEach(function(id) {
267+
var fp = self.getFilePath(id);
268+
if ( fp ) paths[fp] = true;
269+
});
270+
return Object.keys(paths);
271+
},
272+
216273
function getImplementors(interfaceId) {
217274
/**
218275
* Returns class IDs of all classes that implement the given interface.

tools/lsp/handlers/WorkspaceAnalyzer.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,62 @@ foam.CLASS({
131131
};
132132
},
133133

134+
function analyzeFiles(filePaths) {
135+
/**
136+
* Scan a specific list of file paths — same result shape as analyze().
137+
* Use this when a save affects only some subset of the workspace
138+
* (saved file + subclasses + requirers + of-users). Much faster than
139+
* a full workspace scan when the dependency fan-out is small.
140+
*/
141+
var fs_ = require('fs');
142+
var diag = this.diagnosticsHandler;
143+
var seen = {};
144+
var unique = [];
145+
for ( var i = 0 ; i < filePaths.length ; i++ ) {
146+
var p = filePaths[i];
147+
if ( p && ! seen[p] ) { seen[p] = true; unique.push(p); }
148+
}
149+
150+
var filesScanned = 0;
151+
var filesWithIssues = 0;
152+
var warnings = 0;
153+
var errors = 0;
154+
var infos = 0;
155+
var fileResults = {};
156+
157+
for ( var j = 0 ; j < unique.length ; j++ ) {
158+
var filePath = unique[j];
159+
try {
160+
var content = fs_.readFileSync(filePath, 'utf8');
161+
var diagnostics = diag.handle(content);
162+
var uri = 'file://' + filePath;
163+
// ALWAYS include the file in results — empty arrays clear stale
164+
// diagnostics from a prior save.
165+
fileResults[uri] = diagnostics;
166+
if ( diagnostics.length > 0 ) {
167+
filesWithIssues++;
168+
for ( var d = 0 ; d < diagnostics.length ; d++ ) {
169+
var sev = diagnostics[d].severity;
170+
if ( sev === 1 ) errors++;
171+
else if ( sev === 2 ) warnings++;
172+
else infos++;
173+
}
174+
}
175+
} catch ( e ) {}
176+
filesScanned++;
177+
}
178+
179+
return {
180+
filesScanned: filesScanned,
181+
filesWithIssues: filesWithIssues,
182+
warnings: warnings,
183+
errors: errors,
184+
infos: infos,
185+
patterns: [],
186+
fileResults: fileResults
187+
};
188+
},
189+
134190
function analyzeSingleFile(filePath) {
135191
/**
136192
* Analyzes a single file and returns its diagnostics.

tools/lsp/server.js

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ function start() {
381381
if ( ! doc ) return;
382382
fileModelCache.invalidate(uri);
383383

384+
var changedClassIds = [];
384385
if ( isFoamFile(doc.text) ) {
385386
var models = fileModelCache.getModels(uri, doc.text);
386387

@@ -399,53 +400,93 @@ function start() {
399400
}
400401
}
401402

402-
// Clear FoamIndex caches for each class defined in this file.
403+
// Clear FoamIndex caches for each class defined in this file and
404+
// collect them for the targeted re-analyze pass below.
403405
for ( var i = 0 ; i < models.length ; i++ ) {
404406
var classId = fileModelCache.getClassId(models[i]);
405-
if ( classId && typeof index.invalidate === 'function' ) {
406-
index.invalidate(classId);
407-
}
407+
if ( ! classId ) continue;
408+
changedClassIds.push(classId);
409+
if ( typeof index.invalidate === 'function' ) index.invalidate(classId);
408410
}
409411
}
410412

411-
// Re-push diagnostics for EVERY open file (FOAM + JRL) — registry
412-
// mutation can affect any of them. Invalidate their incremental caches
413-
// first so revalidation runs fresh.
413+
// Compute the dependency closure — files whose diagnostics could be
414+
// impacted by this change. Empty list for non-FOAM saves; JRLs only
415+
// affect the open-file loop below.
416+
var affectedPaths = changedClassIds.length > 0
417+
? index.getAffectedFiles(changedClassIds)
418+
: [];
419+
var affectedPathsSet = {};
420+
affectedPaths.forEach(function(p) { affectedPathsSet[p] = true; });
421+
422+
// Re-push diagnostics for the saved file itself, open JRLs (registry
423+
// mutation affects their class refs), and any open FOAM file that's in
424+
// the affected set. Untouched open files are left alone — FOAM's axiom
425+
// state didn't change relative to them.
414426
for ( var ouri in documents ) {
415427
var otext = documents[ouri].text;
428+
if ( ouri === uri ) {
429+
fileModelCache.invalidate(ouri);
430+
if ( isJrlFile(ouri) ) pushJrlDiagnostics(ouri, otext);
431+
else if ( isFoamFile(otext) ) pushDiagnostics(ouri, otext);
432+
continue;
433+
}
416434
if ( isJrlFile(ouri) ) {
417435
pushJrlDiagnostics(ouri, otext);
418436
} else if ( isFoamFile(otext) ) {
419-
fileModelCache.invalidate(ouri);
420-
pushDiagnostics(ouri, otext);
437+
// Only re-diagnose if this file's path is in the affected set.
438+
var opath = uriToPath_(ouri);
439+
if ( opath && affectedPathsSet[opath] ) {
440+
fileModelCache.invalidate(ouri);
441+
pushDiagnostics(ouri, otext);
442+
}
421443
}
422444
}
423445

424-
// Also schedule a full workspace re-analyze so closed-but-affected
425-
// files (subclasses, referencers, JRLs in the Problems panel) refresh.
426-
// Debounced to coalesce burst-saves.
427-
scheduleWorkspaceReanalyze();
446+
// Re-analyze closed-but-affected files so the Problems panel stays
447+
// coherent. Debounced so burst-saves coalesce.
448+
if ( affectedPaths.length > 0 ) {
449+
scheduleAffectedReanalyze(affectedPaths, uri);
450+
}
451+
}
452+
453+
function uriToPath_(uri) {
454+
if ( ! uri ) return null;
455+
if ( uri.indexOf('file://') === 0 ) return decodeURIComponent(uri.substring(7));
456+
return uri;
428457
}
429458

430-
var workspaceReanalyzeTimer_ = null;
431-
function scheduleWorkspaceReanalyze() {
432-
/** Debounced full-workspace re-analysis after a registry change. */
433-
if ( workspaceReanalyzeTimer_ ) clearTimeout(workspaceReanalyzeTimer_);
434-
workspaceReanalyzeTimer_ = setTimeout(function() {
435-
workspaceReanalyzeTimer_ = null;
459+
var affectedReanalyzeTimer_ = null;
460+
var pendingAffectedPaths_ = {};
461+
function scheduleAffectedReanalyze(paths, skipUri) {
462+
/**
463+
* Debounced, targeted re-analysis: scans ONLY the file paths supplied
464+
* by getAffectedFiles. Burst-saves merge their path sets rather than
465+
* each triggering a full workspace scan.
466+
*/
467+
paths.forEach(function(p) { pendingAffectedPaths_[p] = true; });
468+
if ( affectedReanalyzeTimer_ ) clearTimeout(affectedReanalyzeTimer_);
469+
affectedReanalyzeTimer_ = setTimeout(function() {
470+
affectedReanalyzeTimer_ = null;
471+
var batch = Object.keys(pendingAffectedPaths_);
472+
pendingAffectedPaths_ = {};
436473
try {
437-
var results = workspaceAnalyzer.analyze();
474+
var results = workspaceAnalyzer.analyzeFiles(batch);
438475
for ( var uri in results.fileResults ) {
476+
// Skip the saved file and any open file — they've already been
477+
// pushed from the open-doc loop with live buffer contents.
478+
if ( uri === skipUri ) continue;
479+
if ( documents[uri] ) continue;
439480
notify('textDocument/publishDiagnostics', {
440481
uri: uri,
441482
diagnostics: results.fileResults[uri]
442483
});
443484
}
444-
console.error('[LSP] reanalyze after reindex: ' +
485+
console.error('[LSP] affected reanalyze: ' +
445486
results.filesScanned + ' scanned, ' +
446487
results.filesWithIssues + ' with issues');
447488
} catch ( e ) {
448-
console.error('[LSP] reanalyze error: ' + e.message);
489+
console.error('[LSP] affected reanalyze error: ' + e.message);
449490
}
450491
}, 500);
451492
}

tools/tests/testFoamLSP.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2926,6 +2926,48 @@ if ( require('fs').existsSync(realJrlPath) ) {
29262926
}
29272927
}
29282928

2929+
// === SAVE → TARGETED REANALYZE ===
2930+
section('Targeted reanalyze: getAffectedFiles covers the dependency closure');
2931+
2932+
// FObject is the mother class — every FOAM class should be affected.
2933+
// We just want to sanity-check the API and ordering. Using a mid-level
2934+
// class keeps the set reasonable.
2935+
var startId = 'foam.dao.EasyDAO';
2936+
if ( index.classExists(startId) ) {
2937+
var affected = index.getAffectedFiles([startId]);
2938+
test(Array.isArray(affected),
2939+
'getAffectedFiles returns an array');
2940+
// The saved file's own path should be in the set.
2941+
var selfPath = index.getFilePath(startId);
2942+
test(selfPath && affected.indexOf(selfPath) !== -1,
2943+
'Affected set includes the saved file itself');
2944+
// It should NOT include every file in the workspace — narrower than full scan.
2945+
test(affected.length < index.getAllClassIds().length / 2,
2946+
'Affected set (' + affected.length + ') is a small fraction of total classes (' +
2947+
index.getAllClassIds().length + ')');
2948+
2949+
// A subclass's file should be in the set if any subclass exists.
2950+
var subs = index.getSubclasses(startId);
2951+
if ( subs.length > 0 ) {
2952+
var subPath = index.getFilePath(subs[0]);
2953+
if ( subPath ) {
2954+
test(affected.indexOf(subPath) !== -1,
2955+
'Affected set includes subclass file ' + subPath);
2956+
}
2957+
}
2958+
}
2959+
2960+
// analyzeFiles runs diagnostics on the supplied files only
2961+
var analyzer = foam.parse.lsp.handlers.WorkspaceAnalyzer.create({ index: index });
2962+
var anyFilePath = startId && index.getFilePath(startId);
2963+
if ( anyFilePath ) {
2964+
var singleRes = analyzer.analyzeFiles([anyFilePath]);
2965+
test(singleRes.filesScanned === 1,
2966+
'analyzeFiles({[path]}) scans exactly one file');
2967+
test(typeof singleRes.fileResults === 'object',
2968+
'analyzeFiles returns fileResults map');
2969+
}
2970+
29292971
// === SUMMARY ===
29302972

29312973
section('SUMMARY');

0 commit comments

Comments
 (0)