diff --git a/src/extension.ts b/src/extension.ts index a72f4e2..69a183f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,19 +4,62 @@ import * as vscode from "vscode"; import { registerCodeLensProvider } from "./providers/codeLensProvider"; import { UnifiedFunctionMetrics } from "./metricsAnalyzer/metricsAnalyzerFactory"; +/** Shared output channel for function complexity details (created once, reused). */ +let detailsChannel: vscode.OutputChannel | undefined; + +/** + * Formats a cognitive complexity breakdown for a function and writes it to the + * shared output channel, then reveals the channel to the user. + */ +function showFunctionDetails( + func?: UnifiedFunctionMetrics, + _uri?: vscode.Uri +): void { + if (!func) { + return; + } + + if (!detailsChannel) { + detailsChannel = vscode.window.createOutputChannel("Code Metrics Details"); + } + + detailsChannel.clear(); + detailsChannel.appendLine(`Function: ${func.name}`); + detailsChannel.appendLine(`Cognitive Complexity: ${func.complexity}`); + detailsChannel.appendLine( + `Location: lines ${func.startLine + 1}–${func.endLine + 1}` + ); + + if (func.details.length === 0) { + detailsChannel.appendLine("\nNo complexity contributors were reported."); + } else { + detailsChannel.appendLine("\nComplexity contributors:"); + detailsChannel.appendLine( + " Line │ +Score │ Nesting │ Reason" + ); + detailsChannel.appendLine( + "────────┼────────┼─────────┼────────────────────────────────────" + ); + for (const d of func.details) { + const line = String(d.line).padStart(6); + const inc = `+${d.increment}`.padStart(6); + const nesting = String(d.nesting).padStart(7); + detailsChannel.appendLine(` ${line} │ ${inc} │ ${nesting} │ ${d.reason}`); + } + } + + detailsChannel.show(true /* preserveFocus */); +} + // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { console.log("Code Metrics extension is now active!"); - // Register command for CodeLens clicks (no-op to suppress errors) + // Register command for CodeLens clicks — shows a formatted breakdown in the output channel const showFunctionDetailsCommand = vscode.commands.registerCommand( "cognitiveComplexity.showFunctionDetails", - (func: UnifiedFunctionMetrics, uri: vscode.Uri) => { - // No-op implementation: Command registered to prevent 'not found' error when CodeLens is clicked - // Future enhancement: Could show function details in a webview or output channel - // Parameters received: func (function metrics data), uri (document URI) - } + showFunctionDetails ); // Register providers @@ -28,4 +71,6 @@ export function activate(context: vscode.ExtensionContext) { // This method is called when your extension is deactivated export function deactivate() { console.log("Code Metrics extension is now deactivated"); + detailsChannel?.dispose(); + detailsChannel = undefined; } diff --git a/src/metricsAnalyzer/metricsAnalyzerFactory.ts b/src/metricsAnalyzer/metricsAnalyzerFactory.ts index 83558a0..0f40076 100644 --- a/src/metricsAnalyzer/metricsAnalyzerFactory.ts +++ b/src/metricsAnalyzer/metricsAnalyzerFactory.ts @@ -146,163 +146,71 @@ function hashString(str: string): number { } /** - * A record of language-specific analyzers that compute cognitive complexity metrics for source code. - * Each analyzer is a function that takes source text and returns an array of complexity data for all functions found. - * - * @remarks - * The analyzers normalize line and column numbers to be 1-based across all languages for consistency. - * Each language analyzer is lazily loaded using require() to optimize initial load time. - * - * @example - * ```typescript - * const analyzer = languageAnalyzers['csharp']; - * const complexityData = analyzer(sourceCode); - * ``` + * Raw detail shape returned by all language-specific analyzers. + * `line` and `column` are 0-based and are normalized to 1-based by createAnalyzer. */ -const languageAnalyzers: Record< - string, - (sourceText: string) => UnifiedFunctionMetrics[] -> = { - csharp: createCSharpAnalyzer(), - go: createGoAnalyzer(), - javascript: createJavaScriptAnalyzer(), - javascriptreact: createJavaScriptAnalyzer(), - typescript: createTypeScriptAnalyzer(), - typescriptreact: createTypeScriptAnalyzer(), -}; +interface RawMetricsDetail { + increment: number; + reason: string; + line: number; + column: number; + nesting: number; +} /** - * Creates a C# cognitive complexity analyzer function. - * - * @returns A function that analyzes C# source code and returns an array of function complexity metrics. - * The returned analyzer function: - * - Takes C# source code as a string parameter - * - Analyzes cognitive complexity of all functions in the code - * - Returns an array of UnifiedFunctionMetrics objects containing: - * - Function name - * - Complexity score - * - Detailed breakdown of complexity increments with line/column positions (1-based indexing) - * - Function boundaries (start/end line and column) - * - * @remarks - * The analyzer dynamically requires the C# analyzer module and normalizes its output - * from 0-based to 1-based line and column indexing for consistency. + * Raw function-level shape returned by all language-specific analyzers. + * All position fields (`startLine`, `endLine`, `startColumn`, `endColumn`) are 0-based + * and are passed through as-is to `UnifiedFunctionMetrics` without normalization. */ -function createCSharpAnalyzer(): ( - sourceText: string -) => UnifiedFunctionMetrics[] { - return function (sourceText: string) { - const { CSharpMetricsAnalyzer } = require("./languages/csharpAnalyzer"); - const functions = CSharpMetricsAnalyzer.analyzeFile(sourceText); - return functions.map((func: any) => ({ - name: func.name, - complexity: func.complexity, - details: func.details.map((detail: any) => ({ - increment: detail.increment, - reason: detail.reason, - line: detail.line + 1, // C# analyzer uses 0-based, normalize to 1-based - column: detail.column + 1, // C# analyzer uses 0-based, normalize to 1-based - nesting: detail.nesting, - })), - startLine: func.startLine, - endLine: func.endLine, - startColumn: func.startColumn, - endColumn: func.endColumn, - })); - }; +interface RawFunctionMetrics { + name: string; + complexity: number; + details: RawMetricsDetail[]; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; } -/** - * Creates a Go cognitive complexity analyzer function. - * - * @returns A function that analyzes Go source code and returns an array of function complexity metrics. - * The returned analyzer function: - * - Takes Go source code as a string parameter - * - Analyzes cognitive complexity of all functions in the code - * - Returns an array of UnifiedFunctionMetrics objects containing: - * - Function name - * - Complexity score - * - Detailed breakdown of complexity increments with line/column positions (1-based indexing) - * - Function boundaries (start/end line and column) - * - * @remarks - * The analyzer dynamically requires the Go analyzer module and normalizes the detail positions - * (line and column in the details array) from 0-based to 1-based indexing for consistency - * with the C# analyzer. Function boundary positions remain as returned by the analyzer. - */ -function createGoAnalyzer(): (sourceText: string) => UnifiedFunctionMetrics[] { - return function (sourceText: string) { - const { GoMetricsAnalyzer } = require("./languages/goAnalyzer"); - interface GoMetricsDetail { - increment: number; - reason: string; - line: number; - column: number; - nesting: number; - } - interface GoFunctionMetrics { - name: string; - complexity: number; - details: GoMetricsDetail[]; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; - } - const functions = GoMetricsAnalyzer.analyzeFile(sourceText) as GoFunctionMetrics[]; - return functions.map((func: GoFunctionMetrics) => ({ - name: func.name, - complexity: func.complexity, - details: func.details.map((detail: GoMetricsDetail) => ({ - increment: detail.increment, - reason: detail.reason, - line: detail.line + 1, // Go analyzer uses 0-based, normalize to 1-based - column: detail.column + 1, // Go analyzer uses 0-based, normalize to 1-based - nesting: detail.nesting, - })), - startLine: func.startLine, - endLine: func.endLine, - startColumn: func.startColumn, - endColumn: func.endColumn, - })); - }; +/** Shape of a language analyzer class that must expose a static `analyzeFile` method. */ +interface AnalyzerClass { + analyzeFile(sourceText: string): RawFunctionMetrics[]; } /** - * Creates a JavaScript cognitive complexity analyzer function. + * Creates a language-specific cognitive complexity analyzer function. + * + * All language analyzers share the same output shape and the same normalization + * step (0-based → 1-based line/column in detail positions). This helper + * centralises that logic so individual language registrations stay concise. * - * @returns A function that analyzes JavaScript source code and returns an array of function complexity metrics. + * @param modulePath - require()-style path to the language analyzer module (relative to this file) + * @param className - Name of the exported analyzer class that exposes a static `analyzeFile` method + * @returns A function that takes source text and returns an array of UnifiedFunctionMetrics + * @throws {Error} If the module does not export the expected class with an `analyzeFile` method */ -function createJavaScriptAnalyzer(): ( - sourceText: string -) => UnifiedFunctionMetrics[] { - return function (sourceText: string) { - const { JavaScriptMetricsAnalyzer } = require("./languages/javascriptAnalyzer"); - interface JSDetail { - increment: number; - reason: string; - line: number; - column: number; - nesting: number; - } - interface JSFunctionMetrics { - name: string; - complexity: number; - details: JSDetail[]; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; +export function createAnalyzer( + modulePath: string, + className: string +): (sourceText: string) => UnifiedFunctionMetrics[] { + return function (sourceText: string): UnifiedFunctionMetrics[] { + const mod = require(modulePath) as Record; + const analyzerClass = mod[className]; + if (!analyzerClass || typeof analyzerClass.analyzeFile !== "function") { + throw new Error( + `Analyzer module "${modulePath}" does not export a class named "${className}" ` + + `with a static analyzeFile method.` + ); } - const functions = JavaScriptMetricsAnalyzer.analyzeFile(sourceText) as JSFunctionMetrics[]; - return functions.map((func: JSFunctionMetrics) => ({ + const functions: RawFunctionMetrics[] = analyzerClass.analyzeFile(sourceText); + return functions.map((func: RawFunctionMetrics) => ({ name: func.name, complexity: func.complexity, - details: func.details.map((detail: JSDetail) => ({ + details: func.details.map((detail: RawMetricsDetail) => ({ increment: detail.increment, reason: detail.reason, - line: detail.line + 1, // JS analyzer uses 0-based, normalize to 1-based - column: detail.column + 1, // JS analyzer uses 0-based, normalize to 1-based + line: detail.line + 1, // analyzers use 0-based; normalize to 1-based + column: detail.column + 1, // analyzers use 0-based; normalize to 1-based nesting: detail.nesting, })), startLine: func.startLine, @@ -314,46 +222,21 @@ function createJavaScriptAnalyzer(): ( } /** - * Creates a TypeScript cognitive complexity analyzer function. + * A record of language-specific analyzers that compute cognitive complexity metrics for source code. * - * @returns A function that analyzes TypeScript source code and returns an array of function complexity metrics. + * @remarks + * Line and column numbers in detail positions are normalized to 1-based across all languages. + * Each analyzer module is lazily loaded via require() on first invocation (Node.js caches the module + * afterwards), so startup time is not affected by the number of supported languages. */ -function createTypeScriptAnalyzer(): ( - sourceText: string -) => UnifiedFunctionMetrics[] { - return function (sourceText: string) { - const { TypeScriptMetricsAnalyzer } = require("./languages/typescriptAnalyzer"); - interface TSDetail { - increment: number; - reason: string; - line: number; - column: number; - nesting: number; - } - interface TSFunctionMetrics { - name: string; - complexity: number; - details: TSDetail[]; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; - } - const functions = TypeScriptMetricsAnalyzer.analyzeFile(sourceText) as TSFunctionMetrics[]; - return functions.map((func: TSFunctionMetrics) => ({ - name: func.name, - complexity: func.complexity, - details: func.details.map((detail: TSDetail) => ({ - increment: detail.increment, - reason: detail.reason, - line: detail.line + 1, // TS analyzer uses 0-based, normalize to 1-based - column: detail.column + 1, // TS analyzer uses 0-based, normalize to 1-based - nesting: detail.nesting, - })), - startLine: func.startLine, - endLine: func.endLine, - startColumn: func.startColumn, - endColumn: func.endColumn, - })); - }; -} +const languageAnalyzers: Record< + string, + (sourceText: string) => UnifiedFunctionMetrics[] +> = { + csharp: createAnalyzer("./languages/csharpAnalyzer", "CSharpMetricsAnalyzer"), + go: createAnalyzer("./languages/goAnalyzer", "GoMetricsAnalyzer"), + javascript: createAnalyzer("./languages/javascriptAnalyzer", "JavaScriptMetricsAnalyzer"), + javascriptreact: createAnalyzer("./languages/javascriptAnalyzer", "JavaScriptMetricsAnalyzer"), + typescript: createAnalyzer("./languages/typescriptAnalyzer", "TypeScriptMetricsAnalyzer"), + typescriptreact: createAnalyzer("./languages/typescriptAnalyzer", "TypeScriptMetricsAnalyzer"), +}; diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 5e73189..4aee666 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -7,6 +7,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; +import * as extensionModule from "../extension"; suite("Extension Activation Tests", () => { // Ensure extension is activated before running tests @@ -76,4 +77,74 @@ suite("Extension Activation Tests", () => { ); } }); + + test("should execute showFunctionDetails with non-empty details", async () => { + // Tests the table-rendering branch (details.length > 0) + const mockFunctionData: import("../metricsAnalyzer/metricsAnalyzerFactory").UnifiedFunctionMetrics = { + name: "ComplexFunction", + complexity: 3, + details: [ + { increment: 1, reason: "if statement", line: 5, column: 4, nesting: 0 }, + { increment: 2, reason: "nested if statement", line: 7, column: 8, nesting: 1 }, + ], + startLine: 3, + endLine: 15, + startColumn: 0, + endColumn: 1, + }; + + const mockUri = vscode.Uri.file("/test/complex.cs"); + + try { + await vscode.commands.executeCommand( + "cognitiveComplexity.showFunctionDetails", + mockFunctionData, + mockUri + ); + assert.ok(true, "Command with non-empty details executed without errors"); + } catch (error) { + assert.fail( + `Command execution with non-empty details should not throw errors, but got: ${error}` + ); + } + }); + + test("should reuse output channel when showFunctionDetails is called multiple times", async () => { + // Exercises the detailsChannel reuse path (second call skips createOutputChannel) + const mockFunctionData: import("../metricsAnalyzer/metricsAnalyzerFactory").UnifiedFunctionMetrics = { + name: "RepeatedFunction", + complexity: 1, + details: [ + { increment: 1, reason: "if statement", line: 2, column: 2, nesting: 0 }, + ], + startLine: 1, + endLine: 5, + startColumn: 0, + endColumn: 1, + }; + + try { + await vscode.commands.executeCommand( + "cognitiveComplexity.showFunctionDetails", + mockFunctionData + ); + // Second call should reuse the existing output channel + await vscode.commands.executeCommand( + "cognitiveComplexity.showFunctionDetails", + mockFunctionData + ); + assert.ok(true, "Channel reuse executed without errors"); + } catch (error) { + assert.fail( + `Channel reuse should not throw errors, but got: ${error}` + ); + } + }); + + test("should deactivate extension without errors", () => { + // Directly invoke deactivate to cover the disposal path + assert.doesNotThrow(() => { + extensionModule.deactivate(); + }, "deactivate() should not throw"); + }); }); diff --git a/src/test/metricsAnalyzer/metricsAnalyzerFactory.test.ts b/src/test/metricsAnalyzer/metricsAnalyzerFactory.test.ts index d07964f..822aabd 100644 --- a/src/test/metricsAnalyzer/metricsAnalyzerFactory.test.ts +++ b/src/test/metricsAnalyzer/metricsAnalyzerFactory.test.ts @@ -3,6 +3,7 @@ import { MetricsAnalyzerFactory, UnifiedFunctionMetrics, UnifiedMetricsDetail, + createAnalyzer, } from "../../metricsAnalyzer/metricsAnalyzerFactory"; suite("Metrics Analyzer Factory Tests", () => { @@ -851,4 +852,30 @@ func IsComplexCondition(value int, flag1, flag2 bool) bool { }); }); }); + + suite("createAnalyzer Runtime Guard", () => { + test("should throw a descriptive error when module does not export the expected class", () => { + // Use a valid module path but a class name that does not exist in it + const analyzer = createAnalyzer( + "./languages/csharpAnalyzer", + "NonExistentClass" + ); + + assert.throws( + () => analyzer("public class T {}"), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes("NonExistentClass"), + `Expected error message to mention 'NonExistentClass', got: ${err.message}` + ); + assert.ok( + err.message.includes("analyzeFile"), + `Expected error message to mention 'analyzeFile', got: ${err.message}` + ); + return true; + } + ); + }); + }); });