Skip to content

Commit 9edfe4c

Browse files
authored
feat: add keyword completion provider and incremental scan progress
- Add LsdynaKeywordCompletionProvider triggered by '*' character - Add onProgress callback to projectIndexer.buildProjectIndex() - Update scanWorker to send throttled progress messages to parent - Update workerPool to forward progress messages via callback - Update projectIndexLoader to pass through onProgress option - Add SCAN_PROGRESS_NOTIFICATION protocol for server→client progress - Update requestRouter to send progress notifications during indexing - Update indexClient.loadProjectSnapshot to accept onProgress option - Wire scan progress event to includeTreeProvider and keywordIndexProvider - Both tree providers now show real-time scan progress in notification - Add tests for keyword completion and progress callback
1 parent a8b3b8e commit 9edfe4c

14 files changed

Lines changed: 278 additions & 20 deletions

File tree

src/client/providers/includeTreeProvider.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,12 @@ class LsdynaIncludeTreeProvider {
251251
* @param {Object} [options={}] - Dependencies.
252252
* @param {function(string, string[]): string} [options.searchFileFromPaths] - Absolute path resolver helper.
253253
* @param {function(string): Promise<import('../../core/project/projectIndexer').ProjectIndexResult>} [options.loadProjectSnapshot] - Snapshot loader.
254+
* @param {import('vscode').Event} [options.scanProgressEvent] - Event fired with scan progress updates.
254255
*/
255-
constructor({ searchFileFromPaths, loadProjectSnapshot } = {}) {
256+
constructor({ searchFileFromPaths, loadProjectSnapshot, scanProgressEvent } = {}) {
256257
this.searchFileFromPaths = searchFileFromPaths;
257258
this.loadProjectSnapshot = loadProjectSnapshot;
259+
this.scanProgressEvent = scanProgressEvent || null;
258260
this._onDidChangeTreeData = new vscode.EventEmitter();
259261
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
260262
/**
@@ -300,8 +302,20 @@ class LsdynaIncludeTreeProvider {
300302
this.resolvedPaths.clear();
301303
this.missingPaths.clear();
302304
if (this.loadProjectSnapshot) {
303-
const snapshot = await this.loadProjectSnapshot(uri.fsPath);
304-
this.root = this._buildRootFromSnapshot(snapshot, uri.fsPath);
305+
let progressDisposable = null;
306+
if (this.scanProgressEvent) {
307+
progressDisposable = this.scanProgressEvent((info) => {
308+
const fileName = path.basename(info.currentFile || '');
309+
progress.report({ message: i18n.get('filesFound', info.scannedFileCount) + (fileName ? ` - ${fileName}` : '') });
310+
});
311+
}
312+
let snapshot;
313+
try {
314+
snapshot = await this.loadProjectSnapshot(uri.fsPath);
315+
this.root = this._buildRootFromSnapshot(snapshot, uri.fsPath);
316+
} finally {
317+
if (progressDisposable) progressDisposable.dispose();
318+
}
305319

306320
const collectPaths = (node) => {
307321
const key = normalizePathKey(node.filePath);

src/client/providers/keywordIndexProvider.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,13 @@ class LsdynaKeywordIndexProvider {
179179
* @param {function(string): Promise<string[]>} [options.collectIncludeFiles] - Includes scanner callback.
180180
* @param {function(string): Promise<import('../../core/project/projectIndexer').ProjectIndexResult>} [options.loadProjectSnapshot] - Snapshot loader.
181181
* @param {function(import('vscode').TextDocument): boolean} [options.shouldSkipAutomaticDocumentScan] - Large file guard callback.
182+
* @param {import('vscode').Event} [options.scanProgressEvent] - Event fired with scan progress updates.
182183
*/
183-
constructor({ collectIncludeFiles, loadProjectSnapshot, shouldSkipAutomaticDocumentScan } = {}) {
184+
constructor({ collectIncludeFiles, loadProjectSnapshot, shouldSkipAutomaticDocumentScan, scanProgressEvent } = {}) {
184185
this.collectIncludeFiles = collectIncludeFiles;
185186
this.loadProjectSnapshot = loadProjectSnapshot;
186187
this.shouldSkipAutomaticDocumentScan = shouldSkipAutomaticDocumentScan;
188+
this.scanProgressEvent = scanProgressEvent || null;
187189
this._onDidChangeTreeData = new vscode.EventEmitter();
188190
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
189191
/**
@@ -407,8 +409,19 @@ class LsdynaKeywordIndexProvider {
407409
{ location: vscode.ProgressLocation.Notification, title: i18n.get('indexingKeywords'), cancellable: false },
408410
async (progress) => {
409411
if (this.loadProjectSnapshot) {
410-
const snapshot = await this.loadProjectSnapshot(rootFile);
411-
this.roots = this._buildRootsFromSnapshot(snapshot, rootDir);
412+
let progressDisposable = null;
413+
if (this.scanProgressEvent) {
414+
progressDisposable = this.scanProgressEvent((info) => {
415+
const fileName = path.basename(info.currentFile || '');
416+
progress.report({ message: i18n.get('filesFound', info.scannedFileCount) + (fileName ? ` - ${fileName}` : '') });
417+
});
418+
}
419+
try {
420+
const snapshot = await this.loadProjectSnapshot(rootFile);
421+
this.roots = this._buildRootsFromSnapshot(snapshot, rootDir);
422+
} finally {
423+
if (progressDisposable) progressDisposable.dispose();
424+
}
412425
} else {
413426
const files = await this.collectIncludeFiles(rootFile, (count) => {
414427
progress.report({ message: i18n.get('filesFound', count) });

src/client/services/indexClient.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ function createIndexClient({
287287
}
288288
}
289289

290-
async function loadProjectSnapshot(rootFile) {
290+
async function loadProjectSnapshot(rootFile, options = {}) {
291+
const { onProgress } = options;
291292
const resolvedRootFile = resolveRootFile(rootFile);
292293
const rootCacheKey = getRootCacheKey(rootFile);
293294

@@ -330,7 +331,7 @@ function createIndexClient({
330331
}
331332
}
332333

333-
const snapshot = await buildProjectIndex(resolvedRootFile);
334+
const snapshot = await buildProjectIndex(resolvedRootFile, { onProgress });
334335
let trackedFiles = null;
335336
try {
336337
trackedFiles = await captureTrackedFiles(snapshot, getFileSignature);

src/core/project/projectIndexer.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,12 @@ function createProjectIndexer({
179179
* Rebuilds the project index starting from the given root file by recursively scanning inclusions.
180180
*
181181
* @param {string} rootFile - Absolute path to the main LS-DYNA input deck.
182+
* @param {Object} [options={}] - Optional parameters.
183+
* @param {function({scannedFileCount: number, reusedFileCount: number, currentFile: string}): void} [options.onProgress] - Progress callback invoked after each file scan.
182184
* @returns {Promise<ProjectIndexResult>} The assembled project index snapshot.
183185
*/
184-
async function buildProjectIndex(rootFile) {
186+
async function buildProjectIndex(rootFile, options = {}) {
187+
const { onProgress } = options;
185188
const resolvedRootFile = resolveProjectFile(rootFile);
186189
const files = [];
187190
const keywordMap = new Map();
@@ -208,6 +211,13 @@ function createProjectIndexer({
208211
const scanResult = await loadFileScan(resolvedFilePath, stats);
209212
addKeywordUsages(keywordMap, scanResult.keywords);
210213

214+
if (typeof onProgress === 'function') {
215+
onProgress({
216+
scannedFileCount: stats.scannedFileCount + stats.reusedFileCount,
217+
currentFile: resolvedFilePath,
218+
});
219+
}
220+
211221
for (const entry of scanResult.includeEntries) {
212222
const { fileName, lineIndex, startChar, endChar } = entry;
213223
const resolvedPath = resolveIncludeFromSearchPaths(fileName, scanResult.searchPaths);

src/extension.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,47 @@ function getCardFieldsForLine(document, lineNum) {
13421342
return cards[clampedIndex] || null;
13431343
}
13441344

1345+
/**
1346+
* Autocomplete provider for LS-DYNA keyword names triggered by '*'.
1347+
* @implements {vscode.CompletionItemProvider}
1348+
*/
1349+
class LsdynaKeywordCompletionProvider {
1350+
provideCompletionItems(document, position, _token, _context) {
1351+
if (!document) return [];
1352+
1353+
const lineText = document.lineAt(position.line).text;
1354+
const textBeforeCursor = lineText.slice(0, position.character);
1355+
1356+
// Only trigger when '*' is at column 0 (LS-DYNA keywords must start at first column)
1357+
const starIndex = textBeforeCursor.indexOf('*');
1358+
if (starIndex !== 0) return [];
1359+
1360+
const prefix = textBeforeCursor.slice(1).toUpperCase();
1361+
const data = getFieldData();
1362+
const keywordNames = Object.keys(data);
1363+
const items = [];
1364+
1365+
for (const kwName of keywordNames) {
1366+
if (prefix && !kwName.toUpperCase().startsWith(prefix)) continue;
1367+
1368+
const item = new vscode.CompletionItem('*' + kwName, vscode.CompletionItemKind.Keyword);
1369+
item.detail = '(LS-DYNA) Keyword';
1370+
item.filterText = '*' + kwName;
1371+
item.insertText = '*' + kwName;
1372+
item.range = new vscode.Range(position.line, 0, position.line, position.character);
1373+
1374+
const entry = data[kwName];
1375+
if (entry && entry.c) {
1376+
item.documentation = new vscode.MarkdownString(keywordHoverMarkdown(kwName, entry));
1377+
}
1378+
1379+
items.push(item);
1380+
}
1381+
1382+
return items;
1383+
}
1384+
}
1385+
13451386
class LsdynaFieldCompletionProvider {
13461387
provideCompletionItems(document, position, token, context) {
13471388
if (!document || shouldSkipAutomaticDocumentScan(document)) return [];
@@ -1964,11 +2005,32 @@ function activate(context) {
19642005
'$', '#'
19652006
)
19662007
);
2008+
context.subscriptions.push(
2009+
vscode.languages.registerCompletionItemProvider(
2010+
{ language: 'lsdyna' },
2011+
new LsdynaKeywordCompletionProvider(),
2012+
'*'
2013+
)
2014+
);
19672015

19682016

19692017
const client = startLanguageServer(context);
19702018
const indexClient = createIndexClient({ languageClient: client });
19712019

2020+
// Set up scan progress event emitter for tree providers
2021+
const scanProgressEmitter = new vscode.EventEmitter();
2022+
if (typeof client.onReady === 'function') {
2023+
client.onReady().then(() => {
2024+
client.onNotification('lsdyna/scanProgress', (params) => {
2025+
scanProgressEmitter.fire(params);
2026+
});
2027+
}).catch(() => {});
2028+
} else if (typeof client.onNotification === 'function') {
2029+
client.onNotification('lsdyna/scanProgress', (params) => {
2030+
scanProgressEmitter.fire(params);
2031+
});
2032+
}
2033+
19722034
const projectDiagnostics = vscode.languages.createDiagnosticCollection('lsdyna-project');
19732035
context.subscriptions.push(projectDiagnostics);
19742036

@@ -1996,6 +2058,7 @@ function activate(context) {
19962058
const includeTreeProvider = new LsdynaIncludeTreeProvider({
19972059
searchFileFromPaths,
19982060
loadProjectSnapshot: indexClient.loadProjectSnapshot,
2061+
scanProgressEvent: scanProgressEmitter.event,
19992062
});
20002063
includeTreeView = vscode.window.createTreeView('lsdynaIncludeTree', {
20012064
treeDataProvider: includeTreeProvider
@@ -2019,6 +2082,7 @@ function activate(context) {
20192082
collectIncludeFiles,
20202083
loadProjectSnapshot: indexClient.loadProjectSnapshot,
20212084
shouldSkipAutomaticDocumentScan,
2085+
scanProgressEvent: scanProgressEmitter.event,
20222086
});
20232087
keywordTreeView = vscode.window.createTreeView('lsdynaKeywordIndex', {
20242088
treeDataProvider: keywordIndexProvider
@@ -2767,6 +2831,7 @@ module.exports._internals = {
27672831
LsdynaFileDecorationProvider,
27682832
normalizePathKey,
27692833
LsdynaIncludeCompletionProvider,
2834+
LsdynaKeywordCompletionProvider,
27702835
LsdynaFieldCompletionProvider,
27712836
getCardFieldsForLine,
27722837
generateCommentLine,

src/server/requestRouter.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ const protocol = require('../shared/protocol');
2121
*
2222
* @param {string} method - Protocol request method string.
2323
* @param {Object} params - Method parameters payload.
24+
* @param {Object} [context={}] - Optional context with server capabilities.
25+
* @param {function(string, Object): void} [context.sendNotification] - Send notification to client.
2426
* @returns {Promise<any>} Response JSON data.
2527
* @throws {Error} If no active session exists or request method is unsupported.
2628
*/
27-
async function handleRequest(method, params) {
29+
async function handleRequest(method, params, context = {}) {
2830
const session = getActiveSession();
2931
if (!session) {
3032
throw new Error('No active server session initialized');
@@ -33,7 +35,16 @@ async function handleRequest(method, params) {
3335
switch (method) {
3436
case protocol.LOAD_PROJECT_SNAPSHOT_REQUEST: {
3537
const { rootFile } = params;
36-
const snapshot = await session.indexClient.loadProjectSnapshot(rootFile);
38+
const onProgress = context.sendNotification
39+
? (info) => {
40+
context.sendNotification(protocol.SCAN_PROGRESS_NOTIFICATION, {
41+
rootFile,
42+
scannedFileCount: info.scannedFileCount,
43+
currentFile: info.currentFile,
44+
});
45+
}
46+
: undefined;
47+
const snapshot = await session.indexClient.loadProjectSnapshot(rootFile, { onProgress });
3748
return serializeProjectSnapshot(snapshot);
3849
}
3950
case protocol.GET_MANIFEST_ENTRIES_REQUEST: {

src/server/server.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ connection.onShutdown(async () => {
4545

4646
// Bind custom request handler for loading project snapshots.
4747
connection.onRequest(protocol.LOAD_PROJECT_SNAPSHOT_REQUEST, async (params) => {
48-
return handleRequest(protocol.LOAD_PROJECT_SNAPSHOT_REQUEST, params);
48+
return handleRequest(protocol.LOAD_PROJECT_SNAPSHOT_REQUEST, params, {
49+
sendNotification: (method, data) => connection.sendNotification(method, data),
50+
});
4951
});
5052

5153
// Bind custom request handler for retrieving cache manifests.

src/shared/protocol.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,16 @@ const GET_MANIFEST_ENTRIES_REQUEST = 'lsdyna/getManifestEntries';
3434
*/
3535
const GET_CACHE_STATS_REQUEST = 'lsdyna/getCacheStats';
3636

37+
/**
38+
* Notification sent from server to client reporting scan progress during project indexing.
39+
* @type {string}
40+
*/
41+
const SCAN_PROGRESS_NOTIFICATION = 'lsdyna/scanProgress';
42+
3743
module.exports = {
3844
LOAD_PROJECT_SNAPSHOT_REQUEST,
3945
INVALIDATE_NOTIFICATION,
4046
GET_MANIFEST_ENTRIES_REQUEST,
4147
GET_CACHE_STATS_REQUEST,
48+
SCAN_PROGRESS_NOTIFICATION,
4249
};

src/worker/projectIndexLoader.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ function createProjectIndexLoader({
6262
* Asynchronously delegates project indexing to the worker pool.
6363
*
6464
* @param {string} rootFile - Absolute path to the root LS-DYNA file.
65+
* @param {Object} [options={}] - Optional parameters.
66+
* @param {function({scannedFileCount: number, currentFile: string}): void} [options.onProgress] - Progress callback.
6567
* @returns {Promise<import('../core/project/projectIndexer').ProjectIndexResult>} Scanned project snapshot.
6668
*/
67-
async buildProjectIndex(rootFile) {
69+
async buildProjectIndex(rootFile, options = {}) {
6870
const pool = getWorkerPool();
6971
try {
70-
return await pool.buildProjectIndex(rootFile);
72+
return await pool.buildProjectIndex(rootFile, options);
7173
} catch (error) {
7274
if (workerPool === pool && isPoolDisposed(pool)) {
7375
workerPool = null;

src/worker/scanWorker.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,33 @@ function serializeError(error) {
3030
};
3131
}
3232

33+
/**
34+
* Minimum interval (ms) between progress messages to avoid flooding the parent thread.
35+
* @type {number}
36+
*/
37+
const PROGRESS_THROTTLE_MS = 300;
38+
3339
// Bind message listener on parent port to process requests.
3440
parentPort.on('message', async (message) => {
3541
if (!message || message.type !== 'buildProjectIndex') return;
3642

3743
try {
38-
const snapshot = await buildProjectIndex(message.rootFile);
44+
let lastProgressTime = 0;
45+
46+
const onProgress = (info) => {
47+
const now = Date.now();
48+
if (now - lastProgressTime < PROGRESS_THROTTLE_MS) return;
49+
lastProgressTime = now;
50+
51+
parentPort.postMessage({
52+
requestId: message.requestId,
53+
type: 'progress',
54+
scannedFileCount: info.scannedFileCount,
55+
currentFile: info.currentFile,
56+
});
57+
};
58+
59+
const snapshot = await buildProjectIndex(message.rootFile, { onProgress });
3960
parentPort.postMessage({
4061
requestId: message.requestId,
4162
snapshot: serializeProjectSnapshot(snapshot),

0 commit comments

Comments
 (0)