diff --git a/README.md b/README.md index d774893..efa1d51 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,21 @@ Java file, runs the bundled tsDetect engine, and publishes detected test smells to both the **Test Smell Detector** Output panel and VS Code's **Problems** panel. +The extension also performs incremental analysis when a Java test file is +saved. Only the saved test/production pair is analyzed, and only that test +file's diagnostics are refreshed. Automatic analysis can be disabled with the +`testSmellDetector.analyzeOnSave` setting. + Each detected smell is represented as a warning diagnostic. Because the current tsDetect output reports smells at file level rather than exact source locations, the warning is anchored to the Java test class name. VS Code also shows a warning squiggle and its standard diagnostic hover at that location. -This first prototype uses tsDetect's existing file-pair input format, so the -production class must be selected manually. Automatic production-file matching -is outside the current scope. +tsDetect uses a test/production file pair. The extension first remembers a +production file selected during a manual analysis. It can also match common +names such as `CalculatorTest.java` to `Calculator.java` and prefers files +under `src/main`. If an automatic run cannot find a match, run **Analyze Current +File** once and select the production file manually. ## Requirements @@ -43,6 +50,19 @@ If Java is installed under a different executable path, set 6. Select the corresponding production Java file when prompted. 7. Review the detected smells in the **Problems** panel and the detailed run summary in the **Test Smell Detector** Output panel. +8. Modify and save the Java test file. The extension reruns tsDetect after a + short delay and replaces that file's diagnostics automatically. + +## Incremental analysis + +- Runs only for saved local Java test files. +- Recognizes common test suffixes such as `Test`, `Tests`, `IT`, and `Spec`, as + well as Java files under `src/test`. +- Waits briefly after a save to avoid redundant work. +- Serializes detector runs for the same file and ignores outdated results. +- Allows different test files to be analyzed independently. +- Removes stale diagnostics as soon as a Java file changes. +- Can be disabled through the `testSmellDetector.analyzeOnSave` setting. ## Automated checks @@ -69,7 +89,7 @@ npm run package:vsix ## Prototype limitations -- Analysis runs only when the command is invoked. -- The production file is selected manually. +- Production-file matching follows common project and filename conventions; + unusual project layouts may require one manual selection. - Results are reported at file level, so diagnostics mark the test class rather than the exact expression that caused a smell. diff --git a/package.json b/package.json index f41e3f4..ec0eb0f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "Linters" ], "main": "./out/extension.js", + "activationEvents": [ + "onLanguage:java" + ], "capabilities": { "untrustedWorkspaces": { "supported": false @@ -37,6 +40,11 @@ "type": "string", "default": "java", "description": "Java executable used to run the bundled tsDetect JAR." + }, + "testSmellDetector.analyzeOnSave": { + "type": "boolean", + "default": true, + "description": "Automatically analyze saved Java test files and refresh their diagnostics." } } } diff --git a/src/extension.ts b/src/extension.ts index 4285089..d33e6ea 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,21 @@ -import * as vscode from 'vscode'; import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { + isLikelyJavaTestDocument, + ProductionFileResolver, +} from './productionFileResolver'; import { runDetector } from './testSmellDetector'; -// This ID must match the command contributed in package.json. const ANALYZE_CURRENT_FILE_COMMAND = 'testSmellDetector.analyzeCurrentFile'; const DIAGNOSTIC_SOURCE = 'Test Smell Detector'; +const ANALYSIS_DEBOUNCE_MILLISECONDS = 500; + +interface AnalysisRequest { + generation: number; + interactive: boolean; +} /** * tsDetect currently reports smells for the complete test file rather than an @@ -51,126 +61,164 @@ function createDiagnostics( }); } -/** - * VS Code calls activate the first time the contributed command is invoked. - * This function owns the user-facing workflow; process execution stays in a - * separate module so it can be tested without launching VS Code. - */ +/** Activate the command, diagnostics, and incremental on-save analysis. */ export function activate(context: vscode.ExtensionContext): void { - const output = vscode.window.createOutputChannel('Test Smell Detector'); + const output = vscode.window.createOutputChannel(DIAGNOSTIC_SOURCE); const diagnostics = vscode.languages.createDiagnosticCollection( 'testSmellDetector', ); - const analyzeCurrentFile = vscode.commands.registerCommand( - ANALYZE_CURRENT_FILE_COMMAND, - async () => { - const editor = vscode.window.activeTextEditor; + const productionFileResolver = new ProductionFileResolver(context); + const scheduledAnalyses = new Map< + string, + ReturnType + >(); + const analysisGenerations = new Map(); + const analysisQueues = new Map>(); - // tsDetect reads source from disk, so require an active, saved Java file - // before starting the detector workflow. - if (!editor) { - await vscode.window.showErrorMessage( - 'Open a Java test file before running Test Smell Detector.', - ); - return; - } + const documentKey = (document: vscode.TextDocument): string => + document.uri.toString(); + + const getGeneration = (document: vscode.TextDocument): number => + analysisGenerations.get(documentKey(document)) ?? 0; + + const nextGeneration = (document: vscode.TextDocument): number => { + const generation = getGeneration(document) + 1; + analysisGenerations.set(documentKey(document), generation); + return generation; + }; + + const clearScheduledAnalysis = (document: vscode.TextDocument): void => { + const key = documentKey(document); + const timer = scheduledAnalyses.get(key); + if (timer) { + clearTimeout(timer); + scheduledAnalyses.delete(key); + } + }; - const { document } = editor; - const fileName = path.basename(document.fileName); + const isCurrentRequest = ( + document: vscode.TextDocument, + generation: number, + version: number, + ): boolean => + !document.isClosed && + !document.isDirty && + getGeneration(document) === generation && + document.version === version; - if (document.languageId !== 'java') { - await vscode.window.showWarningMessage( + const reportAutomaticMessage = (message: string): void => { + output.appendLine(`[${new Date().toLocaleTimeString()}] ${message}`); + }; + + const analyzeDocument = async ( + document: vscode.TextDocument, + request: AnalysisRequest, + ): Promise => { + const fileName = path.basename(document.fileName); + + if (document.languageId !== 'java') { + if (request.interactive) { + void vscode.window.showWarningMessage( `${fileName} is not recognized as a Java file.`, ); - return; } + return; + } - if (document.uri.scheme !== 'file' || document.isUntitled) { - await vscode.window.showWarningMessage( + if (document.uri.scheme !== 'file' || document.isUntitled) { + if (request.interactive) { + void vscode.window.showWarningMessage( `Save ${fileName} locally before analyzing it.`, ); - return; } + return; + } - if (document.isDirty) { - await vscode.window.showWarningMessage( + if (document.isDirty) { + if (request.interactive) { + void vscode.window.showWarningMessage( `Save your changes to ${fileName} before analyzing it.`, ); - return; } + return; + } - const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); - - // tsDetect requires a test/production pair. Automatic matching can - // replace this manual prompt in a future version. - const productionFiles = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - defaultUri: - workspaceFolder?.uri ?? vscode.Uri.file(path.dirname(document.fileName)), - filters: { - 'Java source files': ['java'], - }, - openLabel: 'Use as production file', - title: `Select the production file tested by ${fileName}`, - }); + const analyzedDocumentVersion = document.version; + try { + const productionFilePath = await productionFileResolver.resolve( + document, + request.interactive, + ); - if (!productionFiles?.length) { + if (!productionFilePath) { + if (!request.interactive) { + reportAutomaticMessage( + `Skipped ${fileName}: no matching production Java file was found. Run Analyze Current File once to select and remember it.`, + ); + } return; } - const productionFilePath = productionFiles[0].fsPath; - if (productionFilePath === document.fileName) { - await vscode.window.showWarningMessage( - 'Choose the production Java file, not the test file itself.', - ); + if ( + !isCurrentRequest( + document, + request.generation, + analyzedDocumentVersion, + ) + ) { return; } - // Results from an older analysis are no longer reliable once a new run - // starts. They will be replaced below if this analysis succeeds. diagnostics.delete(document.uri); - const analyzedDocumentVersion = document.version; - + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); const javaPath = vscode.workspace - .getConfiguration('testSmellDetector') + .getConfiguration('testSmellDetector', document.uri) .get('javaPath', 'java'); + const run = () => + runDetector({ + applicationName: + workspaceFolder?.name ?? + path.basename(path.dirname(document.fileName)), + javaPath, + jarPath: context.asAbsolutePath('detector/tsdetect.jar'), + productionFilePath, + testFilePath: document.fileName, + }); + const result = request.interactive + ? await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Analyzing ${fileName} for test smells...`, + }, + run, + ) + : await run(); - try { - // Keep VS Code responsive and show visible progress while Java runs. - const result = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Analyzing ${fileName} for test smells...`, - }, - () => - runDetector({ - applicationName: - workspaceFolder?.name ?? - path.basename(path.dirname(document.fileName)), - javaPath, - jarPath: context.asAbsolutePath('detector/tsdetect.jar'), - productionFilePath, - testFilePath: document.fileName, - }), - ); - - // Do not publish results for source code that changed while the Java - // process was running. The user can save and run the command again. - if ( - document.isClosed || - document.isDirty || - document.version !== analyzedDocumentVersion - ) { - await vscode.window.showWarningMessage( + if ( + !isCurrentRequest( + document, + request.generation, + analyzedDocumentVersion, + ) + ) { + if (request.interactive && !document.isClosed) { + void vscode.window.showWarningMessage( `${fileName} changed during analysis. Save it and analyze again.`, ); - return; } + return; + } + + if (result.detectedSmells.length === 0) { + diagnostics.delete(document.uri); + } else { + diagnostics.set( + document.uri, + createDiagnostics(document, result.detectedSmells), + ); + } - // Keep the detailed run summary in the Output channel while native - // diagnostics provide the editor-facing Problems experience. + if (request.interactive) { output.clear(); output.appendLine('Test Smell Detector Results'); output.appendLine('==========================='); @@ -179,42 +227,47 @@ export function activate(context: vscode.ExtensionContext): void { output.appendLine(`Test methods: ${result.numberOfTestMethods}`); output.appendLine(''); - // Publishing diagnostics makes the smells appear in VS Code's Problems - // panel and adds warning squiggles at the test class declaration. - diagnostics.set( - document.uri, - createDiagnostics(document, result.detectedSmells), - ); - if (result.detectedSmells.length === 0) { output.appendLine('No test smells detected.'); output.show(true); - await vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `No test smells detected in ${fileName}.`, ); } else { - output.appendLine(`Detected smells (${result.detectedSmells.length}):`); + output.appendLine( + `Detected smells (${result.detectedSmells.length}):`, + ); for (const smell of result.detectedSmells) { output.appendLine(`- ${smell}`); } output.show(true); - const selection = await vscode.window.showWarningMessage( - `${result.detectedSmells.length} test smell(s) detected in ${fileName}.`, - 'Open Problems', - ); - - if (selection === 'Open Problems') { - await vscode.commands.executeCommand('workbench.actions.view.problems'); - } + void vscode.window + .showWarningMessage( + `${result.detectedSmells.length} test smell(s) detected in ${fileName}.`, + 'Open Problems', + ) + .then((selection) => { + if (selection === 'Open Problems') { + void vscode.commands.executeCommand( + 'workbench.actions.view.problems', + ); + } + }); } - } catch (error: unknown) { - // Do not leave results from an earlier successful run visible after a - // failed analysis. - diagnostics.delete(document.uri); + } else { + reportAutomaticMessage( + `${fileName}: ${result.numberOfTestMethods} test method(s), ${result.detectedSmells.length} smell(s).`, + ); + } + } catch (error: unknown) { + if (getGeneration(document) !== request.generation) { + return; + } - // Preserve technical details for debugging while keeping the pop-up - // notification short and actionable. - const message = error instanceof Error ? error.message : String(error); + diagnostics.delete(document.uri); + const message = error instanceof Error ? error.message : String(error); + + if (request.interactive) { output.clear(); output.appendLine('Test Smell Detector failed'); output.appendLine('=========================='); @@ -224,31 +277,154 @@ export function activate(context: vscode.ExtensionContext): void { output.appendLine(error.stack); } output.show(true); + void vscode.window.showErrorMessage( + 'Test smell analysis failed. See the Test Smell Detector output for details.', + ); + } else { + reportAutomaticMessage(`Analysis failed for ${fileName}: ${message}`); + } + } + }; + + /** + * Serialize detector runs for one document. A newer generation invalidates + * queued or running work, while different test files may run independently. + */ + const enqueueAnalysis = ( + document: vscode.TextDocument, + request: AnalysisRequest, + ): Promise => { + const key = documentKey(document); + const previousAnalysis = analysisQueues.get(key) ?? Promise.resolve(); + const nextAnalysis = previousAnalysis + .catch(() => undefined) + .then(async () => { + if ( + getGeneration(document) !== request.generation || + document.isClosed || + document.isDirty + ) { + return; + } + await analyzeDocument(document, request); + }); + analysisQueues.set(key, nextAnalysis); + const cleanUpQueue = (): void => { + if (analysisQueues.get(key) === nextAnalysis) { + analysisQueues.delete(key); + } + }; + void nextAnalysis.then(cleanUpQueue, cleanUpQueue); + return nextAnalysis; + }; + + const scheduleIncrementalAnalysis = ( + document: vscode.TextDocument, + ): void => { + if (!isLikelyJavaTestDocument(document)) { + return; + } + + const analyzeOnSave = vscode.workspace + .getConfiguration('testSmellDetector', document.uri) + .get('analyzeOnSave', true); + if (!analyzeOnSave) { + return; + } + + clearScheduledAnalysis(document); + const generation = nextGeneration(document); + const key = documentKey(document); + const timer = setTimeout(() => { + scheduledAnalyses.delete(key); + void enqueueAnalysis(document, { + generation, + interactive: false, + }); + }, ANALYSIS_DEBOUNCE_MILLISECONDS); + scheduledAnalyses.set(key, timer); + }; + + const analyzeCurrentFile = vscode.commands.registerCommand( + ANALYZE_CURRENT_FILE_COMMAND, + async () => { + const document = vscode.window.activeTextEditor?.document; + if (!document) { await vscode.window.showErrorMessage( - 'Test smell analysis failed. See the Test Smell Detector output for details.', + 'Open a Java test file before running Test Smell Detector.', ); + return; + } + + clearScheduledAnalysis(document); + const generation = nextGeneration(document); + await enqueueAnalysis(document, { + generation, + interactive: true, + }); + }, + ); + + const analyzeAfterSave = vscode.workspace.onDidSaveTextDocument( + scheduleIncrementalAnalysis, + ); + + const invalidateAfterChange = vscode.workspace.onDidChangeTextDocument( + ({ document, contentChanges }) => { + // VS Code can emit this event when only the document's dirty/save state + // changes. Ignore those empty events so they do not cancel a scheduled + // on-save analysis or invalidate a manual analysis already in progress. + if (document.languageId !== 'java' || contentChanges.length === 0) { + return; } + clearScheduledAnalysis(document); + nextGeneration(document); + diagnostics.delete(document.uri); }, ); - // Editing or closing a document invalidates its last file-level result. - const clearDiagnosticsOnChange = vscode.workspace.onDidChangeTextDocument( - ({ document }) => diagnostics.delete(document.uri), + const cleanUpClosedDocument = vscode.workspace.onDidCloseTextDocument( + (document) => { + clearScheduledAnalysis(document); + nextGeneration(document); + diagnostics.delete(document.uri); + }, ); - const clearDiagnosticsOnClose = vscode.workspace.onDidCloseTextDocument( - (document) => diagnostics.delete(document.uri), + + const handleConfigurationChange = vscode.workspace.onDidChangeConfiguration( + (event) => { + if (!event.affectsConfiguration('testSmellDetector.analyzeOnSave')) { + return; + } + + for (const timer of scheduledAnalyses.values()) { + clearTimeout(timer); + } + scheduledAnalyses.clear(); + for (const [key, generation] of analysisGenerations) { + analysisGenerations.set(key, generation + 1); + } + }, ); - // VS Code disposes all registered resources when the extension unloads. context.subscriptions.push( analyzeCurrentFile, + analyzeAfterSave, + invalidateAfterChange, + cleanUpClosedDocument, + handleConfigurationChange, output, diagnostics, - clearDiagnosticsOnChange, - clearDiagnosticsOnClose, + { + dispose: () => { + for (const timer of scheduledAnalyses.values()) { + clearTimeout(timer); + } + }, + }, ); } -/** No explicit shutdown work is required for the command-based MVP. */ +/** VS Code disposes all extension resources registered during activation. */ export function deactivate(): void {} diff --git a/src/productionFileResolver.ts b/src/productionFileResolver.ts new file mode 100644 index 0000000..2ae0971 --- /dev/null +++ b/src/productionFileResolver.ts @@ -0,0 +1,213 @@ +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +const PRODUCTION_FILE_MAPPINGS_KEY = + 'testSmellDetector.productionFileMappings'; +const EXCLUDED_SEARCH_DIRECTORIES = + '**/{.git,node_modules,target,build,out}/**'; + +type ProductionFileMappings = Record; + +/** Return true when a local Java document looks like a test source file. */ +export function isLikelyJavaTestDocument( + document: vscode.TextDocument, +): boolean { + if (document.languageId !== 'java' || document.uri.scheme !== 'file') { + return false; + } + + const fileName = path.basename(document.fileName); + const normalizedPath = document.fileName.replaceAll('\\', '/').toLowerCase(); + + return ( + /(?:Test|Tests|IT|Spec)\.java$/i.test(fileName) || + normalizedPath.includes('/src/test/') + ); +} + +/** + * Locate and remember the production source paired with a Java test file. + * Automatic analysis never opens a picker; a manual run can ask the user when + * convention-based matching is not enough. + */ +export class ProductionFileResolver { + public constructor(private readonly context: vscode.ExtensionContext) {} + + public async resolve( + document: vscode.TextDocument, + interactive: boolean, + ): Promise { + const rememberedPath = await this.getRememberedPath(document); + if (rememberedPath) { + return rememberedPath; + } + + const automaticPath = await this.findAutomaticMatch(document); + if (automaticPath) { + await this.remember(document, automaticPath); + return automaticPath; + } + + if (!interactive) { + return undefined; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + const selectedFiles = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: + workspaceFolder?.uri ?? vscode.Uri.file(path.dirname(document.fileName)), + filters: { + 'Java source files': ['java'], + }, + openLabel: 'Use as production file', + title: `Select the production file tested by ${path.basename(document.fileName)}`, + }); + + const selectedPath = selectedFiles?.[0]?.fsPath; + if (!selectedPath) { + return undefined; + } + + if (sameFile(selectedPath, document.fileName)) { + await vscode.window.showWarningMessage( + 'Choose the production Java file, not the test file itself.', + ); + return undefined; + } + + await this.remember(document, selectedPath); + return selectedPath; + } + + private async getRememberedPath( + document: vscode.TextDocument, + ): Promise { + const mappings = this.context.workspaceState.get( + PRODUCTION_FILE_MAPPINGS_KEY, + {}, + ); + const rememberedPath = mappings[document.uri.toString()]; + + if ( + rememberedPath && + existsSync(rememberedPath) && + !sameFile(rememberedPath, document.fileName) + ) { + return rememberedPath; + } + + if (rememberedPath) { + const updatedMappings = { ...mappings }; + delete updatedMappings[document.uri.toString()]; + await this.context.workspaceState.update( + PRODUCTION_FILE_MAPPINGS_KEY, + updatedMappings, + ); + } + + return undefined; + } + + private async findAutomaticMatch( + document: vscode.TextDocument, + ): Promise { + const productionFileName = getProductionFileName(document.fileName); + const conventionalPath = getConventionalProductionPath( + document.fileName, + productionFileName, + ); + + if ( + conventionalPath && + existsSync(conventionalPath) && + !sameFile(conventionalPath, document.fileName) + ) { + return conventionalPath; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!workspaceFolder) { + return undefined; + } + + const matches = await vscode.workspace.findFiles( + new vscode.RelativePattern(workspaceFolder, `**/${productionFileName}`), + EXCLUDED_SEARCH_DIRECTORIES, + 50, + ); + const candidates = matches.filter( + (candidate) => !sameFile(candidate.fsPath, document.fileName), + ); + + // Prefer normal Maven/Gradle production roots, then the shortest path. + candidates.sort((first, second) => { + const firstInMain = isMainSourcePath(first.fsPath); + const secondInMain = isMainSourcePath(second.fsPath); + if (firstInMain !== secondInMain) { + return firstInMain ? -1 : 1; + } + return first.fsPath.length - second.fsPath.length; + }); + + return candidates[0]?.fsPath; + } + + private async remember( + document: vscode.TextDocument, + productionFilePath: string, + ): Promise { + const mappings = this.context.workspaceState.get( + PRODUCTION_FILE_MAPPINGS_KEY, + {}, + ); + + await this.context.workspaceState.update(PRODUCTION_FILE_MAPPINGS_KEY, { + ...mappings, + [document.uri.toString()]: productionFilePath, + }); + } +} + +function getProductionFileName(testFilePath: string): string { + return path + .basename(testFilePath) + .replace(/(?:Tests|Test|IT|Spec)\.java$/i, '.java'); +} + +function getConventionalProductionPath( + testFilePath: string, + productionFileName: string, +): string | undefined { + const pathParts = path.normalize(testFilePath).split(path.sep); + + for (let index = 0; index <= pathParts.length - 3; index += 1) { + if ( + pathParts[index] === 'src' && + pathParts[index + 1] === 'test' && + pathParts[index + 2] === 'java' + ) { + const productionParts = [...pathParts]; + productionParts[index + 1] = 'main'; + productionParts[productionParts.length - 1] = productionFileName; + return productionParts.join(path.sep); + } + } + + return undefined; +} + +function isMainSourcePath(filePath: string): boolean { + return filePath + .replaceAll('\\', '/') + .toLowerCase() + .includes('/src/main/'); +} + +function sameFile(firstPath: string, secondPath: string): boolean { + return path.resolve(firstPath) === path.resolve(secondPath); +}