Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.github/**
src/**
scripts/**
java/**
regression-corpus/**
.gitignore
tsconfig.json
Expand Down
5 changes: 5 additions & 0 deletions detector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ It was built as the Maven `jar-with-dependencies` artifact using Java 11. The
detector is licensed separately under GNU GPL version 3; see `LICENSE` in this
directory. Review the GPL source-distribution requirements before publishing
or distributing an extension package that contains this JAR.

`tsdetect-locations.jar` is the extension's small integration bridge. It calls
the bundled detector API, maps engine-confirmed method and class names to
JavaParser declaration ranges, and returns those findings to the TypeScript
adapter. Its source is kept in `java/LocationBridge.java`.
Binary file added detector/tsdetect-locations.jar
Binary file not shown.
177 changes: 177 additions & 0 deletions java/LocationBridge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import com.github.javaparser.JavaParser;
import com.github.javaparser.Range;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.Node;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import testsmell.AbstractSmell;
import testsmell.SmellyElement;
import testsmell.TestClass;
import testsmell.TestFile;
import testsmell.TestMethod;
import testsmell.TestSmellDetector;
import testsmell.Util;
import thresholds.DefaultThresholds;

/**
* Runs tsDetect and maps its smelly method or class names to JavaParser source
* ranges. Ambiguous elements remain file-level findings instead of being
* assigned to a declaration that may be incorrect.
*/
public final class LocationBridge {
private static final String FORMAT_VERSION = "TSDETECT_LOCATIONS_V1";

private LocationBridge() {}

public static void main(String[] arguments) throws Exception {
if (arguments.length != 4) {
throw new IllegalArgumentException(
"Expected application, test, production, and output paths."
);
}

String applicationName = arguments[0];
String testFilePath = arguments[1];
String productionFilePath = arguments[2];
Path outputPath = Path.of(arguments[3]);

TestFile testFile = new TestFile(
applicationName,
testFilePath,
productionFilePath
);
TestFile result = new TestSmellDetector(new DefaultThresholds())
.detectSmells(testFile);
CompilationUnit compilationUnit = JavaParser.parse(
new File(testFilePath)
);
List<MethodDeclaration> methods = compilationUnit.getNodesByType(
MethodDeclaration.class
);
List<TypeDeclaration> types = compilationUnit.getNodesByType(
TypeDeclaration.class
);

try (
Writer output = Files.newBufferedWriter(
outputPath,
StandardCharsets.UTF_8
)
) {
writeRow(
output,
FORMAT_VERSION,
String.valueOf(result.getNumberOfTestMethods())
);

for (AbstractSmell smell : result.getTestSmells()) {
if (smell == null || !smell.hasSmell()) {
continue;
}

boolean wroteFinding = false;
for (SmellyElement element : smell.getSmellyElements()) {
if (element == null || !element.isSmelly()) {
continue;
}

wroteFinding = true;
writeFinding(output, smell.getSmellName(), element, methods, types);
}

if (!wroteFinding) {
writeFinding(output, smell.getSmellName(), "file", "", null);
}
}
}
}

private static void writeFinding(
Writer output,
String smellName,
SmellyElement element,
List<MethodDeclaration> methods,
List<TypeDeclaration> types
) throws IOException {
String elementName = element.getElementName();
List<Node> matches = new ArrayList<>();
String scope = "file";

if (element instanceof TestMethod) {
for (MethodDeclaration method : methods) {
if (
method.getNameAsString().equals(elementName)
&& Util.isValidTestMethod(method)
) {
matches.add(method.getName());
}
}
if (matches.size() == 1) {
scope = "method";
}
} else if (element instanceof TestClass) {
for (TypeDeclaration type : types) {
if (type.getNameAsString().equals(elementName)) {
matches.add(type.getName());
}
}
if (matches.size() == 1) {
scope = "class";
}
}

Node matchedNode = matches.size() == 1 ? matches.get(0) : null;
writeFinding(output, smellName, scope, elementName, matchedNode);
}

private static void writeFinding(
Writer output,
String smellName,
String scope,
String elementName,
Node node
) throws IOException {
Optional<Range> range = node == null
? Optional.empty()
: node.getRange();

if (range.isEmpty()) {
writeRow(output, smellName, "file", elementName, "", "", "", "");
return;
}

Range sourceRange = range.get();
writeRow(
output,
smellName,
scope,
elementName,
String.valueOf(sourceRange.begin.line),
String.valueOf(sourceRange.begin.column),
String.valueOf(sourceRange.end.line),
String.valueOf(sourceRange.end.column + 1)
);
}

private static void writeRow(Writer output, String... values)
throws IOException {
for (int index = 0; index < values.length; index += 1) {
if (index > 0) {
output.write(',');
}
output.write('"');
output.write(values[index].replace("\"", "\"\""));
output.write('"');
}
output.write('\n');
}
}
87 changes: 74 additions & 13 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
isLikelyJavaTestDocument,
ProductionFileResolver,
} from './productionFileResolver';
import { runDetector } from './testSmellDetector';
import { DetectorFinding, runDetector } from './testSmellDetector';

const ANALYZE_CURRENT_FILE_COMMAND = 'testSmellDetector.analyzeCurrentFile';
const DIAGNOSTIC_SOURCE = 'Test Smell Detector';
Expand All @@ -18,9 +18,8 @@ interface AnalysisRequest {
}

/**
* tsDetect currently reports smells for the complete test file rather than an
* exact source line. Anchor diagnostics to the declared test class so VS Code
* can show an honest file-level result in the Problems panel.
* Find the declared test class for findings that do not have one unambiguous
* method or class location.
*/
function findTestClassRange(document: vscode.TextDocument): vscode.Range {
const source = document.getText();
Expand All @@ -42,21 +41,71 @@ function findTestClassRange(document: vscode.TextDocument): vscode.Range {
);
}

/** Convert file-level smell names into native VS Code warning diagnostics. */
/** Convert a one-based bridge location into a validated VS Code range. */
function getFindingRange(
document: vscode.TextDocument,
finding: DetectorFinding,
): vscode.Range | undefined {
const location = finding.location;
if (!location) {
return undefined;
}

const startLine = location.startLine - 1;
const startCharacter = location.startColumn - 1;
const endLine = location.endLine - 1;
const endCharacter = location.endColumn - 1;

if (
startLine < 0 ||
endLine < startLine ||
endLine >= document.lineCount ||
startCharacter < 0 ||
endCharacter < 0 ||
startCharacter > document.lineAt(startLine).text.length ||
endCharacter > document.lineAt(endLine).text.length ||
(startLine === endLine && endCharacter <= startCharacter)
) {
return undefined;
}

return new vscode.Range(
startLine,
startCharacter,
endLine,
endCharacter,
);
}

/** Convert engine findings into native VS Code warning diagnostics. */
function createDiagnostics(
document: vscode.TextDocument,
findings: DetectorFinding[],
detectedSmells: string[],
): vscode.Diagnostic[] {
const range = findTestClassRange(document);

return detectedSmells.map((smell) => {
const fallbackRange = findTestClassRange(document);
const displayFindings: DetectorFinding[] =
findings.length > 0
? findings
: detectedSmells.map((name) => ({
name,
scope: 'file',
elementName: '',
}));

return displayFindings.map((finding) => {
const mappedRange = getFindingRange(document, finding);
const range = mappedRange ?? fallbackRange;
const message = mappedRange
? `${finding.name} detected in ${finding.scope} ${finding.elementName}. The underline marks the affected declaration.`
: `${finding.name} detected in this test file (exact source location unavailable).`;
const diagnostic = new vscode.Diagnostic(
range,
`${smell} detected in this test file (file-level result).`,
message,
vscode.DiagnosticSeverity.Warning,
);
diagnostic.source = DIAGNOSTIC_SOURCE;
diagnostic.code = smell;
diagnostic.code = finding.name;
return diagnostic;
});
}
Expand Down Expand Up @@ -214,7 +263,11 @@ export function activate(context: vscode.ExtensionContext): void {
} else {
diagnostics.set(
document.uri,
createDiagnostics(document, result.detectedSmells),
createDiagnostics(
document,
result.findings,
result.detectedSmells,
),
);
}

Expand All @@ -240,10 +293,18 @@ export function activate(context: vscode.ExtensionContext): void {
for (const smell of result.detectedSmells) {
output.appendLine(`- ${smell}`);
}
output.appendLine('');
output.appendLine(`Mapped findings (${result.findings.length}):`);
for (const finding of result.findings) {
const mappedElement = finding.location
? `${finding.scope} ${finding.elementName} at line ${finding.location.startLine}`
: 'file level (exact declaration unavailable)';
output.appendLine(`- ${finding.name}: ${mappedElement}`);
}
output.show(true);
void vscode.window
.showWarningMessage(
`${result.detectedSmells.length} test smell(s) detected in ${fileName}.`,
`${result.findings.length} test smell finding(s) detected in ${fileName}.`,
'Open Problems',
)
.then((selection) => {
Expand All @@ -256,7 +317,7 @@ export function activate(context: vscode.ExtensionContext): void {
}
} else {
reportAutomaticMessage(
`${fileName}: ${result.numberOfTestMethods} test method(s), ${result.detectedSmells.length} smell(s).`,
`${fileName}: ${result.numberOfTestMethods} test method(s), ${result.findings.length} smell finding(s).`,
);
}
} catch (error: unknown) {
Expand Down
Loading
Loading