// Copyright 2025 Chris Bunting
// Brief: Java analyzer for Static Analysis MCP Server
// Scope: Provides SpotBugs, PMD, and Checkstyle integration
import { execSync, exec } from 'child_process';
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join, dirname, extname, basename } from 'path';
import {
AnalysisResult,
AnalysisIssue,
AnalysisMetrics,
AnalysisOptions,
SeverityLevel,
IssueCategory,
Language,
FixSuggestion
} from '@mcp-code-analysis/shared-types';
import { AnalyzerInterface } from '../services/StaticAnalysisService.js';
import { Logger } from '../utils/Logger.js';
import { ConfigParser } from '../services/ConfigParser.js';
import { ErrorHandler, AnalysisError, ErrorCode } from '../utils/ErrorHandler.js';
export interface JavaRule {
ruleId: string;
description: string;
category: IssueCategory;
severity: SeverityLevel;
tool: 'spotbugs' | 'pmd' | 'checkstyle';
fixable: boolean;
}
export class JavaAnalyzer implements AnalyzerInterface {
private logger: Logger;
private configParser: ConfigParser;
private supportedRules: Map<string, JavaRule> = new Map();
private projectConfig: any = null;
constructor(logger: Logger, configParser: ConfigParser) {
this.logger = logger;
this.configParser = configParser;
this.initializeRules();
}
private initializeRules(): void {
// Initialize common Java rules with metadata
const rules: JavaRule[] = [
{
ruleId: 'UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR',
description: 'Field not initialized in constructor',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'spotbugs',
fixable: true
},
{
ruleId: 'NP_NULL_ON_SOME_PATH',
description: 'Possible null pointer dereference',
category: IssueCategory.SECURITY,
severity: SeverityLevel.ERROR,
tool: 'spotbugs',
fixable: false
},
{
ruleId: 'DM_DEFAULT_ENCODING',
description: 'Reliance on default encoding',
category: IssueCategory.SECURITY,
severity: SeverityLevel.WARNING,
tool: 'spotbugs',
fixable: true
},
{
ruleId: 'EmptyStatement',
description: 'Empty statement',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'pmd',
fixable: true
},
{
ruleId: 'UnusedLocalVariable',
description: 'Unused local variable',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'pmd',
fixable: true
},
{
ruleId: 'EmptyCatchBlock',
description: 'Empty catch block',
category: IssueCategory.SECURITY,
severity: SeverityLevel.WARNING,
tool: 'pmd',
fixable: true
},
{
ruleId: 'MissingSwitchDefault',
description: 'Missing default in switch',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'pmd',
fixable: true
},
{
ruleId: 'MethodLength',
description: 'Method length',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'checkstyle',
fixable: false
},
{
ruleId: 'LineLength',
description: 'Line length',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'checkstyle',
fixable: true
}
];
rules.forEach(rule => {
this.supportedRules.set(rule.ruleId, rule);
});
}
async initializeProject(projectPath: string): Promise<void> {
try {
const configFiles = await this.configParser.findConfigFiles(projectPath, Language.JAVA);
this.projectConfig = this.configParser.mergeConfigs(configFiles);
this.logger.info('Java project configuration loaded successfully');
} catch (error) {
this.projectConfig = {};
this.logger.info('Using default Java configuration');
}
}
async analyzeFile(filePath: string, options: AnalysisOptions = {}): Promise<AnalysisResult> {
try {
if (!existsSync(filePath)) {
throw ErrorHandler.createFileNotFoundError(filePath);
}
const content = readFileSync(filePath, 'utf-8');
const issues: AnalysisIssue[] = [];
const metrics = this.calculateMetrics(content);
// Run SpotBugs analysis
if (!options.exclude?.includes('spotbugs')) {
const spotbugsIssues = await this.runSpotBugsAnalysis(filePath, options);
issues.push(...spotbugsIssues);
}
// Run PMD analysis
if (!options.exclude?.includes('pmd')) {
const pmdIssues = await this.runPMDAnalysis(filePath, options);
issues.push(...pmdIssues);
}
// Run Checkstyle analysis
if (!options.exclude?.includes('checkstyle')) {
const checkstyleIssues = await this.runCheckstyleAnalysis(filePath, options);
issues.push(...checkstyleIssues);
}
return this.createAnalysisResult(filePath, Language.JAVA, issues, metrics);
} catch (error) {
if (error instanceof AnalysisError) {
throw error;
}
throw ErrorHandler.createAnalysisFailedError(filePath, Language.JAVA, error as Error);
}
}
async analyzeProject(
projectPath: string,
patterns?: string[],
excludePatterns?: string[],
options: AnalysisOptions = {}
): Promise<AnalysisResult[]> {
try {
await this.initializeProject(projectPath);
const defaultPatterns = [
'**/*.java',
'!**/target/**',
'!**/build/**',
'!**/dist/**',
'!**/node_modules/**',
'!**/.git/**'
];
const filePatterns = patterns || defaultPatterns;
const exclude = excludePatterns || [];
// Find Java files
const { glob } = await import('fast-glob');
const files = await glob(filePatterns, {
cwd: projectPath,
ignore: exclude,
absolute: true,
onlyFiles: true
});
const results: AnalysisResult[] = [];
for (const file of files) {
try {
const result = await this.analyzeFile(file, options);
results.push(result);
} catch (error) {
this.logger.warn(`Failed to analyze file ${file}:`, error);
}
}
return results;
} catch (error) {
throw ErrorHandler.createAnalysisFailedError(projectPath, Language.JAVA, error as Error);
}
}
async getRules(configFile?: string): Promise<any> {
try {
const allRules: any[] = [];
// Add our predefined rules
Array.from(this.supportedRules.values()).forEach(rule => {
allRules.push({
...rule,
enabled: true // All rules are enabled by default
});
});
return {
rules: allRules,
total: allRules.length,
enabled: allRules.filter(r => r.enabled).length,
tools: ['spotbugs', 'pmd', 'checkstyle']
};
} catch (error) {
throw ErrorHandler.createExecutionError('Java tools', 'getting rules', error as Error);
}
}
async configure(config: any): Promise<void> {
try {
if (config.projectPath) {
await this.initializeProject(config.projectPath);
}
this.logger.info('Java analyzer configured successfully');
} catch (error) {
throw ErrorHandler.createInvalidConfigError('java config', error as Error);
}
}
private async runSpotBugsAnalysis(filePath: string, options: AnalysisOptions): Promise<AnalysisIssue[]> {
try {
// First compile the Java file if needed
const compiledPath = await this.compileJavaFile(filePath);
if (!compiledPath) {
return [];
}
const command = `spotbugs -textui -xml ${compiledPath}`;
const result = execSync(command, { encoding: 'utf-8', timeout: 60000 });
const issues: AnalysisIssue[] = [];
// Parse SpotBugs XML output
if (result.includes('<BugInstance')) {
const bugInstances = result.match(/<BugInstance[^>]*>[\s\S]*?<\/BugInstance>/g) || [];
for (const bugInstance of bugInstances) {
const issue = this.parseSpotBugsBugInstance(bugInstance, filePath);
if (issue && this.shouldIncludeIssue(issue, options)) {
issues.push(issue);
}
}
}
return issues;
} catch (error) {
this.logger.warn(`SpotBugs analysis failed for ${filePath}:`, error);
return [];
}
}
private async runPMDAnalysis(filePath: string, options: AnalysisOptions): Promise<AnalysisIssue[]> {
try {
const command = `pmd -d ${filePath} -f xml -R rulesets/java/quickstart.xml`;
const result = execSync(command, { encoding: 'utf-8', timeout: 30000 });
const issues: AnalysisIssue[] = [];
// Parse PMD XML output
if (result.includes('<violation')) {
const violations = result.match(/<violation[^>]*>[\s\S]*?<\/violation>/g) || [];
for (const violation of violations) {
const issue = this.parsePMDViolation(violation, filePath);
if (issue && this.shouldIncludeIssue(issue, options)) {
issues.push(issue);
}
}
}
return issues;
} catch (error) {
this.logger.warn(`PMD analysis failed for ${filePath}:`, error);
return [];
}
}
private async runCheckstyleAnalysis(filePath: string, options: AnalysisOptions): Promise<AnalysisIssue[]> {
try {
const command = `checkstyle -c /sun_checks.xml ${filePath}`;
const result = execSync(command, { encoding: 'utf-8', timeout: 30000 });
const issues: AnalysisIssue[] = [];
const lines = result.split('\n');
for (const line of lines) {
if (line.includes('ERROR') || line.includes('WARNING')) {
const issue = this.parseCheckstyleLine(line, filePath);
if (issue && this.shouldIncludeIssue(issue, options)) {
issues.push(issue);
}
}
}
return issues;
} catch (error) {
this.logger.warn(`Checkstyle analysis failed for ${filePath}:`, error);
return [];
}
}
private async compileJavaFile(filePath: string): Promise<string | null> {
try {
const dir = dirname(filePath);
const fileName = basename(filePath, '.java');
const classFile = join(dir, `${fileName}.class`);
// Check if already compiled
if (existsSync(classFile)) {
return classFile;
}
// Compile the file
const command = `javac "${filePath}"`;
execSync(command, { encoding: 'utf-8', timeout: 30000 });
return existsSync(classFile) ? classFile : null;
} catch (error) {
this.logger.warn(`Failed to compile Java file ${filePath}:`, error);
return null;
}
}
private parseSpotBugsBugInstance(bugInstance: string, filePath: string): AnalysisIssue | null {
try {
const typeMatch = bugInstance.match(/type="([^"]+)"/);
const messageMatch = bugInstance.match(/<ShortMessage>([^<]+)<\/ShortMessage>/);
const lineMatch = bugInstance.match(/<SourceLine[^>]*start="(\d+)"/);
if (!typeMatch || !messageMatch) {
return null;
}
const rule = this.supportedRules.get(typeMatch[1]);
return {
id: `spotbugs_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId: typeMatch[1],
message: messageMatch[1].trim(),
severity: rule?.severity || SeverityLevel.WARNING,
line: lineMatch ? parseInt(lineMatch[1]) : 1,
column: 1,
category: rule?.category || IssueCategory.MAINTAINABILITY,
tags: ['spotbugs']
};
} catch (error) {
this.logger.warn('Failed to parse SpotBugs bug instance:', error);
return null;
}
}
private parsePMDViolation(violation: string, filePath: string): AnalysisIssue | null {
try {
const lineMatch = violation.match(/beginline="(\d+)"/);
const ruleMatch = violation.match(/rule="([^"]+)"/);
const messageMatch = violation.match(/>([^<]+)</);
if (!lineMatch || !ruleMatch || !messageMatch) {
return null;
}
const rule = this.supportedRules.get(ruleMatch[1]);
return {
id: `pmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId: ruleMatch[1],
message: messageMatch[1].trim(),
severity: rule?.severity || SeverityLevel.WARNING,
line: parseInt(lineMatch[1]),
column: 1,
category: rule?.category || IssueCategory.MAINTAINABILITY,
tags: ['pmd']
};
} catch (error) {
this.logger.warn('Failed to parse PMD violation:', error);
return null;
}
}
private parseCheckstyleLine(line: string, filePath: string): AnalysisIssue | null {
try {
const match = line.match(/ERROR\s+(\d+):(\d+)\s+-\s+([^:]+):\s+(.+)$/);
if (!match) {
return null;
}
const [, lineNum, colNum, ruleId, message] = match;
const rule = this.supportedRules.get(ruleId);
return {
id: `checkstyle_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId,
message: message.trim(),
severity: rule?.severity || SeverityLevel.WARNING,
line: parseInt(lineNum),
column: parseInt(colNum),
category: rule?.category || IssueCategory.STYLE,
tags: ['checkstyle']
};
} catch (error) {
this.logger.warn('Failed to parse Checkstyle line:', error);
return null;
}
}
private shouldIncludeIssue(issue: AnalysisIssue, options: AnalysisOptions): boolean {
// Filter by rules
if (options.rules && options.rules.length > 0) {
if (!options.rules.includes(issue.ruleId)) {
return false;
}
}
// Filter by excluded rules
if (options.exclude && options.exclude.length > 0) {
if (options.exclude.includes(issue.ruleId)) {
return false;
}
}
// Filter by fixable
if (options.fixable && !issue.fix) {
return false;
}
return true;
}
private calculateMetrics(content: string): AnalysisMetrics {
const lines = content.split('\n');
const linesOfCode = lines.length;
const commentLines = lines.filter(line =>
line.trim().startsWith('//') ||
line.trim().startsWith('/*') ||
line.trim().startsWith('*') ||
line.trim().startsWith('*')
).length;
return {
linesOfCode,
commentLines,
cyclomaticComplexity: 0, // Would need more sophisticated analysis
cognitiveComplexity: 0, // Would need more sophisticated analysis
maintainabilityIndex: 0 // Would need more sophisticated analysis
};
}
private createAnalysisResult(
filePath: string,
language: Language,
issues: AnalysisIssue[],
metrics: AnalysisMetrics
): AnalysisResult {
return {
id: `analysis_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date(),
filePath,
language,
issues,
metrics,
severity: this.calculateOverallSeverity(issues)
};
}
private calculateOverallSeverity(issues: AnalysisIssue[]): SeverityLevel {
if (issues.length === 0) {
return SeverityLevel.INFO;
}
const hasErrors = issues.some(issue => issue.severity === SeverityLevel.ERROR);
const hasWarnings = issues.some(issue => issue.severity === SeverityLevel.WARNING);
if (hasErrors) {
return SeverityLevel.ERROR;
} else if (hasWarnings) {
return SeverityLevel.WARNING;
} else {
return SeverityLevel.INFO;
}
}
}