// Copyright 2025 Chris Bunting
// Brief: Rust analyzer for Static Analysis MCP Server
// Scope: Provides Clippy and rustfmt 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 RustRule {
ruleId: string;
description: string;
category: IssueCategory;
severity: SeverityLevel;
tool: 'clippy' | 'rustfmt';
fixable: boolean;
}
export class RustAnalyzer implements AnalyzerInterface {
private logger: Logger;
private configParser: ConfigParser;
private supportedRules: Map<string, RustRule> = new Map();
private projectConfig: any = null;
constructor(logger: Logger, configParser: ConfigParser) {
this.logger = logger;
this.configParser = configParser;
this.initializeRules();
}
private initializeRules(): void {
// Initialize common Rust rules with metadata
const rules: RustRule[] = [
{
ruleId: 'dead_code',
description: 'Dead code',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'unused_variables',
description: 'Unused variable',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'unused_imports',
description: 'Unused import',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'needless_return',
description: 'Needless return',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'clone_on_copy',
description: 'Using clone on type with Copy trait',
category: IssueCategory.PERFORMANCE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'eq_op',
description: 'Equal operands on both sides of operator',
category: IssueCategory.SECURITY,
severity: SeverityLevel.ERROR,
tool: 'clippy',
fixable: false
},
{
ruleId: 'if_same_then_else',
description: 'If block with same then and else branches',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'formatting',
description: 'Code formatting issues',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'rustfmt',
fixable: true
},
{
ruleId: 'complexity',
description: 'Code complexity issues',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: false
},
{
ruleId: 'perf',
description: 'Performance issues',
category: IssueCategory.PERFORMANCE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'style',
description: 'Style issues',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
},
{
ruleId: 'suspicious',
description: 'Suspicious code patterns',
category: IssueCategory.SECURITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: false
}
];
rules.forEach(rule => {
this.supportedRules.set(rule.ruleId, rule);
});
}
async initializeProject(projectPath: string): Promise<void> {
try {
const configFiles = await this.configParser.findConfigFiles(projectPath, Language.RUST);
this.projectConfig = this.configParser.mergeConfigs(configFiles);
this.logger.info('Rust project configuration loaded successfully');
} catch (error) {
this.projectConfig = {};
this.logger.info('Using default Rust 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 Clippy analysis
if (!options.exclude?.includes('clippy')) {
const clippyIssues = await this.runClippyAnalysis(filePath, options);
issues.push(...clippyIssues);
}
// Run rustfmt check
if (!options.exclude?.includes('rustfmt')) {
const rustfmtIssues = await this.runRustfmtAnalysis(filePath, options);
issues.push(...rustfmtIssues);
}
return this.createAnalysisResult(filePath, Language.RUST, issues, metrics);
} catch (error) {
if (error instanceof AnalysisError) {
throw error;
}
throw ErrorHandler.createAnalysisFailedError(filePath, Language.RUST, error as Error);
}
}
async analyzeProject(
projectPath: string,
patterns?: string[],
excludePatterns?: string[],
options: AnalysisOptions = {}
): Promise<AnalysisResult[]> {
try {
await this.initializeProject(projectPath);
const defaultPatterns = [
'**/*.rs',
'!**/target/**',
'!**/node_modules/**',
'!**/.git/**'
];
const filePatterns = patterns || defaultPatterns;
const exclude = excludePatterns || [];
// Find Rust 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.RUST, 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: ['clippy', 'rustfmt']
};
} catch (error) {
throw ErrorHandler.createExecutionError('Rust tools', 'getting rules', error as Error);
}
}
async configure(config: any): Promise<void> {
try {
if (config.projectPath) {
await this.initializeProject(config.projectPath);
}
this.logger.info('Rust analyzer configured successfully');
} catch (error) {
throw ErrorHandler.createInvalidConfigError('rust config', error as Error);
}
}
private async runClippyAnalysis(filePath: string, options: AnalysisOptions): Promise<AnalysisIssue[]> {
try {
// Check if this is part of a Cargo project
const projectDir = this.findCargoProject(filePath);
let command: string;
if (projectDir) {
// Run clippy on the entire project
command = `cd "${projectDir}" && cargo clippy --message-format=json --quiet`;
} else {
// Run clippy on individual file
command = `rustc --crate-type lib --clippy "${filePath}" 2>&1`;
}
const result = execSync(command, { encoding: 'utf-8', timeout: 60000 });
const issues: AnalysisIssue[] = [];
if (projectDir) {
// Parse Cargo clippy JSON output
const lines = result.split('\n');
for (const line of lines) {
if (line.trim()) {
try {
const clippyMessage = JSON.parse(line);
if (clippyMessage.message?.spans) {
const issue = this.parseClippyMessage(clippyMessage, filePath);
if (issue && this.shouldIncludeIssue(issue, options)) {
issues.push(issue);
}
}
} catch (parseError) {
// Ignore JSON parse errors
}
}
}
} else {
// Parse rustc clippy output
const lines = result.split('\n');
for (const line of lines) {
if (line.includes('warning') || line.includes('error')) {
const issue = this.parseRustcLine(line, filePath);
if (issue && this.shouldIncludeIssue(issue, options)) {
issues.push(issue);
}
}
}
}
return issues;
} catch (error) {
this.logger.warn(`Clippy analysis failed for ${filePath}:`, error);
return [];
}
}
private async runRustfmtAnalysis(filePath: string, options: AnalysisOptions): Promise<AnalysisIssue[]> {
try {
const command = `rustfmt --check "${filePath}"`;
const result = execSync(command, { encoding: 'utf-8', timeout: 30000 });
const issues: AnalysisIssue[] = [];
// If rustfmt --check produces output, there are formatting issues
if (result.length > 0) {
const issue: AnalysisIssue = {
id: `rustfmt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId: 'formatting',
message: 'Code formatting issues detected',
severity: SeverityLevel.WARNING,
line: 1,
column: 1,
category: IssueCategory.STYLE,
fix: {
description: 'Format code with rustfmt',
replacement: 'Run: rustfmt ' + filePath,
range: {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 }
}
},
tags: ['rustfmt', 'formatting']
};
issues.push(issue);
}
return issues;
} catch (error) {
this.logger.warn(`Rustfmt analysis failed for ${filePath}:`, error);
return [];
}
}
private findCargoProject(filePath: string): string | null {
let currentDir = dirname(filePath);
while (currentDir !== dirname(currentDir)) {
if (existsSync(join(currentDir, 'Cargo.toml'))) {
return currentDir;
}
currentDir = dirname(currentDir);
}
return null;
}
private parseClippyMessage(message: any, filePath: string): AnalysisIssue | null {
try {
if (!message.message?.spans || message.message.spans.length === 0) {
return null;
}
const span = message.message.spans[0];
const rule = this.inferClippyRule(message.message.code?.code || 'clippy.general');
return {
id: `clippy_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId: message.message.code?.code || 'clippy.general',
message: message.message.message,
severity: this.mapClippyLevel(message.message.level),
line: span.line_start || 1,
column: span.column_start || 1,
endLine: span.line_end,
endColumn: span.column_end,
category: rule.category,
tags: ['clippy']
};
} catch (error) {
this.logger.warn('Failed to parse clippy message:', error);
return null;
}
}
private parseRustcLine(line: string, filePath: string): AnalysisIssue | null {
try {
// Example: file.rs:10:5: warning: unused variable
const match = line.match(/^(.+):(\d+):(\d+):\s+(warning|error):\s+(.+)$/);
if (!match) {
return null;
}
const [, , lineNum, colNum, level, message] = match;
const rule = this.inferClippyRule(message);
return {
id: `clippy_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
ruleId: rule.ruleId,
message: message.trim(),
severity: level === 'error' ? SeverityLevel.ERROR : SeverityLevel.WARNING,
line: parseInt(lineNum),
column: parseInt(colNum),
category: rule.category,
tags: ['clippy']
};
} catch (error) {
this.logger.warn('Failed to parse rustc line:', error);
return null;
}
}
private inferClippyRule(code: string): RustRule {
const codeLower = code.toLowerCase();
if (codeLower.includes('dead_code') || codeLower.includes('dead code')) {
return {
ruleId: 'dead_code',
description: 'Dead code',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('unused_var') || codeLower.includes('unused variable')) {
return {
ruleId: 'unused_variables',
description: 'Unused variable',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('unused_import') || codeLower.includes('unused import')) {
return {
ruleId: 'unused_imports',
description: 'Unused import',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('needless_return') || codeLower.includes('needless return')) {
return {
ruleId: 'needless_return',
description: 'Needless return',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('clone_on_copy') || codeLower.includes('clone on copy')) {
return {
ruleId: 'clone_on_copy',
description: 'Using clone on type with Copy trait',
category: IssueCategory.PERFORMANCE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('eq_op') || codeLower.includes('equal operands')) {
return {
ruleId: 'eq_op',
description: 'Equal operands on both sides of operator',
category: IssueCategory.SECURITY,
severity: SeverityLevel.ERROR,
tool: 'clippy',
fixable: false
};
} else if (codeLower.includes('if_same_then_else') || codeLower.includes('if same then else')) {
return {
ruleId: 'if_same_then_else',
description: 'If block with same then and else branches',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('complexity')) {
return {
ruleId: 'complexity',
description: 'Code complexity issues',
category: IssueCategory.MAINTAINABILITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: false
};
} else if (codeLower.includes('perf')) {
return {
ruleId: 'perf',
description: 'Performance issues',
category: IssueCategory.PERFORMANCE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('style')) {
return {
ruleId: 'style',
description: 'Style issues',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
} else if (codeLower.includes('suspicious')) {
return {
ruleId: 'suspicious',
description: 'Suspicious code patterns',
category: IssueCategory.SECURITY,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: false
};
}
// Default fallback
return {
ruleId: 'clippy.general',
description: 'General clippy warning',
category: IssueCategory.STYLE,
severity: SeverityLevel.WARNING,
tool: 'clippy',
fixable: true
};
}
private mapClippyLevel(level: string): SeverityLevel {
switch (level.toLowerCase()) {
case 'error':
return SeverityLevel.ERROR;
case 'warning':
return SeverityLevel.WARNING;
case 'note':
case 'help':
return SeverityLevel.INFO;
default:
return SeverityLevel.INFO;
}
}
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('///') ||
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;
}
}
}