diff --git a/.env b/.env
index 86798f4..8cafc30 100644
--- a/.env
+++ b/.env
@@ -1,6 +1,6 @@
-SERVER_URL='127.0.0.1:8000'
-SMELL_MAP_KEY='workspaceSmells'
-FILE_CHANGES_KEY='lastSavedHashes'
-LAST_USED_SMELLS_KEY='lastUsedSmells'
-CURRENT_REFACTOR_DATA_KEY='refactorData'
-ACTIVE_DIFF_KEY='activeDiff'
+SERVER_URL='127.0.0.1'
+HASH_PATH_MAP_KEY='hashPathMap'
+SMELL_CACHE_KEY='smellCache'
+WORKSPACE_METRICS_DATA='metricsData'
+WORKSPACE_CONFIGURED_PATH='workspaceConfiguredPath'
+UNFINISHED_REFACTORING='unfinishedRefactoring'
diff --git a/.github/workflows/jest-tests.yaml b/.github/workflows/jest-tests.yaml
index 60c8bc3..c71a146 100644
--- a/.github/workflows/jest-tests.yaml
+++ b/.github/workflows/jest-tests.yaml
@@ -18,8 +18,9 @@ jobs:
with:
node-version: 20
- - name: Install dependencies
- run: npm install
+ - name: Clean install
+ run: |
+ npm ci
- name: Run Jest tests
run: npm test -- --coverage
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..611807c
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,58 @@
+name: Publish Extension
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # Needed for tag/release creation
+ id-token: write # For OIDC auth
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Full history needed for tagging
+
+ - name: Get version
+ id: version
+ run: |
+ VERSION=$(node -p "require('./package.json').version")
+ echo "tag_name=v$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Create and push tag
+ run: |
+ git config --global user.name "GitHub Actions"
+ git config --global user.email "actions@github.com"
+ git tag ${{ steps.version.outputs.tag_name }}
+ git push origin ${{ steps.version.outputs.tag_name }}
+
+ - name: Install dependencies
+ run: |
+ npm install
+ npm install -g @vscode/vsce
+
+ - name: Package Extension
+ run: |
+ mkdir -p dist
+ vsce package --out ./dist/extension-${{ steps.version.outputs.tag_name }}.vsix
+
+ - name: Create Draft Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: ${{ steps.version.outputs.tag_name }}
+ name: ${{ steps.version.outputs.tag_name }}
+ body: 'Release notes'
+ files: |
+ dist/extension-${{ steps.version.outputs.tag_name }}.vsix
+ draft: true
+ prerelease: false
+
+ - name: Publish to Marketplace
+ run: |
+ vsce publish -p $VSCE_PAT
+ env:
+ VSCE_PAT: ${{ secrets.VSCE_PAT }}
diff --git a/.github/workflows/version-check.yaml b/.github/workflows/version-check.yaml
new file mode 100644
index 0000000..a92a9a4
--- /dev/null
+++ b/.github/workflows/version-check.yaml
@@ -0,0 +1,59 @@
+name: PR Version Check
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ validate_version:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ fetch-depth: 0 # Required for branch comparison
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - run: npm install compare-versions
+
+ - name: Get PR version
+ id: pr_version
+ run: |
+ PR_VERSION=$(node -p "require('./package.json').version")
+ echo "pr_version=$PR_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Fetch main branch
+ run: git fetch origin main
+
+ - name: Get main's version
+ id: main_version
+ run: |
+ MAIN_VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version")
+ echo "main_version=$MAIN_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Validate version bump
+ run: |
+ # Write a temporary Node.js script for version comparison
+ cat > compare-versions.mjs << 'EOF'
+ import { compareVersions } from 'compare-versions';
+
+ const mainVersion = process.argv[2];
+ const prVersion = process.argv[3];
+
+ console.log("Main version:", mainVersion)
+ console.log("PR version:", prVersion)
+
+ if (compareVersions(prVersion, mainVersion) < 1) {
+ console.error(`::error::Version ${prVersion} must be greater than ${mainVersion}`);
+ process.exit(1);
+ }
+ EOF
+
+ node compare-versions.mjs "${{ steps.main_version.outputs.main_version }}" "${{ steps.pr_version.outputs.pr_version }}"
+
+ echo "✓ Version validated"
diff --git a/.gitignore b/.gitignore
index 4a6b8bd..5516fa6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ node_modules
.vscode-test/
*.vsix
coverage/
+.venv/
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
index ed1cfa5..21a17ba 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -3,6 +3,7 @@
"printWidth": 85,
"semi": true,
"singleQuote": true,
+ "endOfLine": "auto",
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
diff --git a/.vscodeignore b/.vscodeignore
index d255964..c80888d 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -1,14 +1,18 @@
-.vscode/**
-.vscode-test/**
-out/**
-node_modules/**
src/**
-.gitignore
-.yarnrc
+.vscode
+node_modules
+package-lock.json
+tsconfig.json
webpack.config.js
-vsc-extension-quickstart.md
-**/tsconfig.json
-**/eslint.config.mjs
+eslint.config.mjs
+.prettier*
+.gitignore
+run/**
+.venv/**
+test/**
+.github/**
+.husky/**
+coverage/**
**/*.map
-**/*.ts
-**/.vscode-test.*
+.vscode-test.mjs
+.env
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4d87f17
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,9 @@
+Copyright (c) 2024-2025 Ayushi Amin, Mya Hussain, Nivetha Kuruparan, Sevhena Walker, Tanveer Brar
+
+Permission is hereby granted, on a case-by-case basis, to specific individuals or organizations ("Licensee") to use and access the software and associated documentation files (the "Software") strictly for evaluation or development purposes. This permission is non-transferable, non-exclusive, and does not grant the Licensee any rights to modify, merge, publish, distribute, sublicense, sell, or otherwise exploit the Software in any manner without explicit prior written consent from the copyright holder.
+
+Any unauthorized use, modification, distribution, or sale of the Software is strictly prohibited and may result in legal action.
+
+The Software is provided "AS IS," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, or non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
\ No newline at end of file
diff --git a/README.md b/README.md
index fe74f71..c723736 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,55 @@
-VS Code Plugin for Source Code Optimizer
-
-1. clone this repo
-2. open terminal and write:
- `npm install` (only the first time)
- `npm run compile` | `npm run watch` <- second command will auto compile on save
-3. open another vs code window with the ecooptimzer repo
-4. start venv in the ecooptimizer repo
-5. run "python -m ecooptimizer.api.main" in terminal to start the developement server manually
-6. come back to this repo, go to run and debug (or just click `F5` key)
-7. run extension (should open a new vs code window so open the repo u want in this)
-8. in the vscode search bar (`ctrl+shift+p`) type ">eco: detect smells" and run it
+# EcoOptimizers - Sustainable Python Code Refactoring
+
+EcoOptimizers is a VS Code extension that detects and refactors inefficient Python code, reducing unnecessary computations and lowering CO₂ emissions. By identifying common code smells and providing automated refactoring suggestions, EcoOptimizers helps you write cleaner, more efficient, and environmentally friendly code.
+
+## Features
+
+- **Detect Code Smells** – Automatically analyze your Python code to find inefficiencies.
+- **Refactor Code Smells** – Get instant suggestions and apply refactorings with ease.
+- **Reduce CO₂ Emissions** – Improve computational efficiency and contribute to a greener future.
+- **Seamless VS Code Integration** – Analyze and optimize your code directly within the editor.
+
+## Supported Code Smells
+
+EcoOptimizers detects and refactors the following common code smells:
+
+- **Cache Repeated Calls** – Identifies functions that repeatedly compute the same result and suggests caching techniques.
+- **Long Lambda Functions** – Flags excessively long lambda expressions and converts them into named functions for readability and maintainability.
+- **Use A Generator** – Suggests using generators instead of list comprehensions for memory efficiency.
+- **Long Element Chain** – Detects deeply nested attribute accesses and recommends breaking them into intermediate variables for clarity.
+- **Member Ignoring Method** – Identifies methods that do not use their class members and suggests converting them into static methods or external functions.
+- **Long Message Chains** – Finds excessive method chaining and refactors them for better readability.
+- **String Concatenation in Loop** – Detects inefficient string concatenation inside loops and suggests using lists or other optimized methods.
+- **Long Parameter List** – Flags functions with too many parameters and suggests refactoring strategies such as grouping related parameters into objects.
+
+## How It Works
+
+1. **Detect Smells** – Run the EcoOptimizers analysis tool to scan your code for inefficiencies.
+2. **Refactor Suggestions** – View recommended changes and apply them with a click.
+3. **Optimize Your Code** – Enjoy cleaner, more efficient Python code with reduced computational overhead.
+
+## Demo Videos
+
+Watch EcoOptimizers in action:
+
+- [Detecting Code Smells](https://drive.google.com/file/d/1Uyz0fpqjWVZVe_WXuJLB0bTtzOvjhefu/view?usp=sharing) 🔍
+- [Refactoring Code Smells](https://drive.google.com/file/d/1LQFdnKhuZ7nQGFEXZl3HQtF3TFgMJr6F/view?usp=sharing) 🔧
+
+## Installation
+
+1. Open VS Code.
+2. Go to the Extensions Marketplace.
+3. Search for **EcoOptimizers**.
+4. Click **Install**.
+5. Intall the `ecooptimizer` python package.
+ - run: `pip install ecooptimizer`
+ - run: `eco-ext`
+6. Start optimizing your Python code!
+
+## Contribute
+
+EcoOptimizers is open-source! Help improve the extension by contributing to our GitHub repository: [GitHub Repository](https://github.com/ssm-lab/capstone--source-code-optimizer)
+
+---
+
+🚀 Start writing cleaner, more efficient Python code today with EcoOptimizers!
\ No newline at end of file
diff --git a/assets/black_leaf.png b/assets/black_leaf.png
new file mode 100644
index 0000000..fdd8163
Binary files /dev/null and b/assets/black_leaf.png differ
diff --git a/assets/darkgreen_leaf.png b/assets/darkgreen_leaf.png
new file mode 100644
index 0000000..1b5d1ea
Binary files /dev/null and b/assets/darkgreen_leaf.png differ
diff --git a/assets/eco-icon.png b/assets/eco-icon.png
new file mode 100644
index 0000000..f5314b7
Binary files /dev/null and b/assets/eco-icon.png differ
diff --git a/assets/eco_logo.png b/assets/eco_logo.png
new file mode 100644
index 0000000..a718c53
Binary files /dev/null and b/assets/eco_logo.png differ
diff --git a/assets/green_leaf.png b/assets/green_leaf.png
new file mode 100644
index 0000000..4859246
Binary files /dev/null and b/assets/green_leaf.png differ
diff --git a/assets/white_leaf.png b/assets/white_leaf.png
new file mode 100644
index 0000000..429dc6d
Binary files /dev/null and b/assets/white_leaf.png differ
diff --git a/data/default_smells_config.json b/data/default_smells_config.json
new file mode 100644
index 0000000..4c7e3df
--- /dev/null
+++ b/data/default_smells_config.json
@@ -0,0 +1,101 @@
+{
+ "use-a-generator": {
+ "message_id": "R1729",
+ "name": "Use A Generator (UGEN)",
+ "acronym": "UGEN",
+ "enabled": true,
+ "smell_description": "Using generators instead of lists reduces memory consumption and avoids unnecessary allocations, leading to more efficient CPU and energy use.",
+ "analyzer_options": {}
+ },
+ "too-many-arguments": {
+ "message_id": "R0913",
+ "name": "Too Many Arguments (LPL)",
+ "acronym": "LPL",
+ "enabled": true,
+ "smell_description": "Functions with many arguments are harder to optimize and often require more memory and call overhead, increasing CPU load and energy usage.",
+ "analyzer_options": {
+ "max_args": {
+ "label": "Number of Arguments",
+ "description": "Detecting functions with this many arguments.",
+ "value": 6
+ }
+ }
+ },
+ "no-self-use": {
+ "message_id": "R6301",
+ "name": "No Self Use (NSU)",
+ "acronym": "NSU",
+ "enabled": true,
+ "smell_description": "Methods that don't use 'self' can be static, reducing object overhead and avoiding unnecessary memory binding at runtime.",
+ "analyzer_options": {}
+ },
+ "long-lambda-expression": {
+ "message_id": "LLE001",
+ "name": "Long Lambda Expression (LLE)",
+ "acronym": "LLE",
+ "enabled": true,
+ "smell_description": "Complex lambdas are harder for the interpreter to optimize and may lead to repeated evaluations, which can increase CPU usage and energy draw.",
+ "analyzer_options": {
+ "threshold_length": {
+ "label": "Lambda Length",
+ "description": "Detects lambda expressions exceeding this length.",
+ "value": 9
+ },
+ "threshold_count": {
+ "label": "Repetition Count",
+ "description": "Flags patterns that repeat at least this many times.",
+ "value": 5
+ }
+ }
+ },
+ "long-message-chain": {
+ "message_id": "LMC001",
+ "name": "Long Message Chain (LMC)",
+ "acronym": "LMC",
+ "enabled": true,
+ "smell_description": "Deeply nested calls create performance bottlenecks due to increased dereferencing and lookup time, which adds to CPU cycles and energy usage.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Threshold",
+ "description": "Defines a threshold for triggering this smell.",
+ "value": 9
+ }
+ }
+ },
+ "long-element-chain": {
+ "message_id": "LEC001",
+ "name": "Long Element Chain (LEC)",
+ "acronym": "LEC",
+ "enabled": true,
+ "smell_description": "Chained element access can be inefficient in large structures, increasing access time and CPU effort, thereby consuming more energy.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Threshold",
+ "description": "Defines a threshold for triggering this smell.",
+ "value": 3
+ }
+ }
+ },
+ "cached-repeated-calls": {
+ "message_id": "CRC001",
+ "name": "Cached Repeated Calls (CRC)",
+ "acronym": "CRC",
+ "enabled": true,
+ "smell_description": "Failing to cache repeated expensive calls leads to redundant computation, which wastes CPU cycles and drains energy needlessly.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Cache Threshold",
+ "description": "Number of times a function must repeat before caching.",
+ "value": 2
+ }
+ }
+ },
+ "string-concat-loop": {
+ "message_id": "SCL001",
+ "name": "String Concatenation in Loops (SCL)",
+ "acronym": "SCL",
+ "enabled": true,
+ "smell_description": "String concatenation in loops creates new objects each time, increasing memory churn and CPU workload, which leads to higher energy consumption.",
+ "analyzer_options": {}
+ }
+}
diff --git a/data/working_smells_config.json b/data/working_smells_config.json
new file mode 100644
index 0000000..4c7e3df
--- /dev/null
+++ b/data/working_smells_config.json
@@ -0,0 +1,101 @@
+{
+ "use-a-generator": {
+ "message_id": "R1729",
+ "name": "Use A Generator (UGEN)",
+ "acronym": "UGEN",
+ "enabled": true,
+ "smell_description": "Using generators instead of lists reduces memory consumption and avoids unnecessary allocations, leading to more efficient CPU and energy use.",
+ "analyzer_options": {}
+ },
+ "too-many-arguments": {
+ "message_id": "R0913",
+ "name": "Too Many Arguments (LPL)",
+ "acronym": "LPL",
+ "enabled": true,
+ "smell_description": "Functions with many arguments are harder to optimize and often require more memory and call overhead, increasing CPU load and energy usage.",
+ "analyzer_options": {
+ "max_args": {
+ "label": "Number of Arguments",
+ "description": "Detecting functions with this many arguments.",
+ "value": 6
+ }
+ }
+ },
+ "no-self-use": {
+ "message_id": "R6301",
+ "name": "No Self Use (NSU)",
+ "acronym": "NSU",
+ "enabled": true,
+ "smell_description": "Methods that don't use 'self' can be static, reducing object overhead and avoiding unnecessary memory binding at runtime.",
+ "analyzer_options": {}
+ },
+ "long-lambda-expression": {
+ "message_id": "LLE001",
+ "name": "Long Lambda Expression (LLE)",
+ "acronym": "LLE",
+ "enabled": true,
+ "smell_description": "Complex lambdas are harder for the interpreter to optimize and may lead to repeated evaluations, which can increase CPU usage and energy draw.",
+ "analyzer_options": {
+ "threshold_length": {
+ "label": "Lambda Length",
+ "description": "Detects lambda expressions exceeding this length.",
+ "value": 9
+ },
+ "threshold_count": {
+ "label": "Repetition Count",
+ "description": "Flags patterns that repeat at least this many times.",
+ "value": 5
+ }
+ }
+ },
+ "long-message-chain": {
+ "message_id": "LMC001",
+ "name": "Long Message Chain (LMC)",
+ "acronym": "LMC",
+ "enabled": true,
+ "smell_description": "Deeply nested calls create performance bottlenecks due to increased dereferencing and lookup time, which adds to CPU cycles and energy usage.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Threshold",
+ "description": "Defines a threshold for triggering this smell.",
+ "value": 9
+ }
+ }
+ },
+ "long-element-chain": {
+ "message_id": "LEC001",
+ "name": "Long Element Chain (LEC)",
+ "acronym": "LEC",
+ "enabled": true,
+ "smell_description": "Chained element access can be inefficient in large structures, increasing access time and CPU effort, thereby consuming more energy.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Threshold",
+ "description": "Defines a threshold for triggering this smell.",
+ "value": 3
+ }
+ }
+ },
+ "cached-repeated-calls": {
+ "message_id": "CRC001",
+ "name": "Cached Repeated Calls (CRC)",
+ "acronym": "CRC",
+ "enabled": true,
+ "smell_description": "Failing to cache repeated expensive calls leads to redundant computation, which wastes CPU cycles and drains energy needlessly.",
+ "analyzer_options": {
+ "threshold": {
+ "label": "Cache Threshold",
+ "description": "Number of times a function must repeat before caching.",
+ "value": 2
+ }
+ }
+ },
+ "string-concat-loop": {
+ "message_id": "SCL001",
+ "name": "String Concatenation in Loops (SCL)",
+ "acronym": "SCL",
+ "enabled": true,
+ "smell_description": "String concatenation in loops creates new objects each time, increasing memory churn and CPU workload, which leads to higher energy consumption.",
+ "analyzer_options": {}
+ }
+}
diff --git a/media/script.js b/media/script.js
deleted file mode 100644
index ae26b65..0000000
--- a/media/script.js
+++ /dev/null
@@ -1,122 +0,0 @@
-const vscode = acquireVsCodeApi();
-
-function updateWebView(data, sep) {
- // Hide "No refactoring in progress" message
- document.getElementById('no-data').style.display = 'none';
- document.getElementById('container').style.display = 'block';
-
- // Update Energy Saved
- document.getElementById(
- 'energy'
- ).textContent = `Carbon Saved: ${data.energySaved.toExponential(3)} kg CO2`;
-
- // Populate Target File
- const targetFile = data.targetFile;
- const targetFileList = document.getElementById('target-file-list');
- targetFileList.innerHTML = '';
- const li = document.createElement('li');
-
- const relFile = findRelPath(targetFile.refactored, sep);
- if (relFile.length === 0) {
- relFile = targetFile.original;
- }
- li.textContent = relFile;
-
- li.classList.add('clickable');
- li.onclick = () => {
- vscode.postMessage({
- command: 'selectFile',
- original: targetFile.original,
- refactored: targetFile.refactored
- });
- };
- targetFileList.appendChild(li);
-
- // Populate Other Modified Files
- const affectedFileList = document.getElementById('affected-file-list');
- affectedFileList.innerHTML = '';
- if (data.affectedFiles.length === 0) {
- document.getElementById('other-files-head').style.display = 'none';
- }
- data.affectedFiles.forEach((file) => {
- const li = document.createElement('li');
- const relFile = findRelPath(file.refactored, sep);
-
- if (relFile.length === 0) {
- relFile = file.original;
- }
-
- li.textContent = relFile;
- li.classList.add('clickable');
- li.onclick = () => {
- vscode.postMessage({
- command: 'selectFile',
- original: file.original,
- refactored: file.refactored
- });
- };
- affectedFileList.appendChild(li);
- });
-
- // Save state in the webview
- vscode.setState(data);
-}
-
-// Function to clear the UI (for when refactoring is done)
-function clearWebview() {
- document.getElementById('energy').textContent = 'Carbon Saved: --';
- document.getElementById('target-file-list').innerHTML = '';
- document.getElementById('affected-file-list').innerHTML = '';
-
- document.getElementById('no-data').style.display = 'block';
- document.getElementById('container').style.display = 'none';
- vscode.setState(null); // Clear state
-}
-
-// Restore state when webview loads
-window.addEventListener('DOMContentLoaded', () => {
- const savedState = vscode.getState();
- if (savedState) {
- updateWebView(savedState);
- }
-});
-
-// Listen for extension messages
-window.addEventListener('message', (event) => {
- if (event.data.command === 'update') {
- updateWebView(event.data.data, event.data.sep);
- } else if (event.data.command === 'clear') {
- clearWebview();
- } else if (event.data.command === 'pause') {
- document.getElementById('no-data').style.display = 'block';
- document.getElementById('container').style.display = 'none';
- }
-});
-
-// Button click handlers
-document.getElementById('accept-btn').addEventListener('click', () => {
- vscode.postMessage({ command: 'accept' });
- clearWebview();
-});
-
-document.getElementById('reject-btn').addEventListener('click', () => {
- vscode.postMessage({ command: 'reject' });
- clearWebview();
-});
-
-function findRelPath(filePath, sep) {
- // Split the path using the separator
- const parts = filePath.split(sep);
-
- // Find the index of the part containing the 'ecooptimizer-' substring
- const index = parts.findIndex((part) => part.includes('ecooptimizer-'));
-
- // If a matching part is found, return the joined list of items after it
- if (index !== -1) {
- // Slice the array from the next index and join them with the separator
- return parts.slice(index + 1).join(sep);
- }
-
- // Return an empty string if no match is found
- return '';
-}
diff --git a/media/style.css b/media/style.css
deleted file mode 100644
index 2ce8d4e..0000000
--- a/media/style.css
+++ /dev/null
@@ -1,42 +0,0 @@
-body {
- font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans',
- Arial, sans-serif;
- padding: 10px;
-}
-
-/* .body-text {
- font-size: medium;
-} */
-.clickable {
- cursor: pointer;
- padding: 5px;
- transition: background-color 0.2s ease-in-out;
-}
-.clickable:hover {
- background-color: rgba(87, 82, 82, 0.1);
-}
-#container {
- display: none; /* Initially hidden until data is received */
-}
-#no-data {
- /* font-size: 14px; */
- text-align: center;
-}
-#buttons {
- position: absolute;
- display: flex;
- justify-content: space-between;
- margin: 0 5px;
- bottom: 10px;
-}
-ul {
- overflow-y: auto;
- padding: 0;
- list-style-type: square;
-}
-
-button {
- width: 45vw;
- height: 40px;
- border-radius: 2px;
-}
diff --git a/media/vscode.css b/media/vscode.css
deleted file mode 100644
index 12d43b9..0000000
--- a/media/vscode.css
+++ /dev/null
@@ -1,91 +0,0 @@
-:root {
- --container-paddding: 20px;
- --input-padding-vertical: 6px;
- --input-padding-horizontal: 4px;
- --input-margin-vertical: 4px;
- --input-margin-horizontal: 0;
-}
-
-body {
- padding: 0 var(--container-paddding);
- color: var(--vscode-foreground);
- font-size: var(--vscode-font-size);
- font-weight: var(--vscode-font-weight);
- font-family: var(--vscode-font-family);
- background-color: var(--vscode-editor-background);
-}
-
-ol,
-ul {
- padding-left: var(--container-paddding);
-}
-
-body > *,
-form > * {
- margin-block-start: var(--input-margin-vertical);
- margin-block-end: var(--input-margin-vertical);
-}
-
-*:focus {
- outline-color: var(--vscode-focusBorder) !important;
-}
-
-a {
- color: var(--vscode-textLink-foreground);
-}
-
-a:hover,
-a:active {
- color: var(--vscode-textLink-activeForeground);
-}
-
-code {
- font-size: var(--vscode-editor-font-size);
- font-family: var(--vscode-editor-font-family);
-}
-
-button {
- border: none;
- padding: var(--input-padding-vertical) var(--input-padding-horizontal);
- width: 100%;
- text-align: center;
- outline: 1px solid transparent;
- outline-offset: 2px !important;
- color: var(--vscode-button-foreground);
- background: var(--vscode-button-background);
-}
-
-button:hover {
- cursor: pointer;
- background: var(--vscode-button-hoverBackground);
-}
-
-button:focus {
- outline-color: var(--vscode-focusBorder);
-}
-
-button.secondary {
- color: var(--vscode-button-secondaryForeground);
- background: var(--vscode-button-secondaryBackground);
-}
-
-button.secondary:hover {
- background: var(--vscode-button-secondaryHoverBackground);
-}
-
-input:not([type='checkbox']),
-textarea {
- display: block;
- width: 100%;
- border: none;
- font-family: var(--vscode-font-family);
- padding: var(--input-padding-vertical) var(--input-padding-horizontal);
- color: var(--vscode-input-foreground);
- outline-color: var(--vscode-input-border);
- background-color: var(--vscode-input-background);
-}
-
-input::placeholder,
-textarea::placeholder {
- color: var(--vscode-input-placeholderForeground);
-}
diff --git a/media/webview.html b/media/webview.html
deleted file mode 100644
index 4a1b2f6..0000000
--- a/media/webview.html
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
- Refactoring Navigator
-
-
-
- Nothing to see here. If you are currently refactoring a file, make sure the diff view is selected.
-
-
-
Refactoring Summary
-
Carbon Saved: --
-
Target File
-
-
Other Modified Files
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 0688744..8dfc29b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ecooptimizer",
- "version": "0.0.1",
+ "version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ecooptimizer",
- "version": "0.0.1",
+ "version": "0.2.2",
"dependencies": {
"@types/dotenv": "^6.1.1",
"bufferutil": "^4.0.9",
@@ -16,6 +16,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
+ "@types/adm-zip": "^0.5.7",
"@types/jest": "^29.5.14",
"@types/node": "20.x",
"@types/vscode": "^1.92.0",
@@ -239,13 +240,14 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
- "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
+ "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/template": "^7.26.9",
- "@babel/types": "^7.26.9"
+ "@babel/types": "^7.26.10"
},
"engines": {
"node": ">=6.9.0"
@@ -530,10 +532,11 @@
}
},
"node_modules/@babel/types": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
- "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
+ "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
@@ -1520,6 +1523,15 @@
"optional": true,
"peer": true
},
+ "node_modules/@types/adm-zip": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
+ "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
diff --git a/package.json b/package.json
index a338566..124eddb 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,20 @@
{
"name": "ecooptimizer",
+ "publisher": "mac-ecooptimizers",
"displayName": "EcoOptimizer VS Code Plugin",
"contributors": [
"Sevhena Walker",
"Tanveer Brar",
"Ayushi Amin",
"Mya Hussain",
- "Nivetah Kuruparan"
+ "Nivetha Kuruparan"
],
"description": "VS Code Plugin for EcoOptimizer Refactoring Tool",
- "version": "0.0.1",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ssm-lab/capstone--sco-vs-code-plugin"
+ },
+ "version": "0.2.3",
"engines": {
"vscode": "^1.92.0"
},
@@ -17,249 +22,433 @@
"Other"
],
"activationEvents": [
- "onLanguage:python"
+ "onLanguage:python",
+ "onStartupFinished"
],
"main": "./dist/extension.js",
+ "directories": {
+ "src": "./src",
+ "test": "./test"
+ },
+ "scripts": {
+ "deploy": "vsce publish --yarn",
+ "vscode:prepublish": "npm run package",
+ "compile": "webpack",
+ "test": "jest --verbose",
+ "test:watch": "jest --watch --verbose",
+ "watch": "webpack --watch",
+ "package": "webpack --mode production --devtool hidden-source-map",
+ "lint": "eslint src"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "setupFilesAfterEnv": [
+ "./test/setup.ts"
+ ],
+ "moduleNameMapper": {
+ "^vscode$": "/test/mocks/vscode-mock.ts",
+ "^@/(.*)$": "/src/$1"
+ },
+ "moduleDirectories": [
+ "node_modules",
+ "src",
+ "test/__mocks__"
+ ],
+ "roots": [
+ "/src",
+ "/test"
+ ],
+ "collectCoverage": true,
+ "coverageReporters": [
+ "text",
+ "html",
+ "lcov"
+ ],
+ "coverageDirectory": "/coverage/",
+ "coverageThreshold": {
+ "global": {
+ "statements": 80
+ }
+ },
+ "collectCoverageFrom": [
+ "src/**/*.ts",
+ "!src/**/*.d.ts",
+ "!src/**/index.ts",
+ "!test/mocks/*",
+ "!src/extension.ts",
+ "!src/context/*",
+ "!src/providers/*",
+ "!src/commands/showLogs.ts",
+ "!src/emitters/serverStatus.ts",
+ "!src/utils/envConfig.ts",
+ "!src/utils/TreeStructureBuilder.ts",
+ "!src/commands/views/jumpToSmell.ts",
+ "!src/commands/views/openFile.ts",
+ "!src/lib/*",
+ "!src/install.ts"
+ ]
+ },
+ "lint-staged": {
+ "src/**/*.ts": [
+ "eslint --fix",
+ "prettier --write"
+ ]
+ },
+ "devDependencies": {
+ "@types/adm-zip": "^0.5.7",
+ "@types/jest": "^29.5.14",
+ "@types/node": "20.x",
+ "@types/vscode": "^1.92.0",
+ "@types/ws": "^8.5.14",
+ "@typescript-eslint/eslint-plugin": "^8.24.1",
+ "@typescript-eslint/parser": "^8.24.1",
+ "@vscode/test-cli": "^0.0.10",
+ "@vscode/test-electron": "^2.4.1",
+ "css-loader": "^7.1.2",
+ "eslint": "^9.21.0",
+ "eslint-config-prettier": "^10.0.1",
+ "eslint-plugin-prettier": "^5.2.3",
+ "eslint-plugin-unused-imports": "^4.1.4",
+ "husky": "^9.1.7",
+ "jest": "^29.7.0",
+ "jest-silent-reporter": "^0.6.0",
+ "lint-staged": "^15.4.3",
+ "prettier": "^3.5.2",
+ "prettier-plugin-tailwindcss": "^0.6.11",
+ "style-loader": "^4.0.0",
+ "ts-jest": "^29.2.6",
+ "ts-loader": "^9.5.1",
+ "typescript": "^5.7.2",
+ "webpack": "^5.95.0",
+ "webpack-cli": "^5.1.4",
+ "webpack-node-externals": "^3.0.0"
+ },
+ "dependencies": {
+ "@types/dotenv": "^6.1.1",
+ "bufferutil": "^4.0.9",
+ "dotenv": "^16.4.7",
+ "dotenv-webpack": "^8.1.0",
+ "utf-8-validate": "^6.0.5",
+ "ws": "^8.18.0"
+ },
+ "icon": "./assets/eco_logo.png",
"contributes": {
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "ecooptimizer",
+ "title": "Eco",
+ "icon": "assets/eco-icon.png"
+ }
+ ]
+ },
+ "views": {
+ "ecooptimizer": [
+ {
+ "id": "ecooptimizer.refactorView",
+ "name": "Refactoring Details",
+ "icon": "assets/eco-icon.png"
+ },
+ {
+ "id": "ecooptimizer.smellsView",
+ "name": "Code Smells",
+ "icon": "assets/eco-icon.png"
+ },
+ {
+ "id": "ecooptimizer.metricsView",
+ "name": "Carbon Metrics",
+ "icon": "assets/eco-icon.png"
+ },
+ {
+ "id": "ecooptimizer.filterView",
+ "name": "Filter Smells",
+ "icon": "assets/eco-icon.png"
+ }
+ ]
+ },
+ "viewsWelcome": [
+ {
+ "view": "ecooptimizer.refactorView",
+ "contents": "Refactoring is currently not in progress. Try selecting a smell in the Code Smells view to start refactoring.",
+ "when": "!refactoringInProgress"
+ },
+ {
+ "view": "ecooptimizer.smellsView",
+ "contents": "No code smells detected yet. Configure your workspace to start analysis.\n\n[Configure Workspace](command:ecooptimizer.configureWorkspace)\n\n[Read the docs](https://code.visualstudio.com/api) to learn how to use Eco-Optimizer.",
+ "when": "!workspaceState.workspaceConfigured"
+ },
+ {
+ "view": "ecooptimizer.metricsView",
+ "contents": "No energy savings to declare. Configure your workspace to start saving energy!\n\n[Configure Workspace](command:ecooptimizer.configureWorkspace)\n\n[Read the docs](https://code.visualstudio.com/api) to learn how to use Eco-Optimizer.",
+ "when": "!workspaceState.workspaceConfigured"
+ }
+ ],
"commands": [
{
- "command": "ecooptimizer.detectSmells",
- "title": "Detect Smells",
+ "command": "ecooptimizer.startServer",
+ "title": "Start EcoOptimizer Server",
"category": "Eco"
},
{
- "command": "ecooptimizer.refactorSmell",
- "title": "Refactor Smell",
+ "command": "ecooptimizer.stopServer",
+ "title": "Stop EcoOptimizer Server",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.configureWorkspace",
+ "title": "Configure Workspace",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.resetConfiguration",
+ "title": "Reset Configuration",
"category": "Eco"
},
{
"command": "ecooptimizer.wipeWorkCache",
- "title": "Wipe Workspace Cache",
+ "title": "Clear Smells Cache",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.toggleSmellFilter",
+ "title": "Toggle Smell",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.editSmellFilterOption",
+ "title": "Edit Option",
+ "icon": "$(edit)",
"category": "Eco"
},
{
- "command": "ecooptimizer.showRefactorSidebar",
- "title": "Show Refactor Sidebar",
+ "command": "ecooptimizer.selectAllFilterSmells",
+ "title": "Select All Smells",
"category": "Eco"
},
{
- "command": "ecooptimizer.pauseRefactorSidebar",
- "title": "Pause Refactor Sidebar",
+ "command": "ecooptimizer.deselectAllFilterSmells",
+ "title": "Deselect All Smells",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.setFilterDefaults",
+ "title": "Set Filter Defaults",
"category": "Eco",
- "enablement": "false"
+ "when": "view == ecooptimizer.filterView && !refactoringInProgress"
+ },
+ {
+ "command": "ecooptimizer.detectSmellsFolder",
+ "title": "Detect Smells for All Files",
+ "icon": "$(search)",
+ "category": "Eco"
},
{
- "command": "ecooptimizer.clearRefactorSidebar",
- "title": "Clear Refactor Sidebar",
+ "command": "ecooptimizer.detectSmellsFile",
+ "title": "Detect Smells",
+ "icon": "$(search)",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.refactorAllSmellsOfType",
+ "title": "Refactor Smells By Type",
+ "icon": "$(tools)",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.refactorSmell",
+ "title": "Refactor Smell",
+ "icon": "$(tools)",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.acceptRefactoring",
+ "title": "Accept Refactoring",
"category": "Eco",
- "enablement": "false"
+ "icon": "$(check)"
},
{
- "command": "ecooptimizer.startLogging",
- "title": "Show Backend Logs",
+ "command": "ecooptimizer.rejectRefactoring",
+ "title": "Reject Refactoring",
"category": "Eco",
- "enablement": "false"
+ "icon": "$(close)"
},
{
- "command": "ecooptimizer.toggleSmellLinting",
- "title": "🍃 Toggle Smell Linting",
+ "command": "ecooptimizer.exportMetricsData",
+ "title": "Export Metrics Data as JSON",
"category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.clearMetricsData",
+ "title": "Clear Metrics Data",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.metricsView.refresh",
+ "title": "Refresh Metrics Data",
+ "icon": "$(sync)",
+ "category": "Eco"
+ },
+ {
+ "command": "ecooptimizer.toggleSmellLintingOn",
+ "title": "Toggle Smell Linting",
+ "category": "Eco",
+ "icon": {
+ "light": "assets/darkgreen_leaf.png",
+ "dark": "assets/green_leaf.png"
+ }
+ },
+ {
+ "command": "ecooptimizer.toggleSmellLintingOff",
+ "title": "Toggle Smell Linting",
+ "category": "Eco",
+ "icon": {
+ "light": "assets/white_leaf.png",
+ "dark": "assets/black_leaf.png"
+ }
}
],
"menus": {
+ "view/title": [
+ {
+ "command": "ecooptimizer.resetConfiguration",
+ "when": "view == ecooptimizer.smellsView && workspaceState.workspaceConfigured && !refactoringInProgress",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.wipeWorkCache",
+ "when": "view == ecooptimizer.smellsView && workspaceState.workspaceConfigured && !refactoringInProgress",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.selectAllFilterSmells",
+ "when": "view == ecooptimizer.filterView && !refactoringInProgress",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.deselectAllFilterSmells",
+ "when": "view == ecooptimizer.filterView && !refactoringInProgress",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.setFilterDefaults",
+ "when": "view == ecooptimizer.filterView && !refactoringInProgress",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.exportMetricsData",
+ "when": "view == ecooptimizer.metricsView",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.clearMetricsData",
+ "when": "view == ecooptimizer.metricsView",
+ "group": "resource"
+ },
+ {
+ "command": "ecooptimizer.metricsView.refresh",
+ "when": "view == ecooptimizer.metricsView",
+ "group": "navigation"
+ }
+ ],
+ "view/item/context": [
+ {
+ "command": "ecooptimizer.editSmellFilterOption",
+ "when": "viewItem == smellOption && !refactoringInProgress",
+ "group": "inline"
+ },
+ {
+ "command": "ecooptimizer.detectSmellsFolder",
+ "when": "view == ecooptimizer.smellsView && viewItem == directory && !refactoringInProgress",
+ "group": "inline"
+ },
+ {
+ "command": "ecooptimizer.detectSmellsFile",
+ "when": "view == ecooptimizer.smellsView && (viewItem == file || viewItem == file_with_smells) && !refactoringInProgress",
+ "group": "inline"
+ },
+ {
+ "command": "ecooptimizer.refactorAllSmellsOfType",
+ "when": "view == ecooptimizer.smellsView && viewItem == file_with_smells && !refactoringInProgress",
+ "group": "inline"
+ },
+ {
+ "command": "ecooptimizer.refactorSmell",
+ "when": "view == ecooptimizer.smellsView && viewItem == smell && !refactoringInProgress",
+ "group": "inline"
+ }
+ ],
"editor/title": [
{
- "command": "ecooptimizer.toggleSmellLinting",
- "group": "navigation",
- "when": "eco.smellLintingEnabled == false",
- "icon": {
- "light": "off.svg",
- "dark": "off.svg"
- }
+ "command": "ecooptimizer.toggleSmellLintingOn",
+ "when": "workspaceState.workspaceConfigured && editorLangId == python && ecooptimizer.smellLintingEnabled",
+ "group": "navigation"
},
{
- "command": "ecooptimizer.toggleSmellLinting",
- "group": "navigation",
- "when": "eco.smellLintingEnabled == true",
- "icon": {
- "light": "on.svg",
- "dark": "on.svg"
- }
+ "command": "ecooptimizer.toggleSmellLintingOff",
+ "when": "workspaceState.workspaceConfigured && editorLangId == python && !ecooptimizer.smellLintingEnabled",
+ "group": "navigation"
}
]
},
"configuration": {
"title": "EcoOptimizer",
"properties": {
- "ecooptimizer.projectWorkspacePath": {
- "type": "string",
- "default": "",
- "description": "Path to the folder to be targeted, relative to current workspace. Defaults to the currently open folder in VS Code."
- },
- "ecooptimizer.logsOutputPath": {
- "type": "string",
- "default": "",
- "description": "Path to store log files and output reports. Defaults to a 'logs' folder inside the workspace."
- },
- "detection.smells": {
+ "ecooptimizer.detection.smellsColours": {
"order": 1,
"type": "object",
"additionalProperties": false,
- "description": "Configure which smells to detect and their highlight colours.",
+ "description": "Configure highlight colours for smells.",
"default": {
- "long-element-chain": {
- "enabled": true,
- "colour": "lightblue"
- },
- "too-many-arguments": {
- "enabled": true,
- "colour": "lightcoral"
- },
- "long-lambda-expression": {
- "enabled": true,
- "colour": "mediumpurple"
- },
- "long-message-chain": {
- "enabled": true,
- "colour": "lightpink"
- },
- "cached-repeated-calls": {
- "enabled": true,
- "colour": "lightgreen"
- },
- "string-concat-loop": {
- "enabled": true,
- "colour": "lightsalmon"
- },
- "no-self-use": {
- "enabled": true,
- "colour": "lightcyan"
- },
- "use-a-generator": {
- "enabled": true,
- "colour": "yellow"
- }
+ "long-element-chain": "lightblue",
+ "too-many-arguments": "lightcoral",
+ "long-lambda-expression": "mediumpurple",
+ "long-message-chain": "lightpink",
+ "cached-repeated-calls": "lightgreen",
+ "string-concat-loop": "lightsalmon",
+ "no-self-use": "lightcyan",
+ "use-a-generator": "yellow"
},
"properties": {
"long-element-chain": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of long element chains."
- },
- "colour": {
- "type": "string",
- "default": "lightblue",
- "description": "Colour (css syntax) for highlighting long element chains."
- }
- }
+ "type": "string",
+ "default": "lightblue",
+ "description": "Colour (css syntax) for highlighting long element chains."
},
"too-many-arguments": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of functions with too many arguments."
- },
- "colour": {
- "type": "string",
- "default": "lightcoral",
- "description": "Colour (css syntax) for highlighting functions with too many arguments."
- }
- }
+ "type": "string",
+ "default": "lightcoral",
+ "description": "Colour (css syntax) for highlighting functions with too many arguments."
},
"long-lambda-expression": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of long lambda expressions."
- },
- "colour": {
- "type": "string",
- "default": "mediumpurple",
- "description": "Colour (css syntax) for highlighting long lambda expressions."
- }
- }
+ "type": "string",
+ "default": "mediumpurple",
+ "description": "Colour (css syntax) for highlighting long lambda expressions."
},
"long-message-chain": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of long message chains."
- },
- "colour": {
- "type": "string",
- "default": "lightpink",
- "description": "Colour (css syntax) for highlighting long message chains."
- }
- }
+ "type": "string",
+ "default": "lightpink",
+ "description": "Colour (css syntax) for highlighting long message chains."
},
"cached-repeated-calls": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of cached repeated calls."
- },
- "colour": {
- "type": "string",
- "default": "lightgreen",
- "description": "Colour (css syntax) for highlighting cached repeated calls."
- }
- }
+ "type": "string",
+ "default": "lightgreen",
+ "description": "Colour (css syntax) for highlighting cached repeated calls."
},
"string-concat-loop": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of string concatenation in loops."
- },
- "colour": {
- "type": "string",
- "default": "lightsalmon",
- "description": "Colour (css syntax) for highlighting string concatenation in loops."
- }
- }
+ "type": "string",
+ "default": "lightsalmon",
+ "description": "Colour (css syntax) for highlighting string concatenation in loops."
},
"no-self-use": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of methods with no self-use."
- },
- "colour": {
- "type": "string",
- "default": "lightcyan",
- "description": "Colour (css syntax) for highlighting methods with no self-use."
- }
- }
+ "type": "string",
+ "default": "lightcyan",
+ "description": "Colour (css syntax) for highlighting methods with no self-use."
},
"use-a-generator": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "default": true,
- "description": "Enable detection of places where a generator could be used."
- },
- "colour": {
- "type": "string",
- "default": "yellow",
- "description": "Colour (css syntax) for highlighting places where a generator could be used."
- }
- }
+ "type": "string",
+ "default": "yellow",
+ "description": "Colour (css syntax) for highlighting places where a generator could be used."
}
}
},
@@ -292,131 +481,6 @@
"description": "Choose a highlight style for all smells."
}
}
- },
- "keybindings": [
- {
- "command": "ecooptimizer.refactorSmell",
- "key": "ctrl+shift+r",
- "when": "editorTextFocus && resourceExtname == '.py'"
- }
- ],
- "viewsContainers": {
- "activitybar": [
- {
- "id": "refactorSidebarContainer",
- "title": "Refactoring",
- "icon": "resources/refactor-icon.svg"
- }
- ]
- },
- "views": {
- "refactorSidebarContainer": [
- {
- "id": "extension.refactorSidebar",
- "name": "Refactoring Summary",
- "type": "webview"
- }
- ]
}
- },
- "directories": {
- "src": "./src",
- "test": "./test"
- },
- "scripts": {
- "vscode:prepublish": "npm run package",
- "compile": "webpack",
- "test": "jest --verbose",
- "test:watch": "jest --watch --silent --verbose",
- "watch": "webpack --watch",
- "package": "webpack --mode production --devtool hidden-source-map",
- "compile-tests": "tsc -p . --outDir out",
- "watch-tests": "tsc -p . -w --outDir out",
- "lint": "eslint src",
- "prepare": "husky"
- },
- "jest": {
- "preset": "ts-jest",
- "testEnvironment": "node",
- "setupFilesAfterEnv": [
- "./test/setup.ts"
- ],
- "moduleNameMapper": {
- "^vscode$": "/test/mocks/vscode-mock.ts"
- },
- "moduleDirectories": [
- "node_modules",
- "tests/__mocks__"
- ],
- "roots": [
- "/src",
- "/test"
- ],
- "collectCoverage": true,
- "coverageReporters": [
- "text",
- "html",
- "lcov"
- ],
- "coverageDirectory": "/coverage/",
- "coverageThreshold": {
- "global": {
- "statements": 80
- }
- },
- "collectCoverageFrom": [
- "src/**/*.ts",
- "!src/**/*.d.ts",
- "!src/**/index.ts",
- "!test/mocks/*",
- "!src/extension.ts",
- "!src/context/*",
- "!src/utils/configManager.ts",
- "!src/commands/showLogs.ts",
- "!src/ui/refactorView.ts",
- "!src/utils/handleEditorChange.ts"
- ]
- },
- "lint-staged": {
- "src/**/*.ts": [
- "eslint --fix",
- "prettier --write"
- ]
- },
- "devDependencies": {
- "@types/jest": "^29.5.14",
- "@types/node": "20.x",
- "@types/vscode": "^1.92.0",
- "@types/ws": "^8.5.14",
- "@typescript-eslint/eslint-plugin": "^8.24.1",
- "@typescript-eslint/parser": "^8.24.1",
- "@vscode/test-cli": "^0.0.10",
- "@vscode/test-electron": "^2.4.1",
- "css-loader": "^7.1.2",
- "eslint": "^9.21.0",
- "eslint-config-prettier": "^10.0.1",
- "eslint-plugin-prettier": "^5.2.3",
- "eslint-plugin-unused-imports": "^4.1.4",
- "husky": "^9.1.7",
- "jest": "^29.7.0",
- "jest-silent-reporter": "^0.6.0",
- "lint-staged": "^15.4.3",
- "prettier": "^3.5.2",
- "prettier-plugin-tailwindcss": "^0.6.11",
- "style-loader": "^4.0.0",
- "ts-jest": "^29.2.6",
- "ts-loader": "^9.5.1",
- "typescript": "^5.7.2",
- "webpack": "^5.95.0",
- "webpack-cli": "^5.1.4",
- "webpack-node-externals": "^3.0.0"
- },
- "dependencies": {
- "@types/dotenv": "^6.1.1",
- "bufferutil": "^4.0.9",
- "dotenv": "^16.4.7",
- "dotenv-webpack": "^8.1.0",
- "utf-8-validate": "^6.0.5",
- "ws": "^8.18.0"
}
}
diff --git a/src/api/backend.ts b/src/api/backend.ts
index d5979ea..00a8e23 100644
--- a/src/api/backend.ts
+++ b/src/api/backend.ts
@@ -1,24 +1,51 @@
-import * as vscode from 'vscode';
-
+import {basename} from 'path';
import { envConfig } from '../utils/envConfig';
-import { serverStatus } from '../utils/serverStatus';
-import { ServerStatusType } from '../utils/serverStatus';
+import { serverStatus } from '../emitters/serverStatus';
+import { ServerStatusType } from '../emitters/serverStatus';
+import { ecoOutput } from '../extension';
-const BASE_URL = `http://${envConfig.SERVER_URL}`; // API URL for Python backend
+// Base URL for backend API endpoints constructed from environment configuration
+const BASE_URL = `http://${envConfig.SERVER_URL}`;
+/**
+ * Verifies backend service availability and updates extension status.
+ * Performs health check by hitting the /health endpoint and handles three scenarios:
+ * 1. Successful response (200-299) - marks server as UP
+ * 2. Error response - marks server as DOWN with status code
+ * 3. Network failure - marks server as DOWN with error details
+ */
export async function checkServerStatus(): Promise {
try {
- const response = await fetch('http://localhost:8000/health');
+ ecoOutput.info('[backend.ts] Checking backend server health status...');
+ const response = await fetch(`${BASE_URL}/health`);
+
if (response.ok) {
serverStatus.setStatus(ServerStatusType.UP);
+ ecoOutput.trace('[backend.ts] Backend server is healthy');
} else {
serverStatus.setStatus(ServerStatusType.DOWN);
+ ecoOutput.warn(`[backend.ts] Backend server unhealthy status: ${response.status}`);
}
- } catch {
+ } catch (error) {
serverStatus.setStatus(ServerStatusType.DOWN);
+ ecoOutput.error(
+ `[backend.ts] Server connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
}
}
+/**
+ * Initializes and synchronizes logs with the backend server.
+ *
+ * This function sends a POST request to the backend to initialize logging
+ * for the specified log directory. If the request is successful, logging
+ * is initialized; otherwise, an error is logged, and an error message is
+ * displayed to the user.
+ *
+ * @param log_dir - The directory path where logs are stored.
+ * @returns A promise that resolves to `true` if logging is successfully initialized,
+ * or `false` if an error occurs.
+ */
export async function initLogs(log_dir: string): Promise {
const url = `${BASE_URL}/logs/init`;
@@ -35,6 +62,9 @@ export async function initLogs(log_dir: string): Promise {
if (!response.ok) {
console.error(`Unable to initialize logging: ${JSON.stringify(response)}`);
+ ecoOutput.error(
+ `Unable to initialize logging: ${JSON.stringify(response)}`,
+ );
return false;
}
@@ -42,89 +72,177 @@ export async function initLogs(log_dir: string): Promise {
return true;
} catch (error: any) {
console.error(`Eco: Unable to initialize logging: ${error.message}`);
- vscode.window.showErrorMessage(
+ ecoOutput.error(
'Eco: Unable to reach the backend. Please check your connection.',
);
return false;
}
}
-// ✅ Fetch detected smells for a given file (only enabled smells)
+/**
+ * Analyzes source code for code smells using backend detection service.
+ * @param filePath - Absolute path to the source file for analysis
+ * @param enabledSmells - Configuration object specifying which smells to detect
+ * @returns Promise resolving to smell detection results and HTTP status
+ * @throws Error when:
+ * - Network request fails
+ * - Backend returns non-OK status
+ * - Response contains invalid data format
+ */
export async function fetchSmells(
filePath: string,
- enabledSmells: string[],
-): Promise {
+ enabledSmells: Record>,
+): Promise<{ smells: Smell[]; status: number }> {
const url = `${BASE_URL}/smells`;
+ const fileName = basename(filePath);
+ ecoOutput.info(`[backend.ts] Starting smell detection for: ${fileName}`);
try {
- console.log(
- `Eco: Requesting smells for file: ${filePath} with filters: ${enabledSmells}`,
- );
+ ecoOutput.debug(`[backend.ts] Request payload for ${fileName}:`, {
+ file_path: filePath,
+ enabled_smells: enabledSmells
+ });
const response = await fetch(url, {
- method: 'POST', // ✅ Send enabled smells in the request body
+ method: 'POST',
headers: {
'Content-Type': 'application/json',
},
- body: JSON.stringify({ file_path: filePath, enabled_smells: enabledSmells }), // ✅ Include enabled smells
+ body: JSON.stringify({
+ file_path: filePath,
+ enabled_smells: enabledSmells,
+ }),
});
if (!response.ok) {
- console.error(
- `Eco: API request failed (${response.status} - ${response.statusText})`,
- );
- vscode.window.showErrorMessage(
- `Eco: Failed to fetch smells`,
- );
- return [];
+ const errorMsg = `Backend request failed (${response.status})`;
+ ecoOutput.error(`[backend.ts] ${errorMsg}`);
+ try {
+ const errorBody = await response.json();
+ ecoOutput.error(`[backend.ts] Backend error details:`, errorBody);
+ } catch (e: any) {
+ ecoOutput.error(`[backend.ts] Could not parse error response`);
+ }
+ throw new Error(errorMsg);
}
- const smellsList = (await response.json()) as Smell[];
-
- if (!Array.isArray(smellsList)) {
- console.error('Eco: Invalid response format from backend.');
- vscode.window.showErrorMessage('Eco: Failed to fetch smells');
- return [];
+ const smellsList = await response.json();
+
+ // Detailed logging of the response
+ ecoOutput.info(`[backend.ts] Detection complete for ${fileName}`);
+ ecoOutput.debug(`[backend.ts] Raw response headers for ${fileName}:`, Object.fromEntries(response.headers.entries()));
+ ecoOutput.debug(`[backend.ts] Full response for ${fileName}:`, {
+ status: response.status,
+ statusText: response.statusText,
+ body: smellsList
+ });
+
+ // Detailed smell listing
+ ecoOutput.info(`[backend.ts] Detected ${smellsList.length} smells in ${fileName}`);
+ if (smellsList.length > 0) {
+ ecoOutput.debug(`[backend.ts] Complete smells list for ${fileName}:`, smellsList);
+ ecoOutput.debug(`[backend.ts] Verbose smell details for ${fileName}:`,
+ smellsList.map((smell: Smell) => ({
+ type: smell.symbol,
+ location: `${smell.path}:${smell.occurences}`,
+ message: smell.message,
+ context: smell.messageId
+ }))
+ );
}
- console.log(`Eco: Successfully retrieved ${smellsList.length} smells.`);
- return smellsList;
+ return { smells: smellsList, status: response.status };
+
} catch (error: any) {
- console.error(`Eco: Network error while fetching smells: ${error.message}`);
- vscode.window.showErrorMessage(
- 'Eco: Failed to fetch smells',
- );
- return [];
+ ecoOutput.error(`[backend.ts] Smell detection failed for ${fileName}: ${error.message}`);
+ if (error instanceof Error && error.stack) {
+ ecoOutput.trace(`[backend.ts] Error stack info:`, error.stack);
+ }
+ throw new Error(`Detection failed: ${error.message}`);
}
}
-// Request refactoring for a specific smell
-export async function refactorSmell(
- filePath: string,
+/**
+ * Executes code refactoring for a specific detected smell pattern.
+ * @param smell - The smell object containing detection details
+ * @param workspacePath - The path to the workspace.
+ * @returns Promise resolving to refactoring result data
+ * @throws Error when:
+ * - Workspace path is not provided
+ * - Refactoring request fails
+ * - Network errors occur
+ */
+export async function backendRefactorSmell(
smell: Smell,
-): Promise {
+ workspacePath: string,
+): Promise {
const url = `${BASE_URL}/refactor`;
- const workspaceFolder = vscode.workspace.workspaceFolders?.find((folder) =>
- filePath.includes(folder.uri.fsPath),
- )
+ // Validate workspace configuration
+ if (!workspacePath) {
+ ecoOutput.error('[backend.ts] Refactoring aborted: No workspace path');
+ throw new Error('No workspace path provided');
+ }
- if (!workspaceFolder) {
- console.error('Eco: Error - Unable to determine workspace folder for', filePath);
- throw new Error(
- `Eco: Unable to find a matching workspace folder for file: ${filePath}`,
- );
+ ecoOutput.info(`[backend.ts] Starting refactoring for smell: ${smell.symbol}`);
+ console.log('Starting refactoring for smell:', smell);
+
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ sourceDir: workspacePath,
+ smell,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ ecoOutput.error(`[backend.ts] Refactoring failed: ${errorData.detail || 'Unknown error'}`);
+ throw new Error(errorData.detail || 'Refactoring failed');
+ }
+
+ const result = await response.json();
+ ecoOutput.info(`[backend.ts] Refactoring successful for ${smell.symbol}`);
+ return result;
+
+ } catch (error: any) {
+ ecoOutput.error(`[backend.ts] Refactoring error: ${error.message}`);
+ throw new Error(`Refactoring failed: ${error.message}`);
}
+}
+
+/**
+ * Sends a request to the backend to refactor all smells of a type.
+ *
+ * @param smell - The smell to refactor.
+ * @param workspacePath - The path to the workspace.
+ * @returns A promise resolving to the refactored data or throwing an error if unsuccessful.
+ */
+export async function backendRefactorSmellType(
+ smell: Smell,
+ workspacePath: string
+): Promise {
+ const url = `${BASE_URL}/refactor-by-type`;
+ const filePath = smell.path;
+ const smellType = smell.symbol;
- const workspaceFolderPath = workspaceFolder.uri.fsPath;
+ // Validate workspace configuration
+ if (!workspacePath) {
+ ecoOutput.error('[backend.ts] Refactoring aborted: No workspace path');
+ throw new Error('No workspace path provided');
+ }
- console.log(
- `Eco: Initiating refactoring for smell "${smell.symbol}" in "${workspaceFolderPath}"`,
- );
+ ecoOutput.info(`[backend.ts] Starting refactoring for smells of type "${smellType}" in "${filePath}"`);
+ // Prepare the payload for the backend
const payload = {
- source_dir: workspaceFolderPath,
- smell,
+ sourceDir: workspacePath,
+ smellType,
+ firstSmell: smell,
};
try {
@@ -137,17 +255,17 @@ export async function refactorSmell(
});
if (!response.ok) {
- const errorText = await response.text();
- console.error(
- `Eco: Error - Refactoring smell "${smell.symbol}": ${errorText}`,
- );
- throw new Error(`Eco: Error refactoring smell: ${errorText}`);
+ const errorData = await response.json();
+ ecoOutput.error(`[backend.ts] Refactoring failed: ${errorData.detail || 'Unknown error'}`);
+ throw new Error(errorData.detail || 'Refactoring failed');
}
- const refactorResult = (await response.json()) as RefactorOutput;
- return refactorResult;
- } catch (error) {
- console.error('Eco: Unexpected error in refactorSmell:', error);
- throw error;
+ const result = await response.json();
+ ecoOutput.info(`[backend.ts] Refactoring successful for ${smell.symbol}`);
+ return result;
+
+ } catch (error: any) {
+ ecoOutput.error(`[backend.ts] Refactoring error: ${error.message}`);
+ throw new Error(`Refactoring failed: ${error.message}`);
}
-}
+}
\ No newline at end of file
diff --git a/src/commands/configureWorkspace.ts b/src/commands/configureWorkspace.ts
new file mode 100644
index 0000000..5e1901e
--- /dev/null
+++ b/src/commands/configureWorkspace.ts
@@ -0,0 +1,146 @@
+import * as vscode from 'vscode';
+import * as path from 'path';
+import * as fs from 'fs';
+import { envConfig } from '../utils/envConfig';
+
+/**
+ * Initializes workspace configuration by prompting user to select a Python project folder.
+ * This is the main entry point for workspace configuration and delegates to folder-specific logic.
+ *
+ * @param context - VS Code extension context containing workspace state management
+ */
+export async function configureWorkspace(
+ context: vscode.ExtensionContext,
+): Promise {
+ await configurePythonFolder(context);
+}
+
+/**
+ * Recursively identifies Python project folders by scanning for:
+ * - Python files (*.py)
+ * - \_\_init\_\_.py package markers
+ * Maintains a hierarchical understanding of Python projects in the workspace.
+ *
+ * @param folderPath - Absolute filesystem path to scan
+ * @returns Array of qualified Python project paths
+ */
+function findPythonFoldersRecursively(folderPath: string): string[] {
+ let pythonFolders: string[] = [];
+ let hasPythonFiles = false;
+
+ try {
+ const files = fs.readdirSync(folderPath);
+
+ // Validate current folder contains Python artifacts
+ if (
+ files.includes('__init__.py') ||
+ files.some((file) => file.endsWith('.py'))
+ ) {
+ hasPythonFiles = true;
+ }
+
+ // Recursively process subdirectories
+ for (const file of files) {
+ const filePath = path.join(folderPath, file);
+ if (fs.statSync(filePath).isDirectory()) {
+ const subfolderPythonFolders = findPythonFoldersRecursively(filePath);
+ if (subfolderPythonFolders.length > 0) {
+ hasPythonFiles = true;
+ pythonFolders.push(...subfolderPythonFolders);
+ }
+ }
+ }
+
+ // Include current folder if Python content found at any level
+ if (hasPythonFiles) {
+ pythonFolders.push(folderPath);
+ }
+ } catch (error) {
+ vscode.window.showErrorMessage(
+ `Workspace scanning error in ${path.basename(folderPath)}: ${(error as Error).message}`,
+ );
+ }
+
+ return pythonFolders;
+}
+
+/**
+ * Guides user through Python workspace selection process with validation.
+ * Presents filtered list of valid Python project folders and handles selection.
+ *
+ * @param context - Extension context for state persistence
+ */
+async function configurePythonFolder(
+ context: vscode.ExtensionContext,
+): Promise {
+ const workspaceFolders = vscode.workspace.workspaceFolders;
+
+ if (!workspaceFolders?.length) {
+ vscode.window.showErrorMessage(
+ 'No workspace detected. Please open a project folder first.',
+ );
+ return;
+ }
+
+ // Identify all Python project roots
+ const validPythonFolders = workspaceFolders
+ .map((folder) => folder.uri.fsPath)
+ .flatMap(findPythonFoldersRecursively);
+
+ if (validPythonFolders.length === 0) {
+ vscode.window.showErrorMessage(
+ 'No Python projects found. Workspace must contain .py files or __init__.py markers.',
+ );
+ return;
+ }
+
+ // Present interactive folder selection
+ const selectedFolder = await vscode.window.showQuickPick(
+ validPythonFolders.map((folder) => ({
+ label: path.basename(folder),
+ description: folder,
+ detail: `Python content: ${fs
+ .readdirSync(folder)
+ .filter((file) => file.endsWith('.py') || file === '__init__.py')
+ .join(', ')}`,
+ folderPath: folder,
+ })),
+ {
+ placeHolder: 'Select Python project root',
+ matchOnDescription: true,
+ matchOnDetail: true,
+ },
+ );
+
+ if (selectedFolder) {
+ await updateWorkspace(context, selectedFolder.folderPath);
+ vscode.window.showInformationMessage(
+ `Configured workspace: ${path.basename(selectedFolder.folderPath)}`,
+ );
+ }
+}
+
+/**
+ * Persists workspace configuration and updates extension context.
+ * Triggers view refreshes to reflect new workspace state.
+ *
+ * @param context - Extension context for state management
+ * @param workspacePath - Absolute path to selected workspace root
+ */
+export async function updateWorkspace(
+ context: vscode.ExtensionContext,
+ workspacePath: string,
+): Promise {
+ // Persist workspace path
+ await context.workspaceState.update(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ workspacePath,
+ );
+
+ // Update extension context for UI state management
+ vscode.commands.executeCommand(
+ 'setContext',
+ 'workspaceState.workspaceConfigured',
+ true,
+ );
+}
diff --git a/src/commands/detectSmells.ts b/src/commands/detectSmells.ts
deleted file mode 100644
index f597f96..0000000
--- a/src/commands/detectSmells.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import * as vscode from 'vscode';
-
-import { FileHighlighter } from '../ui/fileHighlighter';
-import { getEditorAndFilePath } from '../utils/editorUtils';
-import { fetchSmells } from '../api/backend';
-import { ContextManager } from '../context/contextManager';
-import { envConfig } from '../utils/envConfig';
-import { hashContent, updateHash } from '../utils/hashDocs';
-import { wipeWorkCache } from './wipeWorkCache';
-import { getEnabledSmells } from '../utils/handleSmellSettings';
-import { serverStatus, ServerStatusType } from '../utils/serverStatus';
-
-serverStatus.on('change', (newStatus: ServerStatusType) => {
- console.log('Server status changed:', newStatus);
- if (newStatus === ServerStatusType.DOWN) {
- vscode.window.showWarningMessage(
- 'Smell detection limited. Only cached smells will be shown.',
- );
- }
-});
-
-export interface SmellDetectRecord {
- hash: string;
- smells: Smell[];
-}
-
-let fileHighlighter: FileHighlighter;
-
-export async function detectSmells(contextManager: ContextManager): Promise {
- const { editor, filePath } = getEditorAndFilePath();
-
- // ✅ Ensure an active editor exists
- if (!editor) {
- vscode.window.showErrorMessage('Eco: No active editor found.');
- console.error('Eco: No active editor found to detect smells.');
- return;
- }
-
- // ✅ Ensure filePath is valid
- if (!filePath) {
- vscode.window.showErrorMessage('Eco: Active editor has no valid file path.');
- console.error('Eco: No valid file path found for smell detection.');
- return;
- }
-
- console.log(`Eco: Detecting smells in file: ${filePath}`);
-
- const enabledSmells = getEnabledSmells();
- if (!Object.values(enabledSmells).includes(true)) {
- vscode.window.showWarningMessage(
- 'Eco: No smells are enabled! Detection skipped.',
- );
- return;
- }
-
- // ✅ Check if the enabled smells have changed
- const lastUsedSmells = contextManager.getWorkspaceData(
- envConfig.LAST_USED_SMELLS_KEY!,
- {},
- );
- if (JSON.stringify(lastUsedSmells) !== JSON.stringify(enabledSmells)) {
- console.log('Eco: Smell settings have changed! Wiping cache.');
- await wipeWorkCache(contextManager, 'settings');
- contextManager.setWorkspaceData(envConfig.LAST_USED_SMELLS_KEY!, enabledSmells);
- }
-
- // Handle cache and previous smells
- const allSmells: Record =
- contextManager.getWorkspaceData(envConfig.SMELL_MAP_KEY!) || {};
- const fileSmells = allSmells[filePath];
- const currentFileHash = hashContent(editor.document.getText());
-
- let smellsData: Smell[] | undefined;
-
- if (fileSmells && currentFileHash === fileSmells.hash) {
- vscode.window.showInformationMessage(`Eco: Using cached smells for ${filePath}`);
-
- smellsData = fileSmells.smells;
- } else if (serverStatus.getStatus() === ServerStatusType.UP) {
- updateHash(contextManager, editor.document);
-
- try {
- smellsData = await fetchSmells(
- filePath,
- Object.keys(enabledSmells).filter((s) => enabledSmells[s]),
- );
- } catch (err) {
- console.error(err);
- return;
- }
-
- if (smellsData) {
- allSmells[filePath] = { hash: currentFileHash, smells: smellsData };
- contextManager.setWorkspaceData(envConfig.SMELL_MAP_KEY!, allSmells);
- }
- } else {
- vscode.window.showWarningMessage(
- 'Action blocked: Server is down and no cached smells exist for this file version.',
- );
- return;
- }
-
- if (!smellsData || smellsData.length === 0) {
- vscode.window.showInformationMessage('Eco: No code smells detected.');
- return;
- }
-
- console.log(`Eco: Highlighting detected smells in ${filePath}.`);
- if (!fileHighlighter) {
- fileHighlighter = FileHighlighter.getInstance(contextManager);
- }
- fileHighlighter.highlightSmells(editor, smellsData);
-
- vscode.window.showInformationMessage(
- `Eco: Highlighted ${smellsData.length} smells.`,
- );
-
- // Set the linting state to enabled
- contextManager.setWorkspaceData(envConfig.SMELL_LINTING_ENABLED_KEY, true);
- vscode.commands.executeCommand('setContext', 'eco.smellLintingEnabled', true);
-}
diff --git a/src/commands/detection/detectSmells.ts b/src/commands/detection/detectSmells.ts
new file mode 100644
index 0000000..6dbb5a8
--- /dev/null
+++ b/src/commands/detection/detectSmells.ts
@@ -0,0 +1,193 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { fetchSmells } from '../../api/backend';
+import { SmellsViewProvider } from '../../providers/SmellsViewProvider';
+import { getEnabledSmells } from '../../utils/smellsData';
+import { serverStatus, ServerStatusType } from '../../emitters/serverStatus';
+import { SmellsCacheManager } from '../../context/SmellsCacheManager';
+import { ecoOutput } from '../../extension';
+
+/**
+ * Performs code smell analysis on a single Python file with comprehensive state management.
+ * Only shows user notifications for critical events requiring attention.
+ *
+ * @param filePath - Absolute path to the Python file to analyze
+ * @param smellsViewProvider - Provider for updating the UI with results
+ * @param smellsCacheManager - Manager for cached smell results
+ */
+export async function detectSmellsFile(
+ filePath: string,
+ smellsViewProvider: SmellsViewProvider,
+ smellsCacheManager: SmellsCacheManager,
+): Promise {
+ const shouldProceed = await precheckAndMarkQueued(
+ filePath,
+ smellsViewProvider,
+ smellsCacheManager,
+ );
+
+ if (!shouldProceed) return;
+
+ // Transform enabled smells into backend-compatible format
+ const enabledSmells = getEnabledSmells();
+ const enabledSmellsForBackend = Object.fromEntries(
+ Object.entries(enabledSmells).map(([key, value]) => [key, value.options]),
+ );
+
+ try {
+ ecoOutput.info(`[detection.ts] Analyzing: ${path.basename(filePath)}`);
+ const { smells, status } = await fetchSmells(filePath, enabledSmellsForBackend);
+
+ // Handle backend response
+ if (status === 200) {
+ if (smells.length > 0) {
+ ecoOutput.info(`[detection.ts] Detected ${smells.length} smells`);
+ smellsViewProvider.setStatus(filePath, 'passed');
+ await smellsCacheManager.setCachedSmells(filePath, smells);
+ smellsViewProvider.setSmells(filePath, smells);
+ } else {
+ ecoOutput.info('[detection.ts] File has no detectable smells');
+ smellsViewProvider.setStatus(filePath, 'no_issues');
+ await smellsCacheManager.setCachedSmells(filePath, []);
+ }
+ } else {
+ const msg = `Analysis failed for ${path.basename(filePath)} (status ${status})`;
+ ecoOutput.error(`[detection.ts] ${msg}`);
+ smellsViewProvider.setStatus(filePath, 'failed');
+ vscode.window.showErrorMessage(msg);
+ }
+ } catch (error: any) {
+ const msg = `Analysis failed: ${error.message}`;
+ ecoOutput.error(`[detection.ts] ${msg}`);
+ smellsViewProvider.setStatus(filePath, 'failed');
+ vscode.window.showErrorMessage(msg);
+ }
+}
+
+/**
+ * Validates conditions before analysis. Only shows notifications when:
+ * - Using cached results (info)
+ * - Server is down (warning)
+ * - No smells configured (warning)
+ *
+ * @returns boolean indicating whether analysis should proceed
+ */
+async function precheckAndMarkQueued(
+ filePath: string,
+ smellsViewProvider: SmellsViewProvider,
+ smellsCacheManager: SmellsCacheManager,
+): Promise {
+ // Validate file scheme and extension
+ const fileUri = vscode.Uri.file(filePath);
+ if (fileUri.scheme !== 'file') {
+ return false;
+ }
+
+ if (!filePath.endsWith('.py')) {
+ return false;
+ }
+
+ // Check for cached results
+ if (smellsCacheManager.hasCachedSmells(filePath)) {
+ const cached = smellsCacheManager.getCachedSmells(filePath);
+ ecoOutput.info(
+ `[detection.ts] Using cached results for ${path.basename(filePath)}`,
+ );
+
+ if (cached && cached.length > 0) {
+ smellsViewProvider.setStatus(filePath, 'passed');
+ smellsViewProvider.setSmells(filePath, cached);
+ } else {
+ smellsViewProvider.setStatus(filePath, 'no_issues');
+ }
+ return false;
+ }
+
+ // Check server availability
+ if (serverStatus.getStatus() === ServerStatusType.DOWN) {
+ const msg = 'Backend server unavailable - using cached results where available';
+ ecoOutput.warn(`[detection.ts] ${msg}`);
+ vscode.window.showWarningMessage(msg);
+ smellsViewProvider.setStatus(filePath, 'server_down');
+ return false;
+ }
+
+ // Verify at least one smell detector is enabled
+ const enabledSmells = getEnabledSmells();
+ if (Object.keys(enabledSmells).length === 0) {
+ const msg = 'No smell detectors enabled in settings';
+ ecoOutput.warn(`[detection.ts] ${msg}`);
+ vscode.window.showWarningMessage(msg);
+ return false;
+ }
+
+ smellsViewProvider.setStatus(filePath, 'queued');
+ return true;
+}
+
+/**
+ * Recursively analyzes Python files in a directory with progress indication.
+ * Shows a progress notification for the folder scan operation.
+ *
+ * @param folderPath - Absolute path to the folder to analyze
+ * @param smellsViewProvider - Provider for updating the UI with results
+ * @param smellsCacheManager - Manager for cached smell results
+ */
+export async function detectSmellsFolder(
+ folderPath: string,
+ smellsViewProvider: SmellsViewProvider,
+ smellsCacheManager: SmellsCacheManager,
+): Promise {
+ return vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: `Scanning for Python files in ${path.basename(folderPath)}...`,
+ cancellable: false,
+ },
+ async () => {
+ const pythonFiles: string[] = [];
+
+ // Recursive directory walker for Python files
+ function walk(dir: string): void {
+ try {
+ const entries = fs.readdirSync(dir);
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry);
+ const stat = fs.statSync(fullPath);
+
+ if (stat.isDirectory()) {
+ walk(fullPath);
+ } else if (stat.isFile() && fullPath.endsWith('.py')) {
+ pythonFiles.push(fullPath);
+ }
+ }
+ } catch (error) {
+ ecoOutput.error(
+ `[detection.ts] Scan error: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ }
+ }
+
+ walk(folderPath);
+ ecoOutput.info(`[detection.ts] Found ${pythonFiles.length} files to analyze`);
+
+ if (pythonFiles.length === 0) {
+ vscode.window.showWarningMessage(
+ `No Python files found in ${path.basename(folderPath)}`,
+ );
+ return;
+ }
+
+ vscode.window.showInformationMessage(
+ `Analyzing ${pythonFiles.length} Python files...`,
+ );
+
+ // Process each found Python file
+ for (const file of pythonFiles) {
+ await detectSmellsFile(file, smellsViewProvider, smellsCacheManager);
+ }
+ },
+ );
+}
diff --git a/src/commands/detection/wipeWorkCache.ts b/src/commands/detection/wipeWorkCache.ts
new file mode 100644
index 0000000..80533fc
--- /dev/null
+++ b/src/commands/detection/wipeWorkCache.ts
@@ -0,0 +1,30 @@
+import * as vscode from 'vscode';
+
+import { SmellsCacheManager } from '../../context/SmellsCacheManager';
+import { SmellsViewProvider } from '../../providers/SmellsViewProvider';
+
+/**
+ * Clears the smells cache and refreshes the UI.
+ * @param smellsCacheManager - Manages the caching of smells and file hashes.
+ * @param smellsViewProvider - The UI provider for updating the tree view.
+ */
+export async function wipeWorkCache(
+ smellsCacheManager: SmellsCacheManager,
+ smellsViewProvider: SmellsViewProvider,
+) {
+ const userResponse = await vscode.window.showWarningMessage(
+ 'Are you sure you want to clear the entire workspace analysis? This action cannot be undone.',
+ { modal: true },
+ 'Confirm',
+ );
+
+ if (userResponse === 'Confirm') {
+ smellsCacheManager.clearAllCachedSmells();
+ smellsViewProvider.clearAllStatuses();
+ smellsViewProvider.refresh();
+
+ vscode.window.showInformationMessage('Workspace analysis cleared successfully.');
+ } else {
+ vscode.window.showInformationMessage('Operation cancelled.');
+ }
+}
diff --git a/src/commands/refactor/acceptRefactoring.ts b/src/commands/refactor/acceptRefactoring.ts
new file mode 100644
index 0000000..eca8b47
--- /dev/null
+++ b/src/commands/refactor/acceptRefactoring.ts
@@ -0,0 +1,110 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import { SmellsViewProvider } from '../../providers/SmellsViewProvider';
+import { MetricsViewProvider } from '../../providers/MetricsViewProvider';
+import { RefactoringDetailsViewProvider } from '../../providers/RefactoringDetailsViewProvider';
+import { SmellsCacheManager } from '../../context/SmellsCacheManager';
+import { ecoOutput } from '../../extension';
+import { hideRefactorActionButtons } from '../../utils/refactorActionButtons';
+import { detectSmellsFile } from '../detection/detectSmells';
+import { closeAllTrackedDiffEditors } from '../../utils/trackedDiffEditors';
+import { envConfig } from '../../utils/envConfig';
+
+/**
+ * Handles acceptance and application of refactoring changes to the codebase.
+ * Performs the following operations:
+ * 1. Applies refactored changes to target and affected files
+ * 2. Updates energy savings metrics
+ * 3. Clears cached smell data for modified files
+ * 4. Updates UI components to reflect changes
+ *
+ * @param refactoringDetailsViewProvider - Provides access to refactoring details
+ * @param metricsDataProvider - Handles metrics tracking and updates
+ * @param smellsCacheManager - Manages smell detection cache invalidation
+ * @param smellsViewProvider - Controls the smells view UI updates
+ * @param context - VS Code extension context
+ */
+export async function acceptRefactoring(
+ context: vscode.ExtensionContext,
+ refactoringDetailsViewProvider: RefactoringDetailsViewProvider,
+ metricsDataProvider: MetricsViewProvider,
+ smellsCacheManager: SmellsCacheManager,
+ smellsViewProvider: SmellsViewProvider,
+): Promise {
+ const targetFile = refactoringDetailsViewProvider.targetFile;
+ const affectedFiles = refactoringDetailsViewProvider.affectedFiles;
+
+ // Validate refactoring data exists
+ if (!targetFile || !affectedFiles) {
+ console.log('no data');
+ ecoOutput.error('[refactorActions.ts] Error: No refactoring data available');
+ vscode.window.showErrorMessage('No refactoring data available.');
+ return;
+ }
+
+ try {
+ ecoOutput.info(
+ `[refactorActions.ts] Applying refactoring to target file: ${targetFile.original}`,
+ );
+
+ // Apply refactored changes to filesystem
+ fs.copyFileSync(targetFile.refactored, targetFile.original);
+ affectedFiles.forEach((file) => {
+ fs.copyFileSync(file.refactored, file.original);
+ ecoOutput.info(`[refactorActions.ts] Updated affected file: ${file.original}`);
+ });
+
+ // Update metrics if energy savings data exists
+ if (
+ refactoringDetailsViewProvider.energySaved &&
+ refactoringDetailsViewProvider.targetSmell
+ ) {
+ metricsDataProvider.updateMetrics(
+ targetFile.original,
+ refactoringDetailsViewProvider.energySaved,
+ refactoringDetailsViewProvider.targetSmell.symbol,
+ );
+ ecoOutput.info('[refactorActions.ts] Updated energy savings metrics');
+ }
+
+ // Invalidate cache for modified files
+ await Promise.all([
+ smellsCacheManager.clearCachedSmellsForFile(targetFile.original),
+ ...affectedFiles.map((file) =>
+ smellsCacheManager.clearCachedSmellsForFile(file.original),
+ ),
+ ]);
+ ecoOutput.trace('[refactorActions.ts] Cleared smell caches for modified files');
+
+ // Update UI state
+ smellsViewProvider.setStatus(targetFile.original, 'outdated');
+ affectedFiles.forEach((file) => {
+ smellsViewProvider.setStatus(file.original, 'outdated');
+ });
+
+ await detectSmellsFile(
+ targetFile.original,
+ smellsViewProvider,
+ smellsCacheManager,
+ );
+
+ // Reset UI components
+ refactoringDetailsViewProvider.resetRefactoringDetails();
+ closeAllTrackedDiffEditors();
+ hideRefactorActionButtons();
+ smellsViewProvider.refresh();
+
+ context.workspaceState.update(envConfig.UNFINISHED_REFACTORING!, undefined);
+
+ vscode.window.showInformationMessage('Refactoring successfully applied');
+ ecoOutput.info(
+ '[refactorActions.ts] Refactoring changes completed successfully',
+ );
+ } catch (error) {
+ const errorDetails = error instanceof Error ? error.message : 'Unknown error';
+ ecoOutput.error(
+ `[refactorActions.ts] Error applying refactoring: ${errorDetails}`,
+ );
+ vscode.window.showErrorMessage('Failed to apply refactoring. Please try again.');
+ }
+}
diff --git a/src/commands/refactor/refactor.ts b/src/commands/refactor/refactor.ts
new file mode 100644
index 0000000..a57c5a2
--- /dev/null
+++ b/src/commands/refactor/refactor.ts
@@ -0,0 +1,132 @@
+import * as vscode from 'vscode';
+import * as path from 'path';
+
+import { backendRefactorSmell, backendRefactorSmellType } from '../../api/backend';
+import { SmellsViewProvider } from '../../providers/SmellsViewProvider';
+import { RefactoringDetailsViewProvider } from '../../providers/RefactoringDetailsViewProvider';
+import { ecoOutput } from '../../extension';
+import { serverStatus, ServerStatusType } from '../../emitters/serverStatus';
+import {
+ showRefactorActionButtons,
+ hideRefactorActionButtons,
+} from '../../utils/refactorActionButtons';
+import { registerDiffEditor } from '../../utils/trackedDiffEditors';
+import { envConfig } from '../../utils/envConfig';
+
+/**
+ * Orchestrates the complete refactoring workflow.
+ * If isRefactorAllOfType is true, it sends a request to refactor all smells of the same type.
+ *
+ * - Pre-flight validation checks
+ * - Backend communication
+ * - UI updates and diff visualization
+ * - Success/error handling
+ *
+ * Shows carefully selected user notifications for key milestones and errors.
+ */
+export async function refactor(
+ smellsViewProvider: SmellsViewProvider,
+ refactoringDetailsViewProvider: RefactoringDetailsViewProvider,
+ smell: Smell,
+ context: vscode.ExtensionContext,
+ isRefactorAllOfType: boolean = false,
+): Promise {
+ // Log and notify refactoring initiation
+ const action = isRefactorAllOfType
+ ? 'Refactoring all smells of type'
+ : 'Refactoring';
+ ecoOutput.info(`[refactor.ts] ${action} ${smell.symbol} in ${smell.path}`);
+ vscode.window.showInformationMessage(`${action} ${smell.symbol}...`);
+
+ // Validate workspace configuration
+ const workspacePath = context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+
+ if (!workspacePath) {
+ ecoOutput.error('[refactor.ts] Refactoring aborted: No workspace configured');
+ vscode.window.showErrorMessage('Please configure workspace first');
+ return;
+ }
+
+ // Verify backend availability
+ if (serverStatus.getStatus() === ServerStatusType.DOWN) {
+ ecoOutput.warn('[refactor.ts] Refactoring blocked: Backend unavailable');
+ vscode.window.showWarningMessage(
+ 'Cannot refactor - backend service unavailable',
+ );
+ smellsViewProvider.setStatus(smell.path, 'server_down');
+ return;
+ }
+
+ // Update UI state
+ smellsViewProvider.setStatus(smell.path, 'queued');
+ vscode.commands.executeCommand('setContext', 'refactoringInProgress', true);
+
+ try {
+ // Execute backend refactoring
+ ecoOutput.trace(`[refactor.ts] Sending ${action} request...`);
+ const refactoredData = isRefactorAllOfType
+ ? await backendRefactorSmellType(smell, workspacePath)
+ : await backendRefactorSmell(smell, workspacePath);
+
+ ecoOutput.info(
+ `[refactor.ts] Refactoring completed for ${path.basename(smell.path)}. ` +
+ `Energy saved: ${refactoredData.energySaved ?? 'N/A'} kg CO2`,
+ );
+
+ await context.workspaceState.update(envConfig.UNFINISHED_REFACTORING!, {
+ refactoredData,
+ smell,
+ });
+
+ startRefactorSession(smell, refactoredData, refactoringDetailsViewProvider);
+ } catch (error) {
+ ecoOutput.error(
+ `[refactor.ts] Refactoring failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ vscode.window.showErrorMessage('Refactoring failed. See output for details.');
+
+ refactoringDetailsViewProvider.resetRefactoringDetails();
+ hideRefactorActionButtons();
+ smellsViewProvider.setStatus(smell.path, 'failed');
+ } finally {
+ vscode.commands.executeCommand('setContext', 'refactoringInProgress', false);
+ }
+}
+
+export async function startRefactorSession(
+ smell: Smell,
+ refactoredData: RefactoredData,
+ refactoringDetailsViewProvider: RefactoringDetailsViewProvider,
+): Promise {
+ // Update refactoring details view
+ refactoringDetailsViewProvider.updateRefactoringDetails(
+ smell,
+ refactoredData.targetFile,
+ refactoredData.affectedFiles,
+ refactoredData.energySaved,
+ );
+
+ // Show diff comparison
+ const targetFile = refactoredData.targetFile;
+ const fileName = path.basename(targetFile.original);
+ await vscode.commands.executeCommand(
+ 'vscode.diff',
+ vscode.Uri.file(targetFile.original),
+ vscode.Uri.file(targetFile.refactored),
+ `Refactoring Comparison (${fileName})`,
+ { preview: false },
+ );
+ registerDiffEditor(
+ vscode.Uri.file(targetFile.original),
+ vscode.Uri.file(targetFile.refactored),
+ );
+
+ await vscode.commands.executeCommand('ecooptimizer.refactorView.focus');
+ showRefactorActionButtons();
+
+ vscode.window.showInformationMessage(
+ `Refactoring complete. Estimated savings: ${refactoredData.energySaved ?? 'N/A'} kg CO2`,
+ );
+}
diff --git a/src/commands/refactor/rejectRefactoring.ts b/src/commands/refactor/rejectRefactoring.ts
new file mode 100644
index 0000000..da1e282
--- /dev/null
+++ b/src/commands/refactor/rejectRefactoring.ts
@@ -0,0 +1,47 @@
+import * as vscode from 'vscode';
+
+import { RefactoringDetailsViewProvider } from '../../providers/RefactoringDetailsViewProvider';
+import { hideRefactorActionButtons } from '../../utils/refactorActionButtons';
+import { closeAllTrackedDiffEditors } from '../../utils/trackedDiffEditors';
+import { SmellsViewProvider } from '../../providers/SmellsViewProvider';
+import { ecoOutput } from '../../extension';
+import { envConfig } from '../../utils/envConfig';
+
+/**
+ * Handles rejection of proposed refactoring changes by:
+ * 1. Resetting UI components
+ * 2. Cleaning up diff editors
+ * 3. Restoring original file states
+ * 4. Providing user feedback
+ *
+ * Only shows a single notification to avoid interrupting workflow.
+ */
+export async function rejectRefactoring(
+ context: vscode.ExtensionContext,
+ refactoringDetailsViewProvider: RefactoringDetailsViewProvider,
+ smellsViewProvider: SmellsViewProvider,
+): Promise {
+ ecoOutput.info('[refactorActions.ts] Refactoring changes discarded');
+ vscode.window.showInformationMessage('Refactoring changes discarded');
+
+ try {
+ // Restore original file status if target exists
+ if (refactoringDetailsViewProvider.targetFile?.original) {
+ const originalPath = refactoringDetailsViewProvider.targetFile.original;
+ smellsViewProvider.setStatus(originalPath, 'passed');
+ ecoOutput.trace(`[refactorActions.ts] Reset status for ${originalPath}`);
+ }
+
+ // Clean up UI components
+ await closeAllTrackedDiffEditors();
+ refactoringDetailsViewProvider.resetRefactoringDetails();
+ hideRefactorActionButtons();
+
+ context.workspaceState.update(envConfig.UNFINISHED_REFACTORING!, undefined);
+
+ ecoOutput.trace('[refactorActions.ts] Refactoring rejection completed');
+ } catch (error) {
+ const errorMsg = `[refactorActions.ts] Error during rejection cleanup: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ ecoOutput.error(errorMsg);
+ }
+}
diff --git a/src/commands/refactorSmell.ts b/src/commands/refactorSmell.ts
deleted file mode 100644
index 9d9a4d8..0000000
--- a/src/commands/refactorSmell.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-import * as vscode from 'vscode';
-import * as fs from 'fs';
-
-import { envConfig } from '../utils/envConfig';
-
-import { getEditorAndFilePath } from '../utils/editorUtils';
-import { refactorSmell } from '../api/backend';
-import { sidebarState } from '../utils/handleEditorChange';
-
-import { FileHighlighter } from '../ui/fileHighlighter';
-import { ContextManager } from '../context/contextManager';
-import { setTimeout } from 'timers/promises';
-import { serverStatus } from '../utils/serverStatus';
-import { ServerStatusType } from '../utils/serverStatus';
-
-/* istanbul ignore next */
-serverStatus.on('change', (newStatus: ServerStatusType) => {
- console.log('Server status changed:', newStatus);
- if (newStatus === ServerStatusType.DOWN) {
- vscode.window.showWarningMessage('No refactoring is possible at this time.');
- }
-});
-
-export interface MultiRefactoredData {
- tempDirs: string[];
- targetFile: ChangedFile;
- affectedFiles: ChangedFile[];
- energySaved: number;
-}
-
-async function refactorLine(
- smell: Smell,
- filePath: string,
-): Promise {
- try {
- const refactorResult = await refactorSmell(filePath, smell);
- return refactorResult;
- } catch (error) {
- console.error('Error refactoring smell:', error);
- vscode.window.showErrorMessage((error as Error).message);
- return;
- }
-}
-
-export async function refactorSelectedSmell(
- contextManager: ContextManager,
- smellGiven?: Smell,
-): Promise {
- const { editor, filePath } = getEditorAndFilePath();
-
- const pastData = contextManager.getWorkspaceData(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- );
-
- // Clean up temp directory if not removed
- if (pastData) {
- console.log('cleaning up temps');
- cleanTemps(pastData);
- }
-
- if (!editor || !filePath) {
- vscode.window.showErrorMessage(
- 'Eco: Unable to proceed as no active editor or file path found.',
- );
- return;
- }
-
- const selectedLine = editor.selection.start.line + 1; // Update to VS Code editor indexing
-
- const smellsData: Smell[] = contextManager.getWorkspaceData(
- envConfig.SMELL_MAP_KEY!,
- )[filePath].smells;
-
- if (!smellsData || smellsData.length === 0) {
- vscode.window.showErrorMessage(
- 'Eco: No smells detected in the file for refactoring.',
- );
- return;
- }
-
- // Find the smell to refactor
- let smellToRefactor: Smell | undefined;
- if (smellGiven?.messageId) {
- smellToRefactor = smellsData.find(
- (smell: Smell) =>
- smell.messageId === smellGiven.messageId &&
- smellGiven.occurences[0].line === smell.occurences[0].line,
- );
- } else {
- smellToRefactor = smellsData.find(
- (smell: Smell) => selectedLine === smell.occurences[0].line,
- );
- }
-
- if (!smellToRefactor) {
- vscode.window.showErrorMessage('Eco: No matching smell found for refactoring.');
- return;
- }
-
- await vscode.workspace.save(editor.document.uri);
-
- const refactorResult = await vscode.window.withProgress(
- {
- location: vscode.ProgressLocation.Notification,
- title: `Fetching refactoring for ${smellToRefactor.symbol} on line ${smellToRefactor.occurences[0].line}`,
- },
- async (_progress, _token) => {
- const result = await refactorLine(smellToRefactor, filePath);
-
- if (result && result.refactoredData) {
- vscode.window.showInformationMessage(
- 'Refactoring report available in sidebar.',
- );
- }
-
- return result;
- },
- );
-
- if (!refactorResult || !refactorResult.refactoredData) {
- vscode.window.showErrorMessage(
- 'Eco: Refactoring failed. See console for details.',
- );
- return;
- }
-
- const { refactoredData } = refactorResult;
-
- await startRefactoringSession(contextManager, editor, refactoredData);
-
- if (refactorResult.updatedSmells.length) {
- const fileHighlighter = FileHighlighter.getInstance(contextManager);
- fileHighlighter.highlightSmells(editor, refactorResult.updatedSmells);
- } else {
- vscode.window.showWarningMessage(
- 'Eco: No updated smells detected after refactoring.',
- );
- }
-}
-
-export async function refactorAllSmellsOfType(
- // eslint-disable-next-line unused-imports/no-unused-vars
- contextManager: ContextManager,
- // eslint-disable-next-line unused-imports/no-unused-vars
- smellId: string,
-): Promise {
- // const { editor, filePath } = getEditorAndFilePath();
- // const pastData = contextManager.getWorkspaceData(
- // envConfig.CURRENT_REFACTOR_DATA_KEY!,
- // );
- // // Clean up temp directory if not removed
- // if (pastData) {
- // cleanTemps(pastData);
- // }
- // if (!editor) {
- // vscode.window.showErrorMessage(
- // 'Eco: Unable to proceed as no active editor found.',
- // );
- // console.log('No active editor found to refactor smell. Returning back.');
- // return;
- // }
- // if (!filePath) {
- // vscode.window.showErrorMessage(
- // 'Eco: Unable to proceed as active editor does not have a valid file path.',
- // );
- // console.log('No valid file path found to refactor smell. Returning back.');
- // return;
- // }
- // // only account for one selection to be refactored for now
- // // const selectedLine = editor.selection.start.line + 1; // update to VS code editor indexing
- // const smellsData: Smell[] = contextManager.getWorkspaceData(
- // envConfig.SMELL_MAP_KEY!,
- // )[filePath].smells;
- // if (!smellsData || smellsData.length === 0) {
- // vscode.window.showErrorMessage(
- // 'Eco: No smells detected in the file for refactoring.',
- // );
- // console.log('No smells found in the file for refactoring.');
- // return;
- // }
- // // Filter smells by the given type ID
- // const smellsOfType = smellsData.filter(
- // (smell: Smell) => smell.messageId === smellId,
- // );
- // if (smellsOfType.length === 0) {
- // vscode.window.showWarningMessage(
- // `Eco: No smells of type ${smellId} found in the file.`,
- // );
- // return;
- // }
- // let combinedRefactoredData = '';
- // let totalEnergySaved = 0;
- // let allUpdatedSmells: Smell[] = [];
- // // Refactor each smell of the given type
- // for (const smell of smellsOfType) {
- // const refactorResult = await refactorLine(smell, filePath);
- // if (refactorResult && refactorResult.refactoredData) {
- // // Add two newlines between each refactored result
- // if (combinedRefactoredData) {
- // combinedRefactoredData += '\n\n';
- // }
- // fs.readFile(
- // refactorResult.refactoredData.targetFile.refactored,
- // (err, data) => {
- // if (!err) {
- // combinedRefactoredData += data.toString('utf8');
- // }
- // },
- // );
- // totalEnergySaved += refactorResult.refactoredData.energySaved;
- // if (refactorResult.updatedSmells) {
- // allUpdatedSmells = [...allUpdatedSmells, ...refactorResult.updatedSmells];
- // }
- // }
- // }
- // /*
- // Once all refactorings are merge, need to write to a file so that it has a path that
- // will be the new `targetFile`. Also need to reconstruct the `RefactoredData` object
- // by combining all `affectedFiles` merge to new paths if applicable. Once implemented,
- // just uncomment lines below and pass in the refactoredData.
- // */
- // // Tentative data structure to be built below, change inputs as needed but needs
- // // to implement the `MultiRefactoredData` interface
- // // For any temp files that need to be written due to merging, I'd suggest writing them all
- // // to one temp directory and add that directory to allTempDirs, that way they will be removed
- // // UNCOMMENT ME WHEN READY
- // // const combinedRefactoredData: MultiRefactoredData = {
- // // targetFile: combinedTargetFile,
- // // affectedFiles: allAffectedFiles,
- // // energySaved: totalEnergySaved,
- // // tempDirs: allTempDirs
- // // }
- // // UNCOMMENT ME WHEN READY
- // // startRefactoringSession(contextManager,editor,combinedRefactoredData);
- // if (combinedRefactoredData) {
- // // await RefactorManager.previewRefactor(editor, combinedRefactoredData);
- // vscode.window.showInformationMessage(
- // `Eco: Refactoring completed. Total energy difference: ${totalEnergySaved.toFixed(
- // 4,
- // )}`,
- // );
- // } else {
- // vscode.window.showErrorMessage(
- // 'Eco: Refactoring failed. See console for details.',
- // );
- // return;
- // }
- // if (allUpdatedSmells.length) {
- // const fileHighlighter = FileHighlighter.getInstance(contextManager);
- // fileHighlighter.highlightSmells(editor, allUpdatedSmells);
- // } else {
- // vscode.window.showWarningMessage(
- // 'Eco: No updated smells detected after refactoring.',
- // );
- // }
-}
-
-/* istanbul ignore next */
-async function startRefactoringSession(
- contextManager: ContextManager,
- editor: vscode.TextEditor,
- refactoredData: RefactoredData | MultiRefactoredData,
-): Promise {
- // Store only the diff editor state
- await contextManager.setWorkspaceData(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- refactoredData,
- );
-
- await vscode.commands.executeCommand('extension.refactorSidebar.focus');
-
- //Read the refactored code
- const refactoredCode = vscode.Uri.file(refactoredData.targetFile.refactored);
-
- //Get the original code from the editor
- const originalCode = editor.document.uri;
-
- const allFiles: ChangedFile[] = [
- refactoredData.targetFile,
- ...refactoredData.affectedFiles,
- ].map((file) => {
- return {
- original: vscode.Uri.file(file.original).toString(),
- refactored: vscode.Uri.file(file.refactored).toString(),
- };
- });
-
- await contextManager.setWorkspaceData(envConfig.ACTIVE_DIFF_KEY!, {
- files: allFiles,
- firstOpen: true,
- isOpen: true,
- });
-
- await setTimeout(500);
-
- const doc = await vscode.workspace.openTextDocument(originalCode);
- await vscode.window.showTextDocument(doc, { preview: false });
-
- //Show the diff viewer
- sidebarState.isOpening = true;
- vscode.commands.executeCommand(
- 'vscode.diff',
- originalCode,
- refactoredCode,
- 'Refactoring Comparison',
- );
- vscode.commands.executeCommand('ecooptimizer.showRefactorSidebar');
- sidebarState.isOpening = false;
-}
-
-export async function cleanTemps(pastData: any): Promise {
- console.log('Cleaning up stale artifacts');
- const tempDirs =
- (pastData!.tempDir! as string) || (pastData!.tempDirs! as string[]);
-
- if (Array.isArray(tempDirs)) {
- for (const dir in tempDirs) {
- await fs.promises.rm(dir, { recursive: true, force: true });
- }
- } else {
- await fs.promises.rm(tempDirs, { recursive: true, force: true });
- }
-}
diff --git a/src/commands/resetConfiguration.ts b/src/commands/resetConfiguration.ts
new file mode 100644
index 0000000..bd3e004
--- /dev/null
+++ b/src/commands/resetConfiguration.ts
@@ -0,0 +1,35 @@
+import * as vscode from 'vscode';
+import { envConfig } from '../utils/envConfig';
+
+/**
+ * Resets the workspace configuration by clearing the selected workspace path.
+ * Prompts the user for confirmation before performing the reset.
+ *
+ * @param context - The extension context for managing workspace state.
+ */
+export async function resetConfiguration(
+ context: vscode.ExtensionContext,
+): Promise {
+ const confirm = await vscode.window.showWarningMessage(
+ 'Are you sure you want to reset the workspace configuration? This will remove the currently selected workspace and all analysis data will be lost.',
+ { modal: true },
+ 'Reset',
+ );
+
+ if (confirm === 'Reset') {
+ await context.workspaceState.update(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ undefined,
+ );
+
+ vscode.commands.executeCommand(
+ 'setContext',
+ 'workspaceState.workspaceConfigured',
+ false,
+ );
+
+ return true; // signal that reset happened
+ }
+
+ return false;
+}
diff --git a/src/commands/showLogs.ts b/src/commands/showLogs.ts
index 38229d3..c4f0441 100644
--- a/src/commands/showLogs.ts
+++ b/src/commands/showLogs.ts
@@ -3,8 +3,9 @@ import WebSocket from 'ws';
import { initLogs } from '../api/backend';
import { envConfig } from '../utils/envConfig';
-import { serverStatus, ServerStatusType } from '../utils/serverStatus';
-import { globalData } from '../extension';
+import { serverStatus, ServerStatusType } from '../emitters/serverStatus';
+
+const WEBSOCKET_BASE_URL = `ws://${envConfig.SERVER_URL}/logs`;
class LogInitializationError extends Error {
constructor(message: string) {
@@ -13,176 +14,174 @@ class LogInitializationError extends Error {
}
}
-class WebSocketInitializationError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'WebSocketInitializationError';
- }
-}
-
-const WEBSOCKET_BASE_URL = `ws://${envConfig.SERVER_URL}/logs`;
-
-let websockets: { [key: string]: WebSocket | undefined } = {
- main: undefined,
- detect: undefined,
- refactor: undefined,
-};
-
-let channels: {
- [key: string]: { name: string; channel: vscode.LogOutputChannel | undefined };
-} = {
- main: {
- name: 'EcoOptimizer: Main',
- channel: undefined,
- },
- detect: {
- name: 'EcoOptimizer: Detect',
- channel: undefined,
- },
- refactor: {
- name: 'EcoOptimizer: Refactor',
- channel: undefined,
- },
-};
-
-let CHANNELS_CREATED = false;
-
-serverStatus.on('change', async (newStatus: ServerStatusType) => {
- console.log('Server status changed:', newStatus);
- if (newStatus === ServerStatusType.DOWN) {
- channels.main.channel?.appendLine('Server connection lost');
- } else {
- channels.main.channel?.appendLine('Server connection re-established.');
- await startLogging();
- }
-});
-
-export async function startLogging(retries = 3, delay = 1000): Promise {
- let logInitialized = false;
- const logPath = globalData.contextManager?.context.logUri?.fsPath;
- console.log('log path:', logPath);
-
- if (!logPath) {
- console.error('Missing contextManager or logUri. Cannot initialize logging.');
- return;
+export class LogManager {
+ private websockets: { [key: string]: WebSocket | undefined };
+ private channels: {
+ [key: string]: { name: string; channel: vscode.LogOutputChannel | undefined };
+ };
+ private channelsCreated: boolean;
+ private context: vscode.ExtensionContext;
+
+ constructor(context: vscode.ExtensionContext) {
+ this.context = context;
+ this.websockets = {
+ main: undefined,
+ detect: undefined,
+ refactor: undefined,
+ };
+ this.channels = {
+ main: { name: 'EcoOptimizer: Main', channel: undefined },
+ detect: { name: 'EcoOptimizer: Detect', channel: undefined },
+ refactor: { name: 'EcoOptimizer: Refactor', channel: undefined },
+ };
+ this.channelsCreated = false;
+
+ // Listen for server status changes
+ serverStatus.on('change', async (newStatus: ServerStatusType) => {
+ console.log('Server status changed:', newStatus);
+ if (newStatus === ServerStatusType.DOWN) {
+ this.channels.main.channel?.appendLine('Server connection lost');
+ } else {
+ this.channels.main.channel?.appendLine('Server connection re-established.');
+ await this.startLogging();
+ }
+ });
}
- for (let attempt = 1; attempt <= retries; attempt++) {
- try {
- if (!logInitialized) {
- logInitialized = await initLogs(logPath);
+ /**
+ * Starts the logging process, including initializing logs and WebSockets.
+ * @param retries - Number of retry attempts.
+ * @param delay - Initial delay between retries (in milliseconds).
+ */
+ public async startLogging(retries = 3, delay = 1000): Promise {
+ let logInitialized = false;
+ const logPath = this.context.logUri?.fsPath;
+
+ if (!logPath) {
+ throw new LogInitializationError(
+ 'Missing extension context or logUri. Cannot initialize logging.',
+ );
+ }
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
if (!logInitialized) {
- throw new LogInitializationError(
- `Failed to initialize logs at path: ${logPath}`,
- );
+ logInitialized = await initLogs(logPath);
+
+ if (!logInitialized) {
+ throw new LogInitializationError(
+ `Failed to initialize logs at path: ${logPath}`,
+ );
+ }
+ console.log('Log initialization successful.');
}
- console.log('Log initialization successful.');
- }
- try {
- initializeWebSockets();
+ this.initializeWebSockets();
console.log('Successfully initialized WebSockets. Logging is now active.');
return;
- } catch {
- throw new WebSocketInitializationError('Failed to initialize WebSockets.');
- }
- } catch (error) {
- const err = error as Error;
- console.error(`[Attempt ${attempt}/${retries}] ${err.name}: ${err.message}`);
-
- if (attempt < retries) {
- console.log(`Retrying in ${delay}ms...`);
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff
- } else {
- console.error('Max retries reached. Logging process failed.');
+ } catch (error) {
+ const err = error as Error;
+ console.error(`[Attempt ${attempt}/${retries}] ${err.name}: ${err.message}`);
+
+ if (attempt < retries) {
+ console.log(`Retrying in ${delay}ms...`);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff
+ } else {
+ throw new Error('Max retries reached. Logging process failed.');
+ }
}
}
}
-}
-function initializeWebSockets(): void {
- if (!CHANNELS_CREATED) {
- createOutputChannels();
- CHANNELS_CREATED = true;
+ /**
+ * Initializes WebSocket connections for logging.
+ */
+ private initializeWebSockets(): void {
+ if (!this.channelsCreated) {
+ this.createOutputChannels();
+ this.channelsCreated = true;
+ }
+ this.startWebSocket('main');
+ this.startWebSocket('detect');
+ this.startWebSocket('refactor');
}
- startWebSocket('main');
- startWebSocket('detect');
- startWebSocket('refactor');
-}
-function createOutputChannels(): void {
- console.log('Creating ouput channels');
- for (const channel of Object.keys(channels)) {
- channels[channel].channel = vscode.window.createOutputChannel(
- channels[channel].name,
- { log: true },
- );
+ /**
+ * Creates output channels for logging.
+ */
+ private createOutputChannels(): void {
+ console.log('Creating output channels');
+ for (const channel of Object.keys(this.channels)) {
+ this.channels[channel].channel = vscode.window.createOutputChannel(
+ this.channels[channel].name,
+ { log: true },
+ );
+ }
}
-}
-function startWebSocket(logType: string): void {
- const url = `${WEBSOCKET_BASE_URL}/${logType}`;
- const ws = new WebSocket(url);
- websockets[logType] = ws;
-
- ws.on('message', function message(data) {
- const logEvent = data.toString('utf8');
- const level =
- logEvent.match(/\b(ERROR|DEBUG|INFO|WARNING|TRACE)\b/i)?.[0].trim() ||
- 'UNKNOWN';
- const msg = logEvent.split(`[${level}]`, 2)[1].trim();
-
- console.log(logEvent);
- console.log('Level:', level);
-
- switch (level) {
- case 'ERROR': {
- channels[logType].channel!.error(msg);
- break;
- }
- case 'DEBUG': {
- console.log('logging debug');
- channels[logType].channel!.debug(msg);
- break;
- }
- case 'WARNING': {
- channels[logType].channel!.warn(msg);
- break;
- }
- case 'CRITICAL': {
- channels[logType].channel!.error(msg);
- break;
- }
- default: {
- console.log('Logging info');
- channels[logType].channel!.info(msg);
- break;
+ /**
+ * Starts a WebSocket connection for a specific log type.
+ * @param logType - The type of log (e.g., 'main', 'detect', 'refactor').
+ */
+ private startWebSocket(logType: string): void {
+ const url = `${WEBSOCKET_BASE_URL}/${logType}`;
+ const ws = new WebSocket(url);
+ this.websockets[logType] = ws;
+
+ ws.on('message', (data) => {
+ const logEvent = data.toString('utf8');
+ const level =
+ logEvent.match(/\b(ERROR|DEBUG|INFO|WARNING|TRACE)\b/i)?.[0].trim() ||
+ 'UNKNOWN';
+ const msg = logEvent.split(`[${level}]`, 2)[1].trim();
+
+ switch (level) {
+ case 'ERROR': {
+ this.channels[logType].channel!.error(msg);
+ break;
+ }
+ case 'DEBUG': {
+ this.channels[logType].channel!.debug(msg);
+ break;
+ }
+ case 'WARNING': {
+ this.channels[logType].channel!.warn(msg);
+ break;
+ }
+ case 'CRITICAL': {
+ this.channels[logType].channel!.error(msg);
+ break;
+ }
+ default: {
+ this.channels[logType].channel!.info(msg);
+ break;
+ }
}
- }
- });
-
- ws.on('error', function error(err) {
- channels[logType].channel!.error(err);
- });
-
- ws.on('close', function close() {
- channels[logType].channel!.appendLine(
- `WebSocket connection closed for ${channels[logType].name}`,
- );
- });
-
- ws.on('open', function open() {
- channels[logType].channel!.appendLine(`Connected to ${logType} via WebSocket`);
- });
-}
-
-/**
- * Stops watching log files when the extension is deactivated.
- */
-export function stopWatchingLogs(): void {
- Object.values(websockets).forEach((ws) => ws?.close());
+ });
+
+ ws.on('error', (err) => {
+ this.channels[logType].channel!.error(`WebSocket error: ${err.message}`);
+ });
+
+ ws.on('close', () => {
+ this.channels[logType].channel!.appendLine(
+ `WebSocket connection closed for ${this.channels[logType].name}`,
+ );
+ });
+
+ ws.on('open', () => {
+ this.channels[logType].channel!.appendLine(
+ `Connected to ${logType} via WebSocket`,
+ );
+ });
+ }
- Object.values(channels).forEach((channel) => channel.channel?.dispose());
+ /**
+ * Stops watching logs and cleans up resources.
+ */
+ public stopWatchingLogs(): void {
+ Object.values(this.websockets).forEach((ws) => ws?.close());
+ Object.values(this.channels).forEach((channel) => channel.channel?.dispose());
+ }
}
diff --git a/src/commands/toggleSmellLinting.ts b/src/commands/toggleSmellLinting.ts
deleted file mode 100644
index d4b4f54..0000000
--- a/src/commands/toggleSmellLinting.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import * as vscode from 'vscode';
-import { ContextManager } from '../context/contextManager';
-import { detectSmells } from './detectSmells';
-import { FileHighlighter } from '../ui/fileHighlighter'; // Import the class
-import { envConfig } from '../utils/envConfig';
-
-export async function toggleSmellLinting(
- contextManager: ContextManager,
-): Promise {
- const isEnabled = contextManager.getWorkspaceData(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- false,
- );
- const newState = !isEnabled;
-
- // Update state immediately for UI responsiveness
- vscode.commands.executeCommand('setContext', 'eco.smellLintingEnabled', newState);
-
- // Use the singleton instance of FileHighlighter
- const fileHighlighter = FileHighlighter.getInstance(contextManager);
-
- try {
- if (newState) {
- // Run detection and update state on success
- await detectSmells(contextManager); // in the future recieve a true/false
-
- await contextManager.setWorkspaceData(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- newState,
- );
- } else {
- // Clear highlights and update state
- fileHighlighter.resetHighlights(); // Call resetHighlights on the singleton instance
- await contextManager.setWorkspaceData(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- newState,
- );
- vscode.window.showInformationMessage('Eco: Smell linting turned off.');
- }
- } catch (error) {
- console.error('Eco: Error toggling smell linting:', error);
- vscode.window.showErrorMessage('Eco: Failed to toggle smell linting.');
- // Ensure UI state matches actual on error
- vscode.commands.executeCommand(
- 'setContext',
- 'eco.smellLintingEnabled',
- isEnabled,
- );
- }
-}
diff --git a/src/commands/views/exportMetricsData.ts b/src/commands/views/exportMetricsData.ts
new file mode 100644
index 0000000..8fbb512
--- /dev/null
+++ b/src/commands/views/exportMetricsData.ts
@@ -0,0 +1,76 @@
+import * as vscode from 'vscode';
+import { dirname } from 'path';
+import { writeFileSync } from 'fs';
+
+import { MetricsDataItem } from '../../providers/MetricsViewProvider';
+import { envConfig } from '../../utils/envConfig';
+
+/**
+ * Exports collected metrics data to a JSON file in the workspace.
+ * Handles both file and directory workspace paths, saving the output
+ * as 'metrics-data.json' in the appropriate location.
+ *
+ * @param context - Extension context containing metrics data and workspace state
+ */
+export async function exportMetricsData(
+ context: vscode.ExtensionContext,
+): Promise {
+ // Retrieve stored metrics data from extension context
+ const metricsData = context.workspaceState.get<{
+ [path: string]: MetricsDataItem;
+ }>(envConfig.WORKSPACE_METRICS_DATA!, {});
+
+ console.log('metrics data:', metricsData);
+
+ // Early return if no data available
+ if (Object.keys(metricsData).length === 0) {
+ vscode.window.showInformationMessage('No metrics data available to export.');
+ return;
+ }
+
+ // Get configured workspace path from extension context
+ const configuredWorkspacePath = context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+
+ console.log('configured path:', configuredWorkspacePath);
+
+ if (!configuredWorkspacePath) {
+ vscode.window.showErrorMessage('No configured workspace path found.');
+ return;
+ }
+
+ // Determine output file location based on workspace type
+ const workspaceUri = vscode.Uri.file(configuredWorkspacePath);
+ let fileUri: vscode.Uri;
+
+ try {
+ const stat = await vscode.workspace.fs.stat(workspaceUri);
+
+ if (stat.type === vscode.FileType.Directory) {
+ // For directories, save directly in the workspace root
+ fileUri = vscode.Uri.joinPath(workspaceUri, 'metrics-data.json');
+ } else if (stat.type === vscode.FileType.File) {
+ // For single files, save in the parent directory
+ const parentDir = vscode.Uri.file(dirname(configuredWorkspacePath));
+ fileUri = vscode.Uri.joinPath(parentDir, 'metrics-data.json');
+ } else {
+ vscode.window.showErrorMessage('Invalid workspace path type.');
+ return;
+ }
+ } catch (error) {
+ vscode.window.showErrorMessage(`Failed to access workspace path: ${error}`);
+ return;
+ }
+
+ // Write the metrics data to JSON file
+ try {
+ const jsonData = JSON.stringify(metricsData, null, 2);
+ writeFileSync(fileUri.fsPath, jsonData, 'utf-8');
+ vscode.window.showInformationMessage(
+ `Metrics data exported successfully to ${fileUri.fsPath}`,
+ );
+ } catch (error) {
+ vscode.window.showErrorMessage(`Failed to export metrics data: ${error}`);
+ }
+}
diff --git a/src/commands/views/filterSmells.ts b/src/commands/views/filterSmells.ts
new file mode 100644
index 0000000..7a97c8f
--- /dev/null
+++ b/src/commands/views/filterSmells.ts
@@ -0,0 +1,79 @@
+import * as vscode from 'vscode';
+
+import { FilterViewProvider } from '../../providers/FilterViewProvider';
+
+/**
+ * Registers VS Code commands for managing smell filters.
+ * @param context - The VS Code extension context.
+ * @param filterSmellsProvider - The provider responsible for handling smell filtering.
+ */
+export function registerFilterSmellCommands(
+ context: vscode.ExtensionContext,
+ filterSmellsProvider: FilterViewProvider,
+): void {
+ /**
+ * Toggles the state of a specific smell filter.
+ */
+ context.subscriptions.push(
+ vscode.commands.registerCommand(
+ 'ecooptimizer.toggleSmellFilter',
+ (smellKey: string) => {
+ filterSmellsProvider.toggleSmell(smellKey);
+ },
+ ),
+ );
+
+ /**
+ * Edits a specific smell filter option.
+ * Prompts the user for input, validates the value, and updates the setting.
+ */
+ context.subscriptions.push(
+ vscode.commands.registerCommand(
+ 'ecooptimizer.editSmellFilterOption',
+ async (item: any) => {
+ if (!item || !item.smellKey || !item.optionKey) {
+ vscode.window.showErrorMessage('Error: Missing smell or option key.');
+ return;
+ }
+
+ const { smellKey, optionKey, value: oldValue } = item;
+
+ const newValue = await vscode.window.showInputBox({
+ prompt: `Enter a new value for ${optionKey}`,
+ value: oldValue?.toString() || '',
+ validateInput: (input) =>
+ isNaN(Number(input)) ? 'Must be a number' : undefined,
+ });
+
+ if (newValue !== undefined && !isNaN(Number(newValue))) {
+ filterSmellsProvider.updateOption(smellKey, optionKey, Number(newValue));
+ filterSmellsProvider.refresh();
+ }
+ },
+ ),
+ );
+
+ /**
+ * Enables all smell filters.
+ */
+ context.subscriptions.push(
+ vscode.commands.registerCommand('ecooptimizer.selectAllFilterSmells', () => {
+ filterSmellsProvider.setAllSmellsEnabled(true);
+ }),
+ );
+
+ /**
+ * Disables all smell filters.
+ */
+ context.subscriptions.push(
+ vscode.commands.registerCommand('ecooptimizer.deselectAllFilterSmells', () => {
+ filterSmellsProvider.setAllSmellsEnabled(false);
+ }),
+ );
+
+ context.subscriptions.push(
+ vscode.commands.registerCommand('ecooptimizer.setFilterDefaults', () => {
+ filterSmellsProvider.resetToDefaults();
+ }),
+ );
+}
diff --git a/src/commands/views/jumpToSmell.ts b/src/commands/views/jumpToSmell.ts
new file mode 100644
index 0000000..7946047
--- /dev/null
+++ b/src/commands/views/jumpToSmell.ts
@@ -0,0 +1,35 @@
+import * as vscode from 'vscode';
+
+/**
+ * Jumps to a specific line in the given file within the VS Code editor.
+ * @param filePath - The absolute path of the file.
+ * @param line - The line number to navigate to.
+ */
+export async function jumpToSmell(filePath: string, line: number): Promise {
+ try {
+ const document = await vscode.workspace.openTextDocument(filePath);
+ const editor = await vscode.window.showTextDocument(document);
+
+ // Move cursor to the specified line
+ const position = new vscode.Position(line, 0);
+ editor.selection = new vscode.Selection(position, position);
+
+ const range = new vscode.Range(position, position);
+ editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
+
+ const flashDecorationType = vscode.window.createTextEditorDecorationType({
+ backgroundColor: new vscode.ThemeColor('editor.wordHighlightBackground'),
+ isWholeLine: true,
+ });
+
+ editor.setDecorations(flashDecorationType, [range]);
+
+ setTimeout(() => {
+ editor.setDecorations(flashDecorationType, []);
+ }, 500);
+ } catch (error: any) {
+ vscode.window.showErrorMessage(
+ `Failed to jump to smell in ${filePath}: ${error.message}`,
+ );
+ }
+}
diff --git a/src/commands/views/openFile.ts b/src/commands/views/openFile.ts
new file mode 100644
index 0000000..47687bf
--- /dev/null
+++ b/src/commands/views/openFile.ts
@@ -0,0 +1,21 @@
+import * as vscode from 'vscode';
+
+/**
+ * Opens a file in the VS Code editor.
+ * Ensures the file is fully opened (not in preview mode).
+ * Displays an error message if no file is selected.
+ *
+ * @param fileUri - The URI of the file to be opened.
+ */
+export async function openFile(fileUri: vscode.Uri) {
+ if (!fileUri) {
+ vscode.window.showErrorMessage('Error: No file selected.');
+ return;
+ }
+
+ await vscode.window.showTextDocument(fileUri, {
+ preview: false, // Ensures the file opens as a permanent tab (not in preview mode)
+ viewColumn: vscode.ViewColumn.Active, // Opens in the active editor column
+ preserveFocus: false, // Focuses the file when opened
+ });
+}
diff --git a/src/commands/wipeWorkCache.ts b/src/commands/wipeWorkCache.ts
deleted file mode 100644
index 345a723..0000000
--- a/src/commands/wipeWorkCache.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as vscode from 'vscode';
-import { ContextManager } from '../context/contextManager';
-import { envConfig } from '../utils/envConfig';
-import { updateHash } from '../utils/hashDocs';
-
-export async function wipeWorkCache(
- contextManager: ContextManager,
- reason?: string,
-): Promise {
- try {
- console.log('Eco: Wiping workspace cache...');
-
- // Clear stored smells cache
- await contextManager.setWorkspaceData(envConfig.SMELL_MAP_KEY!, {});
-
- if (reason === 'manual') {
- await contextManager.setWorkspaceData(envConfig.FILE_CHANGES_KEY!, {});
- }
-
- // Update file hashes for all open editors
- const visibleEditors = vscode.window.visibleTextEditors;
-
- if (visibleEditors.length === 0) {
- console.log('Eco: No open files to update hash.');
- } else {
- console.log(`Eco: Updating cache for ${visibleEditors.length} visible files.`);
- }
-
- for (const editor of visibleEditors) {
- if (editor.document) {
- await updateHash(contextManager, editor.document);
- }
- }
-
- // ✅ Determine the appropriate message
- let message = 'Eco: Successfully wiped workspace cache! ✅';
- if (reason === 'settings') {
- message =
- 'Eco: Smell detection settings changed. Cache wiped to apply updates. ✅';
- } else if (reason === 'manual') {
- message = 'Eco: Workspace cache manually wiped by user. ✅';
- }
-
- vscode.window.showInformationMessage(message);
- console.log('Eco:', message);
- } catch (error: any) {
- console.error('Eco: Error while wiping workspace cache:', error);
- vscode.window.showErrorMessage(
- `Eco: Failed to wipe workspace cache. See console for details.`,
- );
- }
-}
diff --git a/src/context/SmellsCacheManager.ts b/src/context/SmellsCacheManager.ts
new file mode 100644
index 0000000..4eb69e4
--- /dev/null
+++ b/src/context/SmellsCacheManager.ts
@@ -0,0 +1,194 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import { createHash } from 'crypto';
+import { envConfig } from '../utils/envConfig';
+import { ecoOutput } from '../extension';
+import { normalizePath } from '../utils/normalizePath';
+
+/**
+ * Manages caching of detected smells to avoid redundant backend calls.
+ * Uses workspace storage to persist cache between sessions.
+ * Implements file content hashing for change detection and maintains
+ * a bidirectional mapping between file paths and their content hashes.
+ */
+export class SmellsCacheManager {
+ // Event emitter for cache update notifications
+ private cacheUpdatedEmitter = new vscode.EventEmitter();
+ public readonly onSmellsUpdated = this.cacheUpdatedEmitter.event;
+
+ constructor(private context: vscode.ExtensionContext) {}
+
+ /**
+ * Generates a stable identifier for a smell based on its properties
+ * @param smell - The smell object to generate ID for
+ * @returns Short SHA-256 hash (first 5 chars) of the serialized smell
+ */
+ private generateSmellId(smell: Smell): string {
+ return createHash('sha256')
+ .update(JSON.stringify(smell))
+ .digest('hex')
+ .substring(0, 5);
+ }
+
+ /**
+ * Generates content hash for a file to detect changes
+ * @param filePath - Absolute path to the file
+ * @returns SHA-256 hash of file content
+ */
+ private generateFileHash(filePath: string): string {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ return createHash('sha256').update(content).digest('hex');
+ }
+
+ /**
+ * Stores smells in cache for specified file
+ * @param filePath - File path to associate with smells
+ * @param smells - Array of smell objects to cache
+ */
+ public async setCachedSmells(filePath: string, smells: Smell[]): Promise {
+ const cache = this.getFullSmellCache();
+ const pathMap = this.getHashToPathMap();
+
+ const normalizedPath = normalizePath(filePath);
+ const fileHash = this.generateFileHash(normalizedPath);
+
+ // Augment smells with stable identifiers
+ const smellsWithIds = smells.map((smell) => ({
+ ...smell,
+ id: this.generateSmellId(smell),
+ }));
+
+ cache[fileHash] = smellsWithIds;
+ pathMap[fileHash] = normalizedPath;
+
+ await this.context.workspaceState.update(envConfig.SMELL_CACHE_KEY!, cache);
+ await this.context.workspaceState.update(envConfig.HASH_PATH_MAP_KEY!, pathMap);
+
+ this.cacheUpdatedEmitter.fire(filePath);
+ }
+
+ /**
+ * Retrieves cached smells for a file
+ * @param filePath - File path to look up in cache
+ * @returns Array of smells or undefined if not found
+ */
+ public getCachedSmells(filePath: string): Smell[] | undefined {
+ const normalizedPath = normalizePath(filePath);
+ const fileHash = this.generateFileHash(normalizedPath);
+ const cache = this.getFullSmellCache();
+ return cache[fileHash];
+ }
+
+ /**
+ * Checks if smells exist in cache for a file
+ * @param filePath - File path to check
+ * @returns True if file has cached smells
+ */
+ public hasCachedSmells(filePath: string): boolean {
+ const normalizedPath = normalizePath(filePath);
+ const fileHash = this.generateFileHash(normalizedPath);
+ const cache = this.getFullSmellCache();
+ return cache[fileHash] !== undefined;
+ }
+
+ /**
+ * Clears cache for a file by its current content hash
+ * @param filePath - File path to clear from cache
+ */
+ public async clearCachedSmellsForFile(filePath: string): Promise {
+ const normalizedPath = normalizePath(filePath);
+ const fileHash = this.generateFileHash(normalizedPath);
+ const cache = this.getFullSmellCache();
+ const pathMap = this.getHashToPathMap();
+
+ delete cache[fileHash];
+ delete pathMap[fileHash];
+
+ await this.context.workspaceState.update(envConfig.SMELL_CACHE_KEY!, cache);
+ await this.context.workspaceState.update(envConfig.HASH_PATH_MAP_KEY!, pathMap);
+
+ this.cacheUpdatedEmitter.fire(normalizedPath);
+ }
+
+ /**
+ * Clears cache for a file by path (regardless of current content hash)
+ * @param filePath - File path to clear from cache
+ */
+ public async clearCachedSmellsByPath(filePath: string): Promise {
+ const pathMap = this.getHashToPathMap();
+ const normalizedPath = normalizePath(filePath);
+ const hash = Object.keys(pathMap).find((h) => pathMap[h] === normalizedPath);
+ if (!hash) return;
+
+ const cache = this.getFullSmellCache();
+ delete cache[hash];
+ delete pathMap[hash];
+
+ await this.context.workspaceState.update(envConfig.SMELL_CACHE_KEY!, cache);
+ await this.context.workspaceState.update(envConfig.HASH_PATH_MAP_KEY!, pathMap);
+
+ this.cacheUpdatedEmitter.fire(normalizedPath);
+ }
+
+ /**
+ * Retrieves complete smell cache
+ * @returns Object mapping file hashes to smell arrays
+ */
+ public getFullSmellCache(): Record {
+ return this.context.workspaceState.get>(
+ envConfig.SMELL_CACHE_KEY!,
+ {},
+ );
+ }
+
+ /**
+ * Retrieves hash-to-path mapping
+ * @returns Object mapping file hashes to original paths
+ */
+ public getHashToPathMap(): Record {
+ return this.context.workspaceState.get>(
+ envConfig.HASH_PATH_MAP_KEY!,
+ {},
+ );
+ }
+
+ /**
+ * Clears entire smell cache
+ */
+ public async clearAllCachedSmells(): Promise {
+ await this.context.workspaceState.update(envConfig.SMELL_CACHE_KEY!, {});
+ await this.context.workspaceState.update(envConfig.HASH_PATH_MAP_KEY!, {});
+
+ this.cacheUpdatedEmitter.fire('all');
+ }
+
+ /**
+ * Retrieves all file paths currently in cache
+ * @returns Array of cached file paths
+ */
+ public getAllFilePaths(): string[] {
+ const map = this.context.workspaceState.get>(
+ envConfig.HASH_PATH_MAP_KEY!,
+ {},
+ );
+ return Object.values(map);
+ }
+
+ /**
+ * Checks if a file has any cache entries (current or historical)
+ * @param filePath - File path to check
+ * @returns True if file exists in cache metadata
+ */
+ public hasFileInCache(filePath: string): boolean {
+ const pathMap = this.getHashToPathMap();
+ const normalizedPath = normalizePath(filePath);
+ const fileExistsInCache = Object.values(pathMap).includes(normalizedPath);
+
+ ecoOutput.debug(
+ `[SmellCacheManager] Path existence check for ${normalizedPath}: ` +
+ `${fileExistsInCache ? 'EXISTS' : 'NOT FOUND'} in cache`,
+ );
+
+ return fileExistsInCache;
+ }
+}
diff --git a/src/context/configManager.ts b/src/context/configManager.ts
new file mode 100644
index 0000000..bd3a216
--- /dev/null
+++ b/src/context/configManager.ts
@@ -0,0 +1,39 @@
+import * as vscode from 'vscode';
+
+export class ConfigManager {
+ private static readonly CONFIG_SECTION = 'ecooptimizer.detection';
+
+ /**
+ * Get a specific configuration value.
+ * @param key The key of the configuration property.
+ * @param _default The default value to return if the configuration property is not found.
+ * @returns The value of the configuration property.
+ */
+ public static get(key: string, _default: any = undefined): T {
+ const config = vscode.workspace.getConfiguration(this.CONFIG_SECTION);
+ return config.get(key, _default);
+ }
+
+ /**
+ * Update a specific configuration value.
+ * @param key The key of the configuration property.
+ * @param value The new value to set.
+ * @param global Whether to update the global configuration or workspace configuration.
+ */
+ public static async update(
+ key: string,
+ value: T,
+ global: boolean = false,
+ ): Promise {
+ const config = vscode.workspace.getConfiguration(this.CONFIG_SECTION);
+ await config.update(key, value, global);
+ }
+
+ /**
+ * Get all configuration values under the ecooptimizer.detection section.
+ * @returns The entire configuration object.
+ */
+ public static getAll(): vscode.WorkspaceConfiguration {
+ return vscode.workspace.getConfiguration(this.CONFIG_SECTION);
+ }
+}
diff --git a/src/context/contextManager.ts b/src/context/contextManager.ts
deleted file mode 100644
index 896255c..0000000
--- a/src/context/contextManager.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as vscode from 'vscode';
-
-export class ContextManager {
- public context: vscode.ExtensionContext;
-
- constructor(context: vscode.ExtensionContext) {
- this.context = context;
- }
-
- // Global state example
- public getGlobalData(
- key: string,
- defaultVal: any = undefined,
- ): T | undefined {
- return this.context.globalState.get(key, defaultVal);
- }
-
- public setGlobalData(key: string, value: any): Thenable {
- return this.context.globalState.update(key, value);
- }
-
- // Workspace state example
- public getWorkspaceData(
- key: string,
- defaultVal: any = undefined,
- ): T | undefined {
- return this.context.workspaceState.get(key, defaultVal);
- }
-
- public setWorkspaceData(key: string, value: any): Thenable {
- return this.context.workspaceState.update(key, value);
- }
-}
diff --git a/src/emitters/serverStatus.ts b/src/emitters/serverStatus.ts
new file mode 100644
index 0000000..60bd515
--- /dev/null
+++ b/src/emitters/serverStatus.ts
@@ -0,0 +1,71 @@
+import * as vscode from 'vscode';
+import { EventEmitter } from 'events';
+import { ecoOutput } from '../extension';
+
+/**
+ * Represents possible server connection states
+ */
+export enum ServerStatusType {
+ UNKNOWN = 'unknown', // Initial state before first connection attempt
+ UP = 'up', // Server is available and responsive
+ DOWN = 'down', // Server is unreachable or unresponsive
+}
+
+/**
+ * Tracks and manages backend server connection state with:
+ * - Status change detection
+ * - Appropriate user notifications
+ * - Event emission for dependent components
+ */
+class ServerStatus extends EventEmitter {
+ private status: ServerStatusType = ServerStatusType.UNKNOWN;
+
+ /**
+ * Gets current server connection state
+ * @returns Current ServerStatusType
+ */
+ getStatus(): ServerStatusType {
+ return this.status;
+ }
+
+ /**
+ * Updates server status with change detection and notifications
+ * @param newStatus - Either UP or DOWN status
+ */
+ setStatus(newStatus: ServerStatusType.UP | ServerStatusType.DOWN): void {
+ if (this.status !== newStatus) {
+ const previousStatus = this.status;
+ this.status = newStatus;
+
+ // Log status transition
+ ecoOutput.trace(
+ `[serverStatus.ts] Server status changed from ${previousStatus} to ${newStatus}`,
+ );
+
+ // Handle status-specific notifications
+ if (newStatus === ServerStatusType.UP) {
+ if (previousStatus !== ServerStatusType.UNKNOWN) {
+ ecoOutput.info('[serverStatus.ts] Server connection re-established');
+ vscode.window.showInformationMessage(
+ 'Backend server reconnected - full functionality restored',
+ { modal: false },
+ );
+ }
+ } else {
+ ecoOutput.info('[serverStatus.ts] Server connection lost');
+ vscode.window.showWarningMessage(
+ 'Backend server unavailable - limited functionality',
+ { modal: false },
+ );
+ }
+
+ // Notify subscribers
+ this.emit('change', newStatus);
+ }
+ }
+}
+
+/**
+ * Singleton instance providing global server status management
+ */
+export const serverStatus = new ServerStatus();
diff --git a/src/extension.ts b/src/extension.ts
index 10fc13a..a04e98a 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,306 +1,687 @@
-import { envConfig } from './utils/envConfig';
import * as vscode from 'vscode';
+import path from 'path';
+
+// let port: number;
+
+// export function getApiPort(): number {
+// return port;
+// }
+
+// === Output Channel ===
+export const ecoOutput = vscode.window.createOutputChannel('Eco-Optimizer', {
+ log: true,
+});
+
+// === Smell Linting ===
+let smellLintingEnabled = false;
-import { detectSmells } from './commands/detectSmells';
+export function isSmellLintingEnabled(): boolean {
+ return smellLintingEnabled;
+}
+
+// === In-Built ===
+import { existsSync, promises } from 'fs';
+
+// === Core Utilities ===
+import { envConfig } from './utils/envConfig';
+import { getNameByMessageId, loadSmells } from './utils/smellsData';
+import { initializeStatusesFromCache } from './utils/initializeStatusesFromCache';
+import { checkServerStatus } from './api/backend';
+
+// === Context & View Providers ===
+import { SmellsCacheManager } from './context/SmellsCacheManager';
import {
- refactorSelectedSmell,
- refactorAllSmellsOfType,
-} from './commands/refactorSmell';
-import { wipeWorkCache } from './commands/wipeWorkCache';
-import { stopWatchingLogs } from './commands/showLogs';
-import { ContextManager } from './context/contextManager';
+ SmellsViewProvider,
+ SmellTreeItem,
+ TreeItem,
+} from './providers/SmellsViewProvider';
+import { MetricsViewProvider } from './providers/MetricsViewProvider';
+import { FilterViewProvider } from './providers/FilterViewProvider';
+import { RefactoringDetailsViewProvider } from './providers/RefactoringDetailsViewProvider';
+
+// === Commands ===
+import { configureWorkspace } from './commands/configureWorkspace';
+import { resetConfiguration } from './commands/resetConfiguration';
import {
- getEnabledSmells,
- handleSmellFilterUpdate,
-} from './utils/handleSmellSettings';
-import { updateHash } from './utils/hashDocs';
-import { RefactorSidebarProvider } from './ui/refactorView';
-import { handleEditorChanges } from './utils/handleEditorChange';
+ detectSmellsFile,
+ detectSmellsFolder,
+} from './commands/detection/detectSmells';
+import { registerFilterSmellCommands } from './commands/views/filterSmells';
+import { jumpToSmell } from './commands/views/jumpToSmell';
+import { wipeWorkCache } from './commands/detection/wipeWorkCache';
+import { refactor, startRefactorSession } from './commands/refactor/refactor';
+import { acceptRefactoring } from './commands/refactor/acceptRefactoring';
+import { rejectRefactoring } from './commands/refactor/rejectRefactoring';
+import { exportMetricsData } from './commands/views/exportMetricsData';
+
+// === Listeners & UI ===
+import { WorkspaceModifiedListener } from './listeners/workspaceModifiedListener';
+import { FileHighlighter } from './ui/fileHighlighter';
import { LineSelectionManager } from './ui/lineSelectionManager';
-import { checkServerStatus } from './api/backend';
-import { serverStatus } from './utils/serverStatus';
-
-import { toggleSmellLinting } from './commands/toggleSmellLinting';
+import { HoverManager } from './ui/hoverManager';
+import {
+ closeAllTrackedDiffEditors,
+ registerDiffEditor,
+} from './utils/trackedDiffEditors';
+import { initializeRefactorActionButtons } from './utils/refactorActionButtons';
+import { LogManager } from './commands/showLogs';
-export const globalData: { contextManager?: ContextManager } = {
- contextManager: undefined,
-};
+// === Backend Server ===
+// import { ServerProcess } from './lib/processManager';
+// import { DependencyManager } from './lib/dependencyManager';
-export function activate(context: vscode.ExtensionContext): void {
- console.log('Eco: Refactor Plugin Activated Successfully');
- const contextManager = new ContextManager(context);
+let backendLogManager: LogManager;
+// let server: ServerProcess;
- globalData.contextManager = contextManager;
+export async function activate(context: vscode.ExtensionContext): Promise {
+ ecoOutput.info('Initializing Eco-Optimizer extension...');
+ console.log('Initializing Eco-Optimizer extension...');
- // Show the settings popup if needed
- // TODO: Setting to re-enable popup if disabled
- const settingsPopupChoice =
- contextManager.getGlobalData('showSettingsPopup');
+ // === Install and Run Backend Server ====
+ // if (!(await DependencyManager.ensureDependencies(context))) {
+ // vscode.window.showErrorMessage(
+ // 'Cannot run the extension without the ecooptimizer server. Deactivating extension.',
+ // );
+ // }
- if (settingsPopupChoice === undefined || settingsPopupChoice) {
- showSettingsPopup();
- }
+ // server = new ServerProcess(context);
+ // try {
+ // port = await server.start();
- console.log('environment variables:', envConfig);
+ // console.log(`Server started on port ${port}`);
+ // } catch (error) {
+ // vscode.window.showErrorMessage(`Failed to start server: ${error}`);
+ // }
- checkServerStatus();
+ backendLogManager = new LogManager(context);
- let smellsData = contextManager.getWorkspaceData(envConfig.SMELL_MAP_KEY!) || {};
- contextManager.setWorkspaceData(envConfig.SMELL_MAP_KEY!, smellsData);
+ // === Load Core Data ===
+ loadSmells();
- let fileHashes =
- contextManager.getWorkspaceData(envConfig.FILE_CHANGES_KEY!) || {};
- contextManager.setWorkspaceData(envConfig.FILE_CHANGES_KEY!, fileHashes);
-
- // Check server health every 10 seconds
+ // === Start periodic backend status checks ===
+ checkServerStatus();
setInterval(checkServerStatus, 10000);
- // ===============================================================
- // REGISTER COMMANDS
- // ===============================================================
+ // === Initialize Refactor Action Buttons ===
+ initializeRefactorActionButtons(context);
+
+ // === Initialize Managers & Providers ===
+ const smellsCacheManager = new SmellsCacheManager(context);
+ const smellsViewProvider = new SmellsViewProvider(context);
+ const metricsViewProvider = new MetricsViewProvider(context);
+ const filterSmellsProvider = new FilterViewProvider(
+ context,
+ metricsViewProvider,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+ const refactoringDetailsViewProvider = new RefactoringDetailsViewProvider();
+
+ initializeStatusesFromCache(context, smellsCacheManager, smellsViewProvider);
- // Detect Smells Command
+ // === Register Tree Views ===
context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.detectSmells', async () => {
- console.log('Eco: Detect Smells Command Triggered');
- detectSmells(contextManager);
+ vscode.window.createTreeView('ecooptimizer.smellsView', {
+ treeDataProvider: smellsViewProvider,
+ }),
+ vscode.window.createTreeView('ecooptimizer.metricsView', {
+ treeDataProvider: metricsViewProvider,
+ showCollapseAll: true,
+ }),
+ vscode.window.createTreeView('ecooptimizer.filterView', {
+ treeDataProvider: filterSmellsProvider,
+ showCollapseAll: true,
+ }),
+ vscode.window.createTreeView('ecooptimizer.refactorView', {
+ treeDataProvider: refactoringDetailsViewProvider,
}),
);
- // Refactor Selected Smell Command
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.refactorSmell', () => {
- if (serverStatus.getStatus() === 'up') {
- console.log('Eco: Refactor Selected Smell Command Triggered');
- refactorSelectedSmell(contextManager);
- } else {
- vscode.window.showWarningMessage('Action blocked: Server is down.');
- }
+ filterSmellsProvider.setTreeView(
+ vscode.window.createTreeView('ecooptimizer.filterView', {
+ treeDataProvider: filterSmellsProvider,
+ showCollapseAll: true,
}),
);
- // Refactor All Smells of Type Command
+ const workspaceConfigured = context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+ vscode.commands.executeCommand(
+ 'setContext',
+ 'workspaceState.workspaceConfigured',
+ Boolean(workspaceConfigured),
+ );
+
+ // === Register Commands ===
context.subscriptions.push(
+ // vscode.commands.registerCommand('ecooptimizer.startServer', async () => {
+ // port = await server.start();
+ // }),
+ // vscode.commands.registerCommand('ecooptimizer.stopServer', async () => {
+ // server.dispose();
+ // }),
+ vscode.commands.registerCommand('ecooptimizer.configureWorkspace', async () => {
+ await configureWorkspace(context);
+ smellsViewProvider.refresh();
+ metricsViewProvider.refresh();
+ }),
+
+ vscode.commands.registerCommand('ecooptimizer.resetConfiguration', async () => {
+ const didReset = await resetConfiguration(context);
+ if (didReset) {
+ smellsCacheManager.clearAllCachedSmells();
+ smellsViewProvider.clearAllStatuses();
+ smellsViewProvider.refresh();
+ metricsViewProvider.refresh();
+ vscode.window.showInformationMessage(
+ 'Workspace configuration and analysis data have been reset.',
+ );
+ }
+ }),
+
+ vscode.commands.registerCommand('ecooptimizer.jumpToSmell', jumpToSmell),
+
+ vscode.commands.registerCommand('ecooptimizer.wipeWorkCache', async () => {
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
+ }),
+
vscode.commands.registerCommand(
- 'ecooptimizer.refactorAllSmellsOfType',
- async (smellId: string) => {
- if (serverStatus.getStatus() === 'up') {
- console.log(
- `Eco: Refactor All Smells of Type Command Triggered for ${smellId}`,
+ 'ecooptimizer.detectSmellsFile',
+ async (fileItem: TreeItem) => {
+ let filePath: string;
+ if (!fileItem) {
+ const allPythonFiles: vscode.QuickPickItem[] = [];
+ const folderPath = workspaceConfigured;
+
+ if (!folderPath) {
+ vscode.window.showWarningMessage('No workspace configured.');
+ return;
+ }
+
+ const gatherPythonFiles = async (dirPath: string): Promise => {
+ const files = await vscode.workspace.fs.readDirectory(
+ vscode.Uri.file(dirPath),
+ );
+ for (const [name, type] of files) {
+ const fullPath = path.join(dirPath, name);
+ if (type === vscode.FileType.File && name.endsWith('.py')) {
+ const relativePath = path.relative(folderPath, fullPath);
+ allPythonFiles.push({
+ label: `${name}`,
+ description: `${path.dirname(relativePath) === '.' ? undefined : path.dirname(relativePath)}`,
+ iconPath: new vscode.ThemeIcon('symbol-file'),
+ });
+ } else if (type === vscode.FileType.Directory) {
+ await gatherPythonFiles(fullPath); // Recursively gather Python files in subdirectories
+ }
+ }
+ };
+
+ const currentFile = vscode.window.activeTextEditor?.document.fileName;
+ if (currentFile && currentFile.endsWith('.py')) {
+ const relativePath = path.relative(folderPath, currentFile);
+ allPythonFiles.push({
+ label: `${path.basename(currentFile)}`,
+ description: `${path.dirname(relativePath) === '.' ? undefined : path.dirname(relativePath)}`,
+ detail: 'Current File',
+ iconPath: new vscode.ThemeIcon('symbol-file'),
+ });
+
+ allPythonFiles.push({
+ label: '───────────────',
+ kind: vscode.QuickPickItemKind.Separator,
+ });
+ }
+
+ await gatherPythonFiles(folderPath);
+
+ if (allPythonFiles.length === 0) {
+ vscode.window.showWarningMessage(
+ 'No Python files found in the workspace.',
+ );
+ return;
+ }
+
+ const selectedFile = await vscode.window.showQuickPick(allPythonFiles, {
+ title: 'Select a Python file to analyze',
+ placeHolder: 'Choose a Python file from the workspace',
+ canPickMany: false,
+ });
+
+ if (!selectedFile) {
+ vscode.window.showWarningMessage('No file selected.');
+ return;
+ }
+
+ filePath = path.join(
+ folderPath,
+ selectedFile.description!,
+ selectedFile.label,
);
- refactorAllSmellsOfType(contextManager, smellId);
} else {
- vscode.window.showWarningMessage('Action blocked: Server is down.');
+ if (!(fileItem instanceof vscode.TreeItem)) {
+ vscode.window.showWarningMessage('Invalid file item selected.');
+ return;
+ }
+ filePath = fileItem.resourceUri!.fsPath;
+ if (!filePath) {
+ vscode.window.showWarningMessage('Please select a file to analyze.');
+ return;
+ }
}
+ detectSmellsFile(filePath, smellsViewProvider, smellsCacheManager);
},
),
- );
- // Wipe Cache Command
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.wipeWorkCache', async () => {
- console.log('Eco: Wipe Work Cache Command Triggered');
- vscode.window.showInformationMessage(
- 'Eco: Manually wiping workspace memory... ✅',
- );
- await wipeWorkCache(contextManager, 'manual');
- }),
- );
+ vscode.commands.registerCommand(
+ 'ecooptimizer.detectSmellsFolder',
+ async (folderItem: vscode.TreeItem) => {
+ let folderPath: string;
+ if (!folderItem) {
+ if (!workspaceConfigured) {
+ vscode.window.showWarningMessage('No workspace configured.');
+ return;
+ }
- // screen button go brr
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.toggleSmellLinting', () => {
- console.log('Eco: Toggle Smell Linting Command Triggered');
- toggleSmellLinting(contextManager);
- }),
- );
+ const allDirectories: vscode.QuickPickItem[] = [];
+ const directoriesWithPythonFiles = new Set();
+
+ const gatherDirectories = async (
+ dirPath: string,
+ relativePath = '',
+ ): Promise => {
+ const files = await vscode.workspace.fs.readDirectory(
+ vscode.Uri.file(dirPath),
+ );
+ let hasPythonFile = false;
+
+ for (const [name, type] of files) {
+ const fullPath = path.join(dirPath, name);
+ const newRelativePath = path.join(relativePath, name);
+ if (type === vscode.FileType.File && name.endsWith('.py')) {
+ hasPythonFile = true;
+ } else if (type === vscode.FileType.Directory) {
+ const subDirHasPythonFile = await gatherDirectories(
+ fullPath,
+ newRelativePath,
+ );
+ if (subDirHasPythonFile) {
+ hasPythonFile = true;
+ }
+ }
+ }
+
+ if (hasPythonFile) {
+ directoriesWithPythonFiles.add(dirPath);
+ const isDirectChild = relativePath.split(path.sep).length === 1;
+ allDirectories.push({
+ label: `${path.basename(dirPath)}`,
+ description: isDirectChild ? undefined : path.dirname(relativePath),
+ iconPath: new vscode.ThemeIcon('folder'),
+ });
+ }
+
+ return hasPythonFile;
+ };
+
+ await gatherDirectories(workspaceConfigured);
+
+ if (allDirectories.length === 0) {
+ vscode.window.showWarningMessage(
+ 'No directories with Python files found in the workspace.',
+ );
+ return;
+ }
- // ===============================================================
- // REGISTER VIEWS
- // ===============================================================
+ const selectedDirectory = await vscode.window.showQuickPick(
+ allDirectories,
+ {
+ title: 'Select a directory to analyze',
+ placeHolder: 'Choose a directory with Python files from the workspace',
+ canPickMany: false,
+ },
+ );
- const refactorProvider = new RefactorSidebarProvider(context);
- context.subscriptions.push(
- vscode.window.registerWebviewViewProvider(
- RefactorSidebarProvider.viewType,
- refactorProvider,
+ if (!selectedDirectory) {
+ vscode.window.showWarningMessage('No directory selected.');
+ return;
+ }
+
+ folderPath = path.join(
+ workspaceConfigured,
+ selectedDirectory.description
+ ? path.join(
+ selectedDirectory.description,
+ path.basename(selectedDirectory.label),
+ )
+ : path.basename(selectedDirectory.label),
+ );
+ } else {
+ if (!(folderItem instanceof vscode.TreeItem)) {
+ vscode.window.showWarningMessage('Invalid folder item selected.');
+ return;
+ }
+ folderPath = folderItem.resourceUri!.fsPath;
+ }
+ detectSmellsFolder(folderPath, smellsViewProvider, smellsCacheManager);
+ },
),
- );
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.showRefactorSidebar', () =>
- refactorProvider.updateView(),
+ vscode.commands.registerCommand(
+ 'ecooptimizer.refactorSmell',
+ (item: SmellTreeItem | Smell) => {
+ let smell: Smell;
+ if (item instanceof SmellTreeItem) {
+ smell = item.smell;
+ } else {
+ smell = item;
+ }
+ if (!smell) {
+ vscode.window.showErrorMessage('No code smell detected for this item.');
+ return;
+ }
+ refactor(smellsViewProvider, refactoringDetailsViewProvider, smell, context);
+ },
),
- );
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.pauseRefactorSidebar', () =>
- refactorProvider.pauseView(),
+ vscode.commands.registerCommand(
+ 'ecooptimizer.refactorAllSmellsOfType',
+ async (item: TreeItem | { fullPath: string; smellType: string }) => {
+ let filePath = item.fullPath;
+ if (!filePath) {
+ vscode.window.showWarningMessage(
+ 'Unable to get file path for smell refactoring.',
+ );
+ return;
+ }
+
+ const cachedSmells = smellsCacheManager.getCachedSmells(filePath);
+ if (!cachedSmells || cachedSmells.length === 0) {
+ vscode.window.showInformationMessage('No smells detected in this file.');
+ return;
+ }
+
+ ecoOutput.info(`🟡 Found ${cachedSmells.length} smells in ${filePath}`);
+
+ const uniqueMessageIds = new Set();
+ for (const smell of cachedSmells) {
+ uniqueMessageIds.add(smell.messageId);
+ }
+
+ let selectedSmell: string;
+ if (item instanceof TreeItem) {
+ const quickPickItems: vscode.QuickPickItem[] = Array.from(
+ uniqueMessageIds,
+ ).map((id) => {
+ const name = getNameByMessageId(id) ?? id;
+ return {
+ label: name,
+ description: id,
+ };
+ });
+
+ const selected = await vscode.window.showQuickPick(quickPickItems, {
+ title: 'Select a smell type to refactor',
+ placeHolder: 'Choose the type of smell you want to refactor',
+ matchOnDescription: false,
+ matchOnDetail: false,
+ ignoreFocusOut: false,
+ canPickMany: false,
+ });
+
+ if (!selected) {
+ return;
+ }
+ selectedSmell = selected.description!;
+ } else {
+ selectedSmell = item.smellType;
+ }
+
+ const firstSmell = cachedSmells.find(
+ (smell) => smell.messageId === selectedSmell,
+ );
+
+ if (!firstSmell) {
+ vscode.window.showWarningMessage('No smells found for the selected type.');
+ return;
+ }
+
+ ecoOutput.info(
+ `🔁 Triggering refactorAllSmellsOfType for: ${selectedSmell}`,
+ );
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ firstSmell,
+ context,
+ true, // isRefactorAllOfType
+ );
+ },
),
- );
- context.subscriptions.push(
- vscode.commands.registerCommand('ecooptimizer.clearRefactorSidebar', () =>
- refactorProvider.clearView(),
+ vscode.commands.registerCommand('ecooptimizer.acceptRefactoring', async () => {
+ await acceptRefactoring(
+ context,
+ refactoringDetailsViewProvider,
+ metricsViewProvider,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+ }),
+
+ vscode.commands.registerCommand('ecooptimizer.rejectRefactoring', async () => {
+ await rejectRefactoring(
+ context,
+ refactoringDetailsViewProvider,
+ smellsViewProvider,
+ );
+ }),
+
+ vscode.commands.registerCommand(
+ 'ecooptimizer.openDiffEditor',
+ (originalFilePath: string, refactoredFilePath: string) => {
+ // Get the file name for the diff editor title
+ const fileName = path.basename(originalFilePath);
+
+ // Show the diff editor with the updated title
+ const originalUri = vscode.Uri.file(originalFilePath);
+ const refactoredUri = vscode.Uri.file(refactoredFilePath);
+ vscode.commands.executeCommand(
+ 'vscode.diff',
+ originalUri,
+ refactoredUri,
+ `Refactoring Comparison (${fileName})`,
+ {
+ preview: false,
+ },
+ );
+
+ registerDiffEditor(originalUri, refactoredUri);
+ },
),
- );
- // ===============================================================
- // ADD LISTENERS
- // ===============================================================
+ vscode.commands.registerCommand('ecooptimizer.exportMetricsData', () => {
+ exportMetricsData(context);
+ }),
- // Register a listener for configuration changes
- context.subscriptions.push(
- vscode.workspace.onDidChangeConfiguration((event) => {
- handleConfigurationChange(event);
+ vscode.commands.registerCommand('ecooptimizer.metricsView.refresh', () => {
+ metricsViewProvider.refresh();
+ }),
+
+ vscode.commands.registerCommand('ecooptimizer.clearMetricsData', () => {
+ vscode.window
+ .showWarningMessage(
+ 'Clear all metrics data? This cannot be undone unless you have exported it.',
+ { modal: true },
+ 'Clear',
+ 'Cancel',
+ )
+ .then((selection) => {
+ if (selection === 'Clear') {
+ context.workspaceState.update(
+ envConfig.WORKSPACE_METRICS_DATA!,
+ undefined,
+ );
+ metricsViewProvider.refresh();
+ vscode.window.showInformationMessage('Metrics data cleared.');
+ }
+ });
}),
);
- vscode.window.onDidChangeVisibleTextEditors(async (editors) => {
- handleEditorChanges(contextManager, editors);
- });
+ // === Register Filter UI Commands ===
+ registerFilterSmellCommands(context, filterSmellsProvider);
- // Adds comments to lines describing the smell
- const lineSelectManager = new LineSelectionManager(contextManager);
+ // === Workspace File Listener ===
context.subscriptions.push(
- vscode.window.onDidChangeTextEditorSelection((event) => {
- console.log('Eco: Detected line selection event');
- lineSelectManager.commentLine(event.textEditor);
- }),
+ new WorkspaceModifiedListener(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ metricsViewProvider,
+ ),
);
- // Updates directory of file states (for checking if modified)
+ // === File Highlighting ===
+ const fileHighlighter = FileHighlighter.getInstance(smellsCacheManager);
+
+ fileHighlighter.updateHighlightsForVisibleEditors();
+
+ // === Line Selection ===
+ const lineSelectManager = new LineSelectionManager(smellsCacheManager);
context.subscriptions.push(
- vscode.workspace.onDidSaveTextDocument(async (document) => {
- console.log('Eco: Detected document saved event');
- await updateHash(contextManager, document);
+ vscode.window.onDidChangeTextEditorSelection((event) => {
+ const textEditor = event.textEditor;
+ if (!textEditor.document.fileName.endsWith('.py')) {
+ return;
+ }
+ lineSelectManager.commentLine(textEditor);
}),
);
- // Handles case of documents already being open on VS Code open
- vscode.window.visibleTextEditors.forEach(async (editor) => {
- if (editor.document) {
- await updateHash(contextManager, editor.document);
+ // == Hover Manager ===
+ const hoverManager = new HoverManager(smellsCacheManager);
+ hoverManager.register(context);
+
+ // === Smell Linting ===
+ const updateSmellLintingContext = (): void => {
+ vscode.commands.executeCommand(
+ 'setContext',
+ 'ecooptimizer.smellLintingEnabled',
+ smellLintingEnabled,
+ );
+ };
+
+ const lintActiveEditors = (): void => {
+ for (const editor of vscode.window.visibleTextEditors) {
+ const filePath = editor.document.uri.fsPath;
+ detectSmellsFile(filePath, smellsViewProvider, smellsCacheManager);
+ ecoOutput.info(
+ `[WorkspaceListener] Smell linting is ON — auto-detecting smells for ${filePath}`,
+ );
}
- });
+ };
+
+ const toggleSmellLinting = (): void => {
+ smellLintingEnabled = !smellLintingEnabled;
+ updateSmellLintingContext();
+ const msg = smellLintingEnabled
+ ? 'Smell linting enabled'
+ : 'Smell linting disabled';
+ lintActiveEditors();
+ vscode.window.showInformationMessage(msg);
+ };
- // Initializes first state of document when opened while extension is active
context.subscriptions.push(
- vscode.workspace.onDidOpenTextDocument(async (document) => {
- console.log('Eco: Detected document opened event');
- await updateHash(contextManager, document);
- }),
+ vscode.commands.registerCommand(
+ 'ecooptimizer.toggleSmellLintingOn',
+ toggleSmellLinting,
+ ),
+ vscode.commands.registerCommand(
+ 'ecooptimizer.toggleSmellLintingOff',
+ toggleSmellLinting,
+ ),
);
- // Listen for file save events
+ // === File View Change Listner ===
context.subscriptions.push(
- vscode.workspace.onDidSaveTextDocument(async (document) => {
- console.log('Eco: Detected document saved event');
+ vscode.window.onDidChangeVisibleTextEditors(() => {
+ fileHighlighter.updateHighlightsForVisibleEditors();
- // Check if smell linting is enabled
- const isEnabled = contextManager.getWorkspaceData(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- false,
- );
- if (isEnabled) {
- console.log('Eco: Smell linting is enabled. Detecting smells...');
- await detectSmells(contextManager);
+ if (smellLintingEnabled) {
+ lintActiveEditors();
}
}),
);
- // Listen for editor changes
- context.subscriptions.push(
- vscode.window.onDidChangeActiveTextEditor(async (editor) => {
- if (editor) {
- console.log('Eco: Detected editor change event');
-
- // Check if the file is a Python file
- if (editor.document.languageId === 'python') {
- console.log('Eco: Active file is a Python file.');
-
- // Check if smell linting is enabled
- const isEnabled = contextManager.getWorkspaceData(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- false,
+ const cleanPastSessionArtifacts = async (): Promise => {
+ const pastData = context.workspaceState.get(
+ envConfig.UNFINISHED_REFACTORING!,
+ );
+
+ if (pastData) {
+ const tempDir = pastData.refactoredData.tempDir;
+
+ try {
+ const tempDirExists = existsSync(tempDir);
+
+ if (tempDirExists) {
+ const userChoice = await vscode.window.showWarningMessage(
+ 'A previous refactoring session was detected. Would you like to continue or discard it?',
+ { modal: true },
+ 'Continue',
+ 'Discard',
);
- if (isEnabled) {
- console.log('Eco: Smell linting is enabled. Detecting smells...');
- await detectSmells(contextManager);
+
+ if (userChoice === 'Discard') {
+ await promises.rm(tempDir, { recursive: true, force: true });
+
+ context.workspaceState.update(
+ envConfig.UNFINISHED_REFACTORING!,
+ undefined,
+ );
+
+ closeAllTrackedDiffEditors();
+ } else if (userChoice === 'Continue') {
+ ecoOutput.info('Resuming previous refactoring session...');
+ startRefactorSession(
+ pastData.smell,
+ pastData.refactoredData,
+ refactoringDetailsViewProvider,
+ );
+ return;
}
}
+ } catch (error) {
+ ecoOutput.error(`Error handling past refactoring session: ${error}`);
+ context.workspaceState.update(envConfig.UNFINISHED_REFACTORING!, undefined);
}
- }),
- );
+ }
+ };
- // ===============================================================
- // HANDLE SMELL FILTER CHANGES
- // ===============================================================
+ cleanPastSessionArtifacts();
- let previousSmells = getEnabledSmells();
- vscode.workspace.onDidChangeConfiguration((event) => {
- if (event.affectsConfiguration('ecooptimizer.enableSmells')) {
- console.log('Eco: Smell preferences changed! Wiping cache.');
- handleSmellFilterUpdate(previousSmells, contextManager);
- previousSmells = getEnabledSmells();
- }
- });
-}
+ // if (!port) {
+ // try {
+ // port = await server.start();
-function showSettingsPopup(): void {
- // Check if the required settings are already configured
- const config = vscode.workspace.getConfiguration('ecooptimizer');
- const workspacePath = config.get('projectWorkspacePath', '');
- const logsOutputPath = config.get('logsOutputPath', '');
- const unitTestPath = config.get('unitTestPath', '');
-
- // If settings are not configured, prompt the user to configure them
- if (!workspacePath || !logsOutputPath || !unitTestPath) {
- vscode.window
- .showInformationMessage(
- 'Please configure the paths for your workspace and logs.',
- { modal: true },
- 'Continue', // Button to open settings
- 'Skip', // Button to dismiss
- 'Never show this again',
- )
- .then((selection) => {
- if (selection === 'Continue') {
- // Open the settings page filtered to extension's settings
- vscode.commands.executeCommand(
- 'workbench.action.openSettings',
- 'ecooptimizer',
- );
- } else if (selection === 'Skip') {
- // Inform user they can configure later
- vscode.window.showInformationMessage(
- 'You can configure the paths later in the settings.',
- );
- } else if (selection === 'Never show this again') {
- globalData.contextManager!.setGlobalData('showSettingsPopup', false);
- vscode.window.showInformationMessage(
- 'You can re-enable this popup again in the settings.',
- );
- }
- });
- }
-}
+ // console.log(`Server started on port ${port}`);
+ // } catch (error) {
+ // vscode.window.showErrorMessage(`Failed to start server: ${error}`);
+ // }
+ // }
-function handleConfigurationChange(event: vscode.ConfigurationChangeEvent): void {
- // Check if any relevant setting was changed
- if (
- event.affectsConfiguration('ecooptimizer.projectWorkspacePath') ||
- event.affectsConfiguration('ecooptimizer.unitTestCommand') ||
- event.affectsConfiguration('ecooptimizer.logsOutputPath')
- ) {
- // Display a warning message about changing critical settings
- vscode.window.showWarningMessage(
- 'You have changed a critical setting for the EcoOptimizer plugin. Ensure the new value is valid and correct for optimal functionality.',
- );
- }
+ ecoOutput.info('Eco-Optimizer extension activated successfully');
+ console.log('Eco-Optimizer extension activated successfully');
}
export function deactivate(): void {
- console.log('Eco: Deactivating Plugin - Stopping Log Watching');
- stopWatchingLogs();
+ ecoOutput.info('Extension deactivated');
+ console.log('Extension deactivated');
+
+ // server.dispose();
+ backendLogManager.stopWatchingLogs();
+ ecoOutput.dispose();
}
diff --git a/src/global.d.ts b/src/global.d.ts
index cf5f436..3371cf5 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -1,65 +1,99 @@
export {};
-// global.d.ts
-export {};
-
+/**
+ * Global type declarations for the Eco-Optimizer extension.
+ * These interfaces define the core data structures used throughout the application.
+ */
declare global {
- // Define your global types here
+ /**
+ * Represents a specific location in source code where a smell occurs.
+ * Uses VS Code-style line/column numbering (1-based).
+ */
export interface Occurrence {
+ /** The starting line number (1-based) */
line: number;
+ /** The ending line number (1-based, optional) */
endLine?: number;
+ /** The starting column number (1-based) */
column: number;
+ /** The ending column number (1-based, optional) */
endColumn?: number;
}
+ /**
+ * Additional context-specific information about a code smell.
+ * The fields vary depending on the smell type.
+ */
export interface AdditionalInfo {
- // CRC
+ // Fields for Cached Repeated Calls (CRC) smell:
+ /** Number of repetitions found (for CRC smells) */
repetitions?: number;
+ /** The call string that's being repeated (for CRC smells) */
callString?: string;
- // SCL
+
+ // Fields for String Concatenation in Loop (SCL) smell:
+ /** The target variable being concatenated (for SCL smells) */
concatTarget?: string;
+ /** The line number where the inner loop occurs (for SCL smells) */
innerLoopLine?: number;
}
+ /**
+ * Represents a detected code smell with all its metadata.
+ * This is the core data structure for analysis results.
+ */
export interface Smell {
- type: string; // Type of the smell (e.g., "performance", "convention")
- symbol: string; // Symbolic identifier for the smell (e.g., "cached-repeated-calls")
- message: string; // Detailed description of the smell
- messageId: string; // Unique ID for the smell
- confidence: string; // Confidence level (e.g., "HIGH", "MEDIUM")
- path: string; // Optional: absolute file path
- module: string; // Optional: Module name
- obj?: string; // Optional: Object name associated with the smell (if applicable)
- occurences: Occurrence[]; // Optional: List of occurrences for repeated calls
+ /** Category of the smell (e.g., "performance", "convention") */
+ type: string;
+ /** Unique identifier for the smell type (e.g., "cached-repeated-calls") */
+ symbol: string;
+ /** Human-readable description of the smell */
+ message: string;
+ /** Unique message ID for specific smell variations */
+ messageId: string;
+ /** Confidence level in detection ("HIGH", "MEDIUM", "LOW") */
+ confidence: string;
+ /** Absolute path to the file containing the smell */
+ path: string;
+ /** Module or namespace where the smell was found */
+ module: string;
+ /** Specific object/function name (when applicable) */
+ obj?: string;
+ /** All detected locations of this smell in the code */
+ occurences: Occurrence[];
+ /** Type-specific additional information about the smell */
additionalInfo: AdditionalInfo;
+ /** Unique identifier for this specific smell instance */
+ id?: string;
}
-
- export interface ChangedFile {
- original: string;
- refactored: string;
- }
-
+
+ /**
+ * Represents the response from the backend refactoring service.
+ * Contains all necessary information to present and apply refactorings.
+ */
export interface RefactoredData {
+ /** Temporary directory containing all refactored files */
tempDir: string;
- targetFile: ChangedFile;
- energySaved: number;
- affectedFiles: ChangedFile[];
+ /** The main file that was refactored */
+ targetFile: {
+ /** Path to the original version */
+ original: string;
+ /** Path to the refactored version */
+ refactored: string;
+ };
+ /** Estimated energy savings in joules (optional) */
+ energySaved?: number;
+ /** Any additional files affected by the refactoring */
+ affectedFiles: {
+ /** Path to the original version */
+ original: string;
+ /** Path to the refactored version */
+ refactored: string;
+ }[];
}
-
- export interface RefactorOutput {
- refactoredData?: RefactoredData; // Refactored code as a string
- updatedSmells: Smell[]; //
- }
-
- export interface ActiveDiff {
- files: ChangedFile[];
- isOpen: boolean;
- firstOpen: boolean;
+
+ export interface RefactorArtifacts {
+ refactoredData: RefactoredData;
+ smell: Smell;
}
-
- export type SmellDetails = {
- symbol: string;
- message: string;
- };
-
-}
+}
\ No newline at end of file
diff --git a/src/install.ts b/src/install.ts
new file mode 100644
index 0000000..f018d07
--- /dev/null
+++ b/src/install.ts
@@ -0,0 +1,310 @@
+import * as path from 'path';
+import * as childProcess from 'child_process';
+import { access, unlink, writeFile } from 'fs/promises';
+
+// Constants for package management
+const PACKAGE_NAME = 'ecooptimizer';
+const PYPI_INDEX = 'https://pypi.org/simple';
+
+/**
+ * Configuration interface for Python environment setup
+ */
+interface InstallConfig {
+ pythonPath: string;
+ version: string;
+ targetDir: string;
+}
+
+/**
+ * Ensures a valid Python virtual environment exists
+ * @param config Installation configuration
+ * @returns Path to the Python executable in the virtual environment
+ * @throws Error if environment setup fails
+ */
+async function ensurePythonEnvironment(config: InstallConfig): Promise {
+ const venvPath = path.join(config.targetDir, '.venv');
+ const isWindows = process.platform === 'win32';
+ const pythonExecutable = path.join(
+ venvPath,
+ isWindows ? 'Scripts' : 'bin',
+ isWindows ? 'python.exe' : 'python',
+ );
+
+ try {
+ // 1. Verify Python is available and executable
+ await new Promise((resolve, reject) => {
+ const pythonCheck = childProcess.spawn(config.pythonPath, ['--version']);
+ pythonCheck.stderr?.on('data', (chunk) => console.error(chunk));
+ pythonCheck.stdout?.on('data', (chunk) => console.log(chunk));
+ pythonCheck.on('close', (code) => {
+ code === 0
+ ? resolve()
+ : reject(new Error(`Python check failed (code ${code})`));
+ });
+ pythonCheck.on('error', (err) => {
+ console.error(err);
+ reject(err);
+ });
+ });
+
+ // 2. Check for existing virtual environment
+ let venvExists = false;
+ try {
+ await access(venvPath);
+ venvExists = true;
+ console.log('Virtual environment already exists');
+ } catch {
+ console.log('Creating virtual environment...');
+ }
+
+ if (!venvExists) {
+ const tempFile = path.join(config.targetDir, 'create_venv_temp.py');
+ try {
+ // Python script to create virtual environment
+ const scriptContent = `
+import sys
+import venv
+import os
+
+try:
+ venv.create("${venvPath.replace(/\\/g, '\\\\')}",
+ clear=True,
+ with_pip=True)
+ print("VENV_CREATION_SUCCESS")
+except Exception as e:
+ print(f"VENV_CREATION_ERROR: {str(e)}", file=sys.stderr)
+ sys.exit(1)
+`;
+ await writeFile(tempFile, scriptContent);
+
+ const creationSuccess = await new Promise((resolve, reject) => {
+ const proc = childProcess.spawn(config.pythonPath, [tempFile], {
+ stdio: 'pipe',
+ });
+
+ let output = '';
+ let errorOutput = '';
+
+ proc.stdout.on('data', (data) => (output += data.toString()));
+ proc.stderr.on('data', (data) => (errorOutput += data.toString()));
+
+ proc.on('close', (code) => {
+ if (code === 0 && output.includes('VENV_CREATION_SUCCESS')) {
+ resolve(true);
+ } else {
+ const errorMatch = errorOutput.match(/VENV_CREATION_ERROR: (.+)/);
+ const errorMessage =
+ errorMatch?.[1] || `Process exited with code ${code}`;
+ console.error('Virtual environment creation failed:', errorMessage);
+ reject(new Error(errorMessage));
+ }
+ });
+
+ proc.on('error', (err) => {
+ console.error('Process error:', err);
+ reject(err);
+ });
+ });
+
+ // Fallback check if venv was partially created
+ if (!creationSuccess) {
+ try {
+ await access(pythonExecutable);
+ console.warn('Using partially created virtual environment');
+ } catch (accessError) {
+ console.error(
+ 'Partial virtual environment creation failed:',
+ accessError,
+ );
+ throw new Error('Virtual environment creation completely failed');
+ }
+ }
+ } finally {
+ // Clean up temporary file
+ await unlink(tempFile).catch(() => {});
+ }
+ }
+
+ // 3. Final verification of virtual environment Python
+ await access(pythonExecutable);
+ return pythonExecutable;
+ } catch (error: any) {
+ console.error('Error in ensurePythonEnvironment:', error.message);
+ throw error;
+ }
+}
+
+/**
+ * Verifies installed package version matches expected version
+ * @param pythonPath Path to Python executable
+ * @param config Installation configuration
+ * @returns true if version matches
+ * @throws Error if version mismatch or package not found
+ */
+async function verifyPyPackage(
+ pythonPath: string,
+ config: InstallConfig,
+): Promise {
+ console.log('Verifying python package version...');
+ const installedVersion = childProcess
+ .execSync(
+ `"${pythonPath}" -c "import importlib.metadata; print(importlib.metadata.version('${PACKAGE_NAME}'))"`,
+ )
+ .toString()
+ .trim();
+
+ if (installedVersion !== config.version) {
+ throw new Error(
+ `Version mismatch: Expected ${config.version}, got ${installedVersion}`,
+ );
+ }
+
+ console.log('Version match.');
+ return true;
+}
+
+/**
+ * Installs package from PyPI into virtual environment
+ * @param config Installation configuration
+ */
+async function installFromPyPI(config: InstallConfig): Promise {
+ let pythonPath: string;
+ try {
+ pythonPath = await ensurePythonEnvironment(config);
+ console.log('Python environment is ready at:', pythonPath);
+ } catch (error: any) {
+ console.error('Failed to set up Python environment:', error.message);
+ return;
+ }
+ const pipPath = pythonPath.replace('python', 'pip');
+
+ // Skip if already installed
+ if (await verifyPyPackage(pythonPath, config)) {
+ console.log('Package already installed.');
+ return;
+ }
+
+ // Update setuptools first
+ console.log('Installing setup tools...');
+ try {
+ childProcess.execSync(`"${pipPath}" install --upgrade "setuptools>=45.0.0"`, {
+ stdio: 'inherit',
+ });
+ } catch (error) {
+ console.warn('Could not update setuptools:', error);
+ }
+
+ // Main package installation
+ console.log('Installing ecooptimizer...');
+ try {
+ childProcess.execSync(
+ `"${pipPath}" install --index-url ${PYPI_INDEX} "${PACKAGE_NAME}==${config.version}"`,
+ { stdio: 'inherit' },
+ );
+
+ verifyPyPackage(pythonPath, config);
+ console.log('✅ Installation completed successfully');
+ } catch (error) {
+ console.error('❌ Installation failed:', error);
+ throw error;
+ }
+}
+
+/**
+ * Finds a valid Python executable path
+ * @returns Path to Python executable
+ * @throws Error if no valid Python found
+ */
+async function findPythonPath(): Promise {
+ // Check explicit environment variable first
+ if (process.env.PYTHON_PATH && (await validatePython(process.env.PYTHON_PATH))) {
+ return process.env.PYTHON_PATH;
+ }
+
+ // Common Python executable names (ordered by preference)
+ const candidates = ['python', 'python3.10', 'python3', 'py'];
+
+ // Platform-specific locations
+ if (process.platform === 'win32') {
+ candidates.push(
+ path.join(
+ process.env.LOCALAPPDATA || '',
+ 'Programs',
+ 'Python',
+ 'Python310',
+ 'python.exe',
+ ),
+ path.join(process.env.ProgramFiles || '', 'Python310', 'python.exe'),
+ );
+ }
+
+ if (process.platform === 'darwin') {
+ candidates.push('/usr/local/bin/python3'); // Homebrew default
+ }
+
+ // Check environment-specific paths
+ if (process.env.CONDA_PREFIX) {
+ candidates.push(path.join(process.env.CONDA_PREFIX, 'bin', 'python'));
+ }
+
+ if (process.env.VIRTUAL_ENV) {
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
+ }
+
+ // Test each candidate
+ for (const candidate of candidates) {
+ try {
+ if (await validatePython(candidate)) {
+ return candidate;
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ throw new Error('No valid Python installation found');
+}
+
+/**
+ * Validates Python executable meets requirements
+ * @param pythonPath Path to Python executable
+ * @returns true if valid Python 3.9+ installation
+ */
+async function validatePython(pythonPath: string): Promise {
+ try {
+ const versionOutput = childProcess
+ .execSync(`"${pythonPath}" --version`)
+ .toString()
+ .trim();
+
+ const versionMatch = versionOutput.match(/Python (\d+)\.(\d+)/);
+ if (!versionMatch) return false;
+
+ const major = parseInt(versionMatch[1]);
+ const minor = parseInt(versionMatch[2]);
+
+ console.log('Python version:', major, minor);
+ return major === 3 && minor >= 9; // Require Python 3.9+
+ } catch {
+ return false;
+ }
+}
+
+// Main execution block when run directly
+if (require.main === module) {
+ (async (): Promise => {
+ try {
+ const config: InstallConfig = {
+ pythonPath: await findPythonPath(),
+ version: require('../package.json').version,
+ targetDir: process.cwd(),
+ };
+
+ console.log(`Using Python at: ${config.pythonPath}`);
+ await installFromPyPI(config);
+ } catch (error) {
+ console.error('Fatal error:', error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+ })();
+}
diff --git a/src/lib/README.md b/src/lib/README.md
new file mode 100644
index 0000000..94ac452
--- /dev/null
+++ b/src/lib/README.md
@@ -0,0 +1,3 @@
+The files in this folder do not currently do anything in the extension.
+
+They are left here for the future when the server backend can be properly integrated into the extension.
\ No newline at end of file
diff --git a/src/lib/dependencyManager.ts b/src/lib/dependencyManager.ts
new file mode 100644
index 0000000..a388d6e
--- /dev/null
+++ b/src/lib/dependencyManager.ts
@@ -0,0 +1,77 @@
+import { existsSync } from 'fs';
+import { join } from 'path';
+import * as vscode from 'vscode';
+import childProcess from 'child_process';
+
+/**
+ * Handles Python dependency management for the extension.
+ * Creates and manages a virtual environment (.venv) in the extension directory
+ * and provides installation capabilities when dependencies are missing.
+ */
+export class DependencyManager {
+ /**
+ * Ensures required dependencies are installed. Checks for existing virtual environment
+ * and prompts user to install if missing.
+ *
+ * @param context - Extension context containing installation path
+ * @returns Promise resolving to true if dependencies are available, false otherwise
+ */
+ static async ensureDependencies(
+ context: vscode.ExtensionContext,
+ ): Promise {
+ // Check for existing virtual environment
+ const venvPath = join(context.extensionPath, '.venv');
+ if (existsSync(venvPath)) return true;
+
+ // Prompt user to install dependencies if venv doesn't exist
+ const choice = await vscode.window.showErrorMessage(
+ 'Python dependencies missing. Install now?',
+ 'Install',
+ 'Cancel',
+ );
+
+ if (choice === 'Install') {
+ return vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Installing dependencies...',
+ },
+ async () => {
+ try {
+ await this.runInstaller(context);
+ return true;
+ } catch (error) {
+ vscode.window.showErrorMessage(`Installation failed: ${error}`);
+ return false;
+ }
+ },
+ );
+ }
+ return false;
+ }
+
+ /**
+ * Executes the dependency installation process in a child process.
+ * Shows progress to user and handles installation errors.
+ *
+ * @param context - Extension context containing installation path
+ * @throws Error when installation process fails
+ */
+ private static async runInstaller(
+ context: vscode.ExtensionContext,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ // Spawn installer process with inherited stdio for live output
+ const installer = childProcess.spawn('node', ['dist/install.js'], {
+ cwd: context.extensionPath,
+ stdio: 'inherit', // Show installation progress in parent console
+ });
+
+ installer.on('close', (code) =>
+ code === 0
+ ? resolve()
+ : reject(new Error(`Installer exited with code ${code}`)),
+ );
+ });
+ }
+}
diff --git a/src/lib/processManager.ts b/src/lib/processManager.ts
new file mode 100644
index 0000000..37f66eb
--- /dev/null
+++ b/src/lib/processManager.ts
@@ -0,0 +1,134 @@
+import * as childProcess from 'child_process';
+import { existsSync } from 'fs';
+import * as net from 'net';
+import { join } from 'path';
+import * as vscode from 'vscode';
+import { ecoOutput } from '../extension';
+
+/**
+ * Manages the lifecycle of the backend server process, including:
+ * - Starting the Python server with proper environment
+ * - Port allocation and verification
+ * - Process cleanup on exit
+ * - Logging and error handling
+ */
+export class ServerProcess {
+ private process?: childProcess.ChildProcess;
+
+ constructor(private context: vscode.ExtensionContext) {}
+
+ /**
+ * Starts the backend server process and verifies it's ready.
+ * @returns Promise resolving to the port number the server is running on
+ * @throws Error if server fails to start or Python environment is missing
+ */
+ async start(): Promise {
+ // Determine Python executable path based on platform
+ const pythonPath = join(
+ this.context.extensionPath,
+ process.platform === 'win32'
+ ? '.venv\\Scripts\\python.exe'
+ : '.venv/bin/python',
+ );
+
+ if (!existsSync(pythonPath)) {
+ throw new Error('Python environment not found');
+ }
+
+ // Clean up any existing server process
+ await this.killProcessTree();
+
+ // Find and bind to an available port
+ const port = await this.findFreePort();
+
+ // Start the Python server process
+ this.process = childProcess.spawn(
+ pythonPath,
+ ['-m', 'ecooptimizer.api', '--port', port.toString(), '--dev'],
+ {
+ cwd: this.context.extensionPath,
+ env: { ...process.env, PYTHONUNBUFFERED: '1' }, // Ensure unbuffered output
+ },
+ );
+
+ // Set up process event handlers
+ this.process.stdout?.on('data', (data) => ecoOutput.info(`[Server] ${data}`));
+ this.process.stderr?.on('data', (data) => ecoOutput.error(`[Server] ${data}`));
+ this.process.on('close', () => {
+ ecoOutput.info('Server stopped');
+ console.log('Server stopped');
+ });
+
+ // Verify server is actually listening before returning
+ await this.verifyReady(port);
+ return port;
+ }
+
+ /**
+ * Finds an available network port
+ * @returns Promise resolving to an available port number
+ */
+ private async findFreePort(): Promise {
+ return new Promise((resolve) => {
+ const server = net.createServer();
+ server.listen(0, () => {
+ const port = (server.address() as net.AddressInfo).port;
+ server.close(() => resolve(port));
+ });
+ });
+ }
+
+ /**
+ * Kills the server process and its entire process tree
+ * Handles platform-specific process termination
+ */
+ private async killProcessTree(): Promise {
+ if (!this.process?.pid) return;
+
+ try {
+ if (process.platform === 'win32') {
+ // Windows requires taskkill for process tree termination
+ childProcess.execSync(`taskkill /PID ${this.process.pid} /T /F`);
+ } else {
+ // Unix systems can kill process groups with negative PID
+ process.kill(-this.process.pid, 'SIGKILL');
+ }
+ } catch (error) {
+ ecoOutput.error(`Process cleanup failed: ${error}`);
+ } finally {
+ this.process = undefined;
+ }
+ }
+
+ /**
+ * Verifies the server is actually listening on the specified port
+ * @param port Port number to check
+ * @param timeout Maximum wait time in milliseconds
+ * @throws Error if server doesn't become ready within timeout
+ */
+ private async verifyReady(port: number, timeout = 10000): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ try {
+ const socket = net.createConnection({ port });
+ await new Promise((resolve, reject) => {
+ socket.on('connect', resolve);
+ socket.on('error', reject);
+ });
+ socket.end();
+ return;
+ } catch {
+ // Retry after short delay if connection fails
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ }
+ throw new Error(`Server didn't start within ${timeout}ms`);
+ }
+
+ /**
+ * Clean up resources when disposing of the manager
+ */
+ dispose(): void {
+ this.process?.kill();
+ }
+}
diff --git a/src/listeners/workspaceModifiedListener.ts b/src/listeners/workspaceModifiedListener.ts
new file mode 100644
index 0000000..14eb386
--- /dev/null
+++ b/src/listeners/workspaceModifiedListener.ts
@@ -0,0 +1,201 @@
+import * as vscode from 'vscode';
+import { basename } from 'path';
+
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
+import { SmellsViewProvider } from '../providers/SmellsViewProvider';
+import { MetricsViewProvider } from '../providers/MetricsViewProvider';
+import { ecoOutput, isSmellLintingEnabled } from '../extension';
+import { detectSmellsFile } from '../commands/detection/detectSmells';
+import { envConfig } from '../utils/envConfig';
+
+/**
+ * Monitors workspace modifications and maintains analysis state consistency by:
+ * - Tracking file system changes (create/change/delete)
+ * - Handling document save events
+ * - Managing cache invalidation
+ * - Coordinating view updates
+ */
+export class WorkspaceModifiedListener {
+ private fileWatcher: vscode.FileSystemWatcher | undefined;
+ private saveListener: vscode.Disposable | undefined;
+
+ constructor(
+ private context: vscode.ExtensionContext,
+ private smellsCacheManager: SmellsCacheManager,
+ private smellsViewProvider: SmellsViewProvider,
+ private metricsViewProvider: MetricsViewProvider,
+ ) {
+ this.initializeFileWatcher();
+ this.initializeSaveListener();
+ ecoOutput.trace(
+ '[WorkspaceListener] Initialized workspace modification listener',
+ );
+ }
+
+ /**
+ * Creates file system watcher for Python files in configured workspace
+ */
+ private initializeFileWatcher(): void {
+ const configuredPath = this.context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+ if (!configuredPath) {
+ ecoOutput.trace(
+ '[WorkspaceListener] No workspace configured - skipping file watcher',
+ );
+ return;
+ }
+
+ try {
+ this.fileWatcher = vscode.workspace.createFileSystemWatcher(
+ new vscode.RelativePattern(configuredPath, '**/*.py'),
+ false, // Watch create events
+ false, // Watch change events
+ false, // Watch delete events
+ );
+
+ this.fileWatcher.onDidCreate(() => {
+ ecoOutput.trace('[WorkspaceListener] Detected new Python file');
+ this.refreshViews();
+ });
+
+ this.fileWatcher.onDidDelete((uri) => {
+ ecoOutput.trace(`[WorkspaceListener] Detected deletion of ${uri.fsPath}`);
+ this.handleFileDeletion(uri.fsPath);
+ });
+
+ ecoOutput.trace(
+ `[WorkspaceListener] Watching Python files in ${configuredPath}`,
+ );
+ } catch (error) {
+ ecoOutput.error(
+ `[WorkspaceListener] Error initializing file watcher: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ /**
+ * Sets up document save listener for Python files
+ */
+ private initializeSaveListener(): void {
+ this.saveListener = vscode.workspace.onDidSaveTextDocument((document) => {
+ if (document.languageId === 'python') {
+ ecoOutput.trace(
+ `[WorkspaceListener] Detected save in ${document.uri.fsPath}`,
+ );
+ this.handleFileChange(document.uri.fsPath);
+
+ if (isSmellLintingEnabled()) {
+ ecoOutput.info(
+ `[WorkspaceListener] Smell linting is ON — auto-detecting smells for ${document.uri.fsPath}`,
+ );
+ detectSmellsFile(
+ document.uri.fsPath,
+ this.smellsViewProvider,
+ this.smellsCacheManager,
+ );
+ }
+ }
+ });
+ }
+
+ /**
+ * Handles file modifications by:
+ * - Invalidating cached analysis if exists
+ * - Marking file as outdated in UI
+ * @param filePath - Absolute path to modified file
+ */
+ private async handleFileChange(filePath: string): Promise {
+ // Log current cache state for debugging
+ const cachedFiles = this.smellsCacheManager.getAllFilePaths();
+ ecoOutput.trace(
+ `[WorkspaceListener] Current cached files (${cachedFiles.length}):\n` +
+ cachedFiles.map((f) => ` - ${f}`).join('\n'),
+ );
+
+ const hadCache = this.smellsCacheManager.hasFileInCache(filePath);
+ if (!hadCache) {
+ ecoOutput.trace(`[WorkspaceListener] No cache to invalidate for ${filePath}`);
+ return;
+ }
+
+ try {
+ await this.smellsCacheManager.clearCachedSmellsForFile(filePath);
+ this.smellsViewProvider.setStatus(filePath, 'outdated');
+
+ ecoOutput.trace(
+ `[WorkspaceListener] Invalidated cache for modified file: ${filePath}`,
+ );
+ vscode.window.showInformationMessage(
+ `Analysis data marked outdated for ${basename(filePath)}`,
+ { modal: false },
+ );
+
+ this.refreshViews();
+ } catch (error) {
+ ecoOutput.error(
+ `[WorkspaceListener] Error handling file change: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ /**
+ * Handles file deletions by:
+ * - Clearing related cache entries
+ * - Removing from UI views
+ * @param filePath - Absolute path to deleted file
+ */
+ private async handleFileDeletion(filePath: string): Promise {
+ const hadCache = this.smellsCacheManager.hasCachedSmells(filePath);
+ let removed = false;
+
+ if (hadCache) {
+ try {
+ await this.smellsCacheManager.clearCachedSmellsByPath(filePath);
+ removed = true;
+ ecoOutput.trace(
+ `[WorkspaceListener] Cleared cache for deleted file: ${filePath}`,
+ );
+ } catch (error) {
+ ecoOutput.error(
+ `[WorkspaceListener] Error clearing cache: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ const removedFromTree = this.smellsViewProvider.removeFile(filePath);
+ if (removedFromTree) {
+ removed = true;
+ ecoOutput.trace(`[WorkspaceListener] Removed from view: ${filePath}`);
+ }
+
+ if (removed) {
+ vscode.window.showInformationMessage(
+ `Removed analysis data for deleted file: ${basename(filePath)}`,
+ { modal: false },
+ );
+ }
+
+ this.refreshViews();
+ }
+
+ /**
+ * Triggers refresh of all dependent views
+ */
+ private refreshViews(): void {
+ this.smellsViewProvider.refresh();
+ this.metricsViewProvider.refresh();
+ ecoOutput.trace('[WorkspaceListener] Refreshed all views');
+ }
+
+ /**
+ * Cleans up resources including:
+ * - File system watcher
+ * - Document save listener
+ */
+ public dispose(): void {
+ this.fileWatcher?.dispose();
+ this.saveListener?.dispose();
+ ecoOutput.trace('[WorkspaceListener] Disposed all listeners');
+ }
+}
diff --git a/src/providers/FilterViewProvider.ts b/src/providers/FilterViewProvider.ts
new file mode 100644
index 0000000..cab9a17
--- /dev/null
+++ b/src/providers/FilterViewProvider.ts
@@ -0,0 +1,269 @@
+import * as vscode from 'vscode';
+import {
+ FilterSmellConfig,
+ getFilterSmells,
+ loadSmells,
+ saveSmells,
+} from '../utils/smellsData';
+import { MetricsViewProvider } from './MetricsViewProvider';
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
+import { SmellsViewProvider } from './SmellsViewProvider';
+
+/**
+ * Provides a tree view for managing and filtering code smells in the VS Code extension.
+ * Handles smell configuration, option editing, and maintains consistency with cached results.
+ */
+export class FilterViewProvider implements vscode.TreeDataProvider {
+ // Event emitter for tree view updates
+ private _onDidChangeTreeData: vscode.EventEmitter<
+ vscode.TreeItem | undefined | void
+ > = new vscode.EventEmitter();
+ readonly onDidChangeTreeData: vscode.Event =
+ this._onDidChangeTreeData.event;
+
+ private treeView?: vscode.TreeView;
+ private smells: Record = {};
+
+ constructor(
+ private context: vscode.ExtensionContext,
+ private metricsViewProvider: MetricsViewProvider,
+ private smellsCacheManager: SmellsCacheManager,
+ private smellsViewProvider: SmellsViewProvider,
+ ) {
+ this.smells = getFilterSmells();
+ }
+
+ /**
+ * Sets up the tree view and handles checkbox state changes
+ * @param treeView The VS Code tree view instance to manage
+ */
+ setTreeView(treeView: vscode.TreeView): void {
+ this.treeView = treeView;
+
+ this.treeView.onDidChangeCheckboxState(async (event) => {
+ for (const [item] of event.items) {
+ if (item instanceof SmellItem) {
+ const confirmed = await this.confirmFilterChange();
+ if (confirmed) {
+ await this.toggleSmell(item.key);
+ } else {
+ // Refresh view if change was cancelled
+ this._onDidChangeTreeData.fire();
+ }
+ }
+ }
+ });
+ }
+
+ getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
+ return element;
+ }
+
+ /**
+ * Gets children items for the tree view
+ * @param element Parent element or undefined for root items
+ * @returns Promise resolving to array of tree items
+ */
+ getChildren(element?: SmellItem): Thenable {
+ if (!element) {
+ // Root level items - all available smells
+ return Promise.resolve(
+ Object.keys(this.smells)
+ .sort((a, b) => this.smells[a].name.localeCompare(this.smells[b].name))
+ .map((smellKey) => {
+ const smell = this.smells[smellKey];
+ return new SmellItem(
+ smellKey,
+ smell.name,
+ smell.enabled,
+ smell.analyzer_options &&
+ Object.keys(smell.analyzer_options).length > 0
+ ? vscode.TreeItemCollapsibleState.Collapsed
+ : vscode.TreeItemCollapsibleState.None,
+ );
+ }),
+ );
+ }
+
+ // Child items - smell configuration options
+ const options = this.smells[element.key]?.analyzer_options;
+ return options
+ ? Promise.resolve(
+ Object.entries(options).map(
+ ([optionKey, optionData]) =>
+ new SmellOptionItem(
+ optionKey,
+ optionData.label,
+ optionData.value,
+ optionData.description,
+ element.key,
+ ),
+ ),
+ )
+ : Promise.resolve([]);
+ }
+
+ /**
+ * Toggles a smell's enabled state
+ * @param smellKey The key of the smell to toggle
+ */
+ async toggleSmell(smellKey: string): Promise {
+ if (this.smells[smellKey]) {
+ this.smells[smellKey].enabled = !this.smells[smellKey].enabled;
+ saveSmells(this.smells);
+ await this.invalidateCachedSmellsForAffectedFiles();
+ this._onDidChangeTreeData.fire();
+ }
+ }
+
+ /**
+ * Updates a smell analyzer option value
+ * @param smellKey The smell containing the option
+ * @param optionKey The option to update
+ * @param newValue The new value for the option
+ */
+ async updateOption(
+ smellKey: string,
+ optionKey: string,
+ newValue: number | string,
+ ): Promise {
+ const confirmed = await this.confirmFilterChange();
+ if (!confirmed) return;
+
+ if (this.smells[smellKey]?.analyzer_options?.[optionKey]) {
+ this.smells[smellKey].analyzer_options[optionKey].value = newValue;
+ saveSmells(this.smells);
+ await this.invalidateCachedSmellsForAffectedFiles();
+ this._onDidChangeTreeData.fire();
+ } else {
+ vscode.window.showErrorMessage(
+ `Error: No analyzer option found for ${optionKey}`,
+ );
+ }
+ }
+
+ refresh(): void {
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ /**
+ * Enables or disables all smells at once
+ * @param enabled Whether to enable or disable all smells
+ */
+ async setAllSmellsEnabled(enabled: boolean): Promise {
+ const confirmed = await this.confirmFilterChange();
+ if (!confirmed) return;
+
+ Object.keys(this.smells).forEach((key) => {
+ this.smells[key].enabled = enabled;
+ });
+ saveSmells(this.smells);
+ await this.invalidateCachedSmellsForAffectedFiles();
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Resets all smell configurations to their default values
+ */
+ async resetToDefaults(): Promise {
+ const confirmed = await this.confirmFilterChange();
+ if (!confirmed) return;
+
+ loadSmells('default');
+ this.smells = getFilterSmells();
+ saveSmells(this.smells);
+
+ await this.invalidateCachedSmellsForAffectedFiles();
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Invalidates cached smells for all files when filters change
+ */
+ async invalidateCachedSmellsForAffectedFiles(): Promise {
+ const cachedFilePaths = this.smellsCacheManager.getAllFilePaths();
+
+ for (const filePath of cachedFilePaths) {
+ this.smellsCacheManager.clearCachedSmellsForFile(filePath);
+ this.smellsViewProvider.setStatus(filePath, 'outdated');
+ }
+
+ this.metricsViewProvider.refresh();
+ this.smellsViewProvider.refresh();
+ }
+
+ /**
+ * Shows confirmation dialog for filter changes that invalidate cache
+ * @returns Promise resolving to whether change should proceed
+ */
+ private async confirmFilterChange(): Promise {
+ const suppressWarning = this.context.workspaceState.get(
+ 'ecooptimizer.suppressFilterWarning',
+ false,
+ );
+
+ if (suppressWarning) {
+ return true;
+ }
+
+ const result = await vscode.window.showWarningMessage(
+ 'Changing smell filters will invalidate existing analysis results. Do you want to continue?',
+ { modal: true },
+ 'Yes',
+ "Don't Remind Me Again",
+ );
+
+ if (result === "Don't Remind Me Again") {
+ await this.context.workspaceState.update(
+ 'ecooptimizer.suppressFilterWarning',
+ true,
+ );
+ return true;
+ }
+
+ return result === 'Yes';
+ }
+}
+
+/**
+ * Tree item representing a single smell in the filter view
+ */
+class SmellItem extends vscode.TreeItem {
+ constructor(
+ public readonly key: string,
+ public readonly name: string,
+ public enabled: boolean,
+ public readonly collapsibleState: vscode.TreeItemCollapsibleState,
+ ) {
+ super(name, collapsibleState);
+ this.contextValue = 'smellItem';
+ this.checkboxState = enabled
+ ? vscode.TreeItemCheckboxState.Checked
+ : vscode.TreeItemCheckboxState.Unchecked;
+ }
+}
+
+/**
+ * Tree item representing a configurable option for a smell
+ */
+class SmellOptionItem extends vscode.TreeItem {
+ constructor(
+ public readonly optionKey: string,
+ public readonly label: string,
+ public value: number | string,
+ public readonly description: string,
+ public readonly smellKey: string,
+ ) {
+ super('placeholder', vscode.TreeItemCollapsibleState.None);
+
+ this.contextValue = 'smellOption';
+ this.label = `${label}: ${value}`;
+ this.tooltip = description;
+ this.description = '';
+ this.command = {
+ command: 'ecooptimizer.editSmellFilterOption',
+ title: 'Edit Option',
+ arguments: [this],
+ };
+ }
+}
diff --git a/src/providers/MetricsViewProvider.ts b/src/providers/MetricsViewProvider.ts
new file mode 100644
index 0000000..e003054
--- /dev/null
+++ b/src/providers/MetricsViewProvider.ts
@@ -0,0 +1,421 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import { basename, dirname } from 'path';
+import { buildPythonTree } from '../utils/TreeStructureBuilder';
+import { envConfig } from '../utils/envConfig';
+import { getFilterSmells } from '../utils/smellsData';
+import { normalizePath } from '../utils/normalizePath';
+
+/**
+ * Custom TreeItem for displaying metrics in the VS Code explorer
+ * Handles different node types (folders, files, smells) with appropriate icons and behaviors
+ */
+class MetricTreeItem extends vscode.TreeItem {
+ constructor(
+ public readonly label: string,
+ public readonly collapsibleState: vscode.TreeItemCollapsibleState,
+ public readonly contextValue: string,
+ public readonly carbonSaved?: number,
+ public readonly resourceUri?: vscode.Uri,
+ public readonly smellName?: string,
+ ) {
+ super(label, collapsibleState);
+
+ // Set icon based on node type
+ switch (this.contextValue) {
+ case 'folder':
+ this.iconPath = new vscode.ThemeIcon('folder');
+ break;
+ case 'file':
+ this.iconPath = new vscode.ThemeIcon('file');
+ break;
+ case 'smell':
+ this.iconPath = new vscode.ThemeIcon('tag');
+ break;
+ case 'folder-stats':
+ this.iconPath = new vscode.ThemeIcon('graph');
+ break;
+ }
+
+ // Format carbon savings display
+ this.description =
+ carbonSaved !== undefined
+ ? `Carbon Saved: ${formatNumber(carbonSaved)} kg`
+ : '';
+ this.tooltip = smellName || this.description;
+
+ // Make files clickable to open them
+ if (resourceUri && contextValue === 'file') {
+ this.command = {
+ title: 'Open File',
+ command: 'vscode.open',
+ arguments: [resourceUri],
+ };
+ }
+ }
+}
+
+/**
+ * Interface for storing metrics data for individual files
+ */
+export interface MetricsDataItem {
+ totalCarbonSaved: number;
+ smellDistribution: {
+ [smell: string]: number;
+ };
+}
+
+/**
+ * Structure for aggregating metrics across folders
+ */
+interface FolderMetrics {
+ totalCarbonSaved: number;
+ smellDistribution: Map; // Map
+ children: {
+ files: Map; // Map
+ folders: Map; // Map
+ };
+}
+
+/**
+ * Provides a tree view of carbon savings metrics across the workspace
+ * Aggregates data by folder structure and smell types with caching for performance
+ */
+export class MetricsViewProvider implements vscode.TreeDataProvider {
+ private _onDidChangeTreeData = new vscode.EventEmitter<
+ MetricTreeItem | undefined
+ >();
+ readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
+
+ // Cache for folder metrics to avoid repeated calculations
+ private folderMetricsCache: Map = new Map();
+
+ constructor(private context: vscode.ExtensionContext) {}
+
+ /**
+ * Triggers a refresh of the tree view
+ */
+ refresh(): void {
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ getTreeItem(element: MetricTreeItem): vscode.TreeItem {
+ return element;
+ }
+
+ /**
+ * Builds the tree view hierarchy
+ * @param element The parent element or undefined for root items
+ * @returns Promise resolving to child tree items
+ */
+ async getChildren(element?: MetricTreeItem): Promise {
+ const metricsData = this.context.workspaceState.get<{
+ [path: string]: MetricsDataItem;
+ }>(envConfig.WORKSPACE_METRICS_DATA!, {});
+
+ // Root level items
+ if (!element) {
+ const configuredPath = this.context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+ if (!configuredPath) return [];
+
+ // Show either single file or folder contents at root
+ const isDirectory =
+ fs.existsSync(configuredPath) && fs.statSync(configuredPath).isDirectory();
+ if (isDirectory) {
+ return [this.createFolderItem(configuredPath)];
+ } else {
+ return [this.createFileItem(configuredPath, metricsData)];
+ }
+ }
+
+ // Folder contents
+ if (element.contextValue === 'folder') {
+ const folderPath = element.resourceUri!.fsPath;
+ const folderMetrics = await this.calculateFolderMetrics(
+ folderPath,
+ metricsData,
+ );
+ const treeNodes = buildPythonTree(folderPath);
+
+ // Create folder statistics section
+ const folderStats = [
+ new MetricTreeItem(
+ `Total Carbon Saved: ${formatNumber(folderMetrics.totalCarbonSaved)} kg`,
+ vscode.TreeItemCollapsibleState.None,
+ 'folder-stats',
+ ),
+ ...Array.from(folderMetrics.smellDistribution.entries()).map(
+ ([acronym, [name, carbonSaved]]) =>
+ this.createSmellItem({ acronym, name, carbonSaved }),
+ ),
+ ].sort(compareTreeItems);
+
+ // Create folder contents listing
+ const contents = treeNodes.map((node) => {
+ return node.isFile
+ ? this.createFileItem(node.fullPath, metricsData)
+ : this.createFolderItem(node.fullPath);
+ });
+
+ return [...contents, ...folderStats];
+ }
+
+ // File smell breakdown
+ if (element.contextValue === 'file') {
+ const filePath = element.resourceUri!.fsPath;
+ const fileMetrics = this.calculateFileMetrics(filePath, metricsData);
+ return fileMetrics.smellData.map((data) => this.createSmellItem(data));
+ }
+
+ return [];
+ }
+
+ /**
+ * Creates a folder tree item
+ */
+ private createFolderItem(folderPath: string): MetricTreeItem {
+ return new MetricTreeItem(
+ basename(folderPath),
+ vscode.TreeItemCollapsibleState.Collapsed,
+ 'folder',
+ undefined,
+ vscode.Uri.file(folderPath),
+ );
+ }
+
+ /**
+ * Creates a file tree item with carbon savings
+ */
+ private createFileItem(
+ filePath: string,
+ metricsData: { [path: string]: MetricsDataItem },
+ ): MetricTreeItem {
+ const fileMetrics = this.calculateFileMetrics(filePath, metricsData);
+ return new MetricTreeItem(
+ basename(filePath),
+ vscode.TreeItemCollapsibleState.Collapsed,
+ 'file',
+ fileMetrics.totalCarbonSaved,
+ vscode.Uri.file(filePath),
+ );
+ }
+
+ /**
+ * Creates a smell breakdown item
+ */
+ private createSmellItem(data: {
+ acronym: string;
+ name: string;
+ carbonSaved: number;
+ }): MetricTreeItem {
+ return new MetricTreeItem(
+ `${data.acronym}: ${formatNumber(data.carbonSaved)} kg`,
+ vscode.TreeItemCollapsibleState.None,
+ 'smell',
+ undefined,
+ undefined,
+ data.name,
+ );
+ }
+
+ /**
+ * Calculates aggregated metrics for a folder and its contents
+ * Uses caching to optimize performance for large folder structures
+ */
+ private async calculateFolderMetrics(
+ folderPath: string,
+ metricsData: { [path: string]: MetricsDataItem },
+ ): Promise {
+ // Return cached metrics if available
+ const cachedMetrics = this.folderMetricsCache.get(folderPath);
+ if (cachedMetrics) {
+ return cachedMetrics;
+ }
+
+ const folderMetrics: FolderMetrics = {
+ totalCarbonSaved: 0,
+ smellDistribution: new Map(),
+ children: {
+ files: new Map(),
+ folders: new Map(),
+ },
+ };
+
+ // Build directory tree structure
+ const treeNodes = buildPythonTree(folderPath);
+
+ for (const node of treeNodes) {
+ if (node.isFile) {
+ // Aggregate file metrics
+ const fileMetrics = this.calculateFileMetrics(node.fullPath, metricsData);
+ folderMetrics.children.files.set(
+ node.fullPath,
+ fileMetrics.totalCarbonSaved,
+ );
+ folderMetrics.totalCarbonSaved += fileMetrics.totalCarbonSaved;
+
+ // Aggregate smell distribution from file
+ for (const smellData of fileMetrics.smellData) {
+ const current =
+ folderMetrics.smellDistribution.get(smellData.acronym)?.[1] || 0;
+ folderMetrics.smellDistribution.set(smellData.acronym, [
+ smellData.name,
+ current + smellData.carbonSaved,
+ ]);
+ }
+ } else {
+ // Recursively process subfolders
+ const subFolderMetrics = await this.calculateFolderMetrics(
+ node.fullPath,
+ metricsData,
+ );
+ folderMetrics.children.folders.set(node.fullPath, subFolderMetrics);
+ folderMetrics.totalCarbonSaved += subFolderMetrics.totalCarbonSaved;
+
+ // Aggregate smell distribution from subfolder
+ subFolderMetrics.smellDistribution.forEach(
+ ([name, carbonSaved], acronym) => {
+ const current = folderMetrics.smellDistribution.get(acronym)?.[1] || 0;
+ folderMetrics.smellDistribution.set(acronym, [
+ name,
+ current + carbonSaved,
+ ]);
+ },
+ );
+ }
+ }
+
+ // Cache the calculated metrics
+ this.folderMetricsCache.set(folderPath, folderMetrics);
+ return folderMetrics;
+ }
+
+ /**
+ * Calculates metrics for a single file
+ */
+ private calculateFileMetrics(
+ filePath: string,
+ metricsData: { [path: string]: MetricsDataItem },
+ ): {
+ totalCarbonSaved: number;
+ smellData: { acronym: string; name: string; carbonSaved: number }[];
+ } {
+ const smellConfigData = getFilterSmells();
+ const fileData = metricsData[normalizePath(filePath)] || {
+ totalCarbonSaved: 0,
+ smellDistribution: {},
+ };
+
+ // Filter smell distribution to only include enabled smells
+ const smellDistribution = Object.keys(smellConfigData).reduce(
+ (acc, symbol) => {
+ if (smellConfigData[symbol]) {
+ acc[symbol] = fileData.smellDistribution[symbol] || 0;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return {
+ totalCarbonSaved: fileData.totalCarbonSaved,
+ smellData: Object.entries(smellDistribution).map(([symbol, carbonSaved]) => ({
+ acronym: smellConfigData[symbol].acronym,
+ name: smellConfigData[symbol].name,
+ carbonSaved,
+ })),
+ };
+ }
+
+ /**
+ * Updates metrics for a file when new analysis results are available
+ */
+ updateMetrics(filePath: string, carbonSaved: number, smellSymbol: string): void {
+ const metrics = this.context.workspaceState.get<{
+ [path: string]: MetricsDataItem;
+ }>(envConfig.WORKSPACE_METRICS_DATA!, {});
+
+ const normalizedPath = normalizePath(filePath);
+
+ // Initialize metrics if they don't exist
+ if (!metrics[normalizedPath]) {
+ metrics[normalizedPath] = {
+ totalCarbonSaved: 0,
+ smellDistribution: {},
+ };
+ }
+
+ // Update metrics
+ metrics[normalizedPath].totalCarbonSaved =
+ (metrics[normalizedPath].totalCarbonSaved || 0) + carbonSaved;
+
+ if (!metrics[normalizedPath].smellDistribution[smellSymbol]) {
+ metrics[normalizedPath].smellDistribution[smellSymbol] = 0;
+ }
+ metrics[normalizedPath].smellDistribution[smellSymbol] += carbonSaved;
+
+ // Persist changes
+ this.context.workspaceState.update(envConfig.WORKSPACE_METRICS_DATA!, metrics);
+
+ // Clear cache for all parent folders
+ this.clearCacheForFileParents(filePath);
+ this.refresh();
+ }
+
+ /**
+ * Clears cached metrics for all parent folders of a modified file
+ */
+ private clearCacheForFileParents(filePath: string): void {
+ let configuredPath = this.context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+
+ if (!configuredPath) {
+ return;
+ }
+ configuredPath = normalizePath(configuredPath);
+
+ // Walk up the directory tree clearing cache
+ let currentPath = dirname(filePath);
+ while (currentPath.includes(configuredPath)) {
+ this.folderMetricsCache.delete(currentPath);
+ currentPath = dirname(currentPath);
+ }
+ }
+}
+
+// ===========================================================
+// HELPER FUNCTIONS
+// ===========================================================
+
+/**
+ * Priority for sorting tree items by type
+ */
+const contextPriority: { [key: string]: number } = {
+ folder: 1,
+ file: 2,
+ smell: 3,
+ 'folder-stats': 4,
+};
+
+/**
+ * Comparator for tree items (folders first, then files, then smells)
+ */
+function compareTreeItems(a: MetricTreeItem, b: MetricTreeItem): number {
+ const priorityA = contextPriority[a.contextValue] || 0;
+ const priorityB = contextPriority[b.contextValue] || 0;
+ if (priorityA !== priorityB) return priorityA - priorityB;
+ return a.label.localeCompare(b.label);
+}
+
+/**
+ * Formats numbers for display, using scientific notation for very small values
+ */
+function formatNumber(number: number, decimalPlaces: number = 2): string {
+ const threshold = 0.001;
+ return Math.abs(number) < threshold
+ ? number.toExponential(decimalPlaces)
+ : number.toFixed(decimalPlaces);
+}
diff --git a/src/providers/RefactoringDetailsViewProvider.ts b/src/providers/RefactoringDetailsViewProvider.ts
new file mode 100644
index 0000000..d5c8b45
--- /dev/null
+++ b/src/providers/RefactoringDetailsViewProvider.ts
@@ -0,0 +1,201 @@
+import * as vscode from 'vscode';
+import * as path from 'path';
+import { getDescriptionByMessageId, getNameByMessageId } from '../utils/smellsData';
+
+/**
+ * Provides a tree view that displays detailed information about ongoing refactoring operations.
+ * Shows the target smell, affected files, and estimated energy savings.
+ */
+export class RefactoringDetailsViewProvider
+ implements vscode.TreeDataProvider
+{
+ // Event emitter for tree data changes
+ private _onDidChangeTreeData = new vscode.EventEmitter<
+ RefactoringDetailItem | undefined
+ >();
+ readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
+
+ // State properties
+ private refactoringDetails: RefactoringDetailItem[] = [];
+ public targetFile: { original: string; refactored: string } | undefined;
+ public affectedFiles: { original: string; refactored: string }[] = [];
+ public energySaved: number | undefined;
+ public targetSmell: Smell | undefined;
+
+ constructor() {
+ this.resetRefactoringDetails();
+ }
+
+ /**
+ * Updates the view with new refactoring details
+ * @param targetSmell - The code smell being refactored
+ * @param targetFile - Paths to original and refactored target files
+ * @param affectedFiles - Additional files impacted by the refactoring
+ * @param energySaved - Estimated energy savings in kg CO2
+ */
+ updateRefactoringDetails(
+ targetSmell: Smell,
+ targetFile: { original: string; refactored: string },
+ affectedFiles: { original: string; refactored: string }[],
+ energySaved: number | undefined,
+ ): void {
+ this.targetSmell = targetSmell;
+ this.targetFile = targetFile;
+ this.affectedFiles = affectedFiles;
+ this.energySaved = energySaved;
+ this.refactoringDetails = [];
+
+ // Add smell information
+ if (targetSmell) {
+ const smellName =
+ getNameByMessageId(targetSmell.messageId) || targetSmell.messageId;
+ this.refactoringDetails.push(
+ new RefactoringDetailItem(
+ `Refactoring: ${smellName}`,
+ '',
+ '',
+ '',
+ true,
+ false,
+ true,
+ ),
+ );
+ }
+
+ // Add energy savings
+ if (energySaved !== undefined) {
+ this.refactoringDetails.push(
+ new RefactoringDetailItem(
+ `Estimated Savings: ${energySaved} kg CO2`,
+ 'Based on energy impact analysis',
+ '',
+ '',
+ false,
+ true,
+ ),
+ );
+ }
+
+ // Add target file
+ if (targetFile) {
+ this.refactoringDetails.push(
+ new RefactoringDetailItem(
+ `${path.basename(targetFile.original)}`,
+ 'Main refactored file',
+ targetFile.original,
+ targetFile.refactored,
+ affectedFiles.length > 0,
+ ),
+ );
+ }
+
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ /**
+ * Resets the view to its initial state
+ */
+ resetRefactoringDetails(): void {
+ this.targetFile = undefined;
+ this.affectedFiles = [];
+ this.targetSmell = undefined;
+ this.energySaved = undefined;
+ this.refactoringDetails = [];
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ // VS Code TreeDataProvider implementation
+ getTreeItem(element: RefactoringDetailItem): vscode.TreeItem {
+ return element;
+ }
+
+ getChildren(element?: RefactoringDetailItem): RefactoringDetailItem[] {
+ if (!element) {
+ return this.refactoringDetails;
+ }
+
+ // Handle smell description expansion
+ if (element.isSmellItem && this.targetSmell) {
+ const description =
+ getDescriptionByMessageId(this.targetSmell.messageId) ||
+ this.targetSmell.message;
+ return [
+ new RefactoringDetailItem(
+ '',
+ description,
+ '',
+ '',
+ false,
+ false,
+ false,
+ 'info',
+ ),
+ ];
+ }
+
+ // Handle affected files expansion
+ if (element.isParent && this.affectedFiles.length > 0) {
+ return this.affectedFiles.map(
+ (file) =>
+ new RefactoringDetailItem(
+ path.basename(file.original),
+ 'Affected file',
+ file.original,
+ file.refactored,
+ ),
+ );
+ }
+
+ return [];
+ }
+}
+
+/**
+ * Represents an item in the refactoring details tree view
+ */
+class RefactoringDetailItem extends vscode.TreeItem {
+ constructor(
+ public readonly label: string,
+ public readonly description: string,
+ public readonly originalFilePath: string,
+ public readonly refactoredFilePath: string,
+ public readonly isParent: boolean = false,
+ public readonly isEnergySaved: boolean = false,
+ public readonly isSmellItem: boolean = false,
+ public readonly itemType: 'info' | 'file' | 'none' = 'none',
+ ) {
+ super(
+ label,
+ isParent || isSmellItem
+ ? vscode.TreeItemCollapsibleState.Collapsed
+ : vscode.TreeItemCollapsibleState.None,
+ );
+
+ // Configure item based on type
+ if (isEnergySaved) {
+ this.iconPath = new vscode.ThemeIcon(
+ 'lightbulb',
+ new vscode.ThemeColor('charts.yellow'),
+ );
+ this.tooltip = 'Estimated energy savings from this refactoring';
+ } else if (isSmellItem) {
+ this.iconPath = new vscode.ThemeIcon(
+ 'warning',
+ new vscode.ThemeColor('charts.orange'),
+ );
+ } else if (itemType === 'info') {
+ this.iconPath = new vscode.ThemeIcon('info');
+ this.tooltip = this.description;
+ }
+
+ // Make files clickable to open diffs
+ if (originalFilePath && refactoredFilePath && itemType !== 'info') {
+ this.command = {
+ command: 'ecooptimizer.openDiffEditor',
+ title: 'Compare Changes',
+ arguments: [originalFilePath, refactoredFilePath],
+ };
+ this.contextValue = 'refactoringFile';
+ }
+ }
+}
diff --git a/src/providers/SmellsViewProvider.ts b/src/providers/SmellsViewProvider.ts
new file mode 100644
index 0000000..5694c8a
--- /dev/null
+++ b/src/providers/SmellsViewProvider.ts
@@ -0,0 +1,304 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { buildPythonTree } from '../utils/TreeStructureBuilder';
+import { getAcronymByMessageId } from '../utils/smellsData';
+import { normalizePath } from '../utils/normalizePath';
+import { envConfig } from '../utils/envConfig';
+
+/**
+ * Provides a tree view for displaying code smells in the workspace.
+ * Shows files and their detected smells in a hierarchical structure,
+ * with status indicators and navigation capabilities.
+ */
+export class SmellsViewProvider
+ implements vscode.TreeDataProvider
+{
+ // Event emitter for tree view updates
+ private _onDidChangeTreeData: vscode.EventEmitter<
+ TreeItem | SmellTreeItem | undefined | void
+ > = new vscode.EventEmitter();
+ readonly onDidChangeTreeData: vscode.Event<
+ TreeItem | SmellTreeItem | undefined | void
+ > = this._onDidChangeTreeData.event;
+
+ // Tracks analysis status and smells for each file
+ private fileStatuses: Map = new Map();
+ private fileSmells: Map = new Map();
+
+ constructor(private context: vscode.ExtensionContext) {}
+
+ /**
+ * Triggers a refresh of the tree view
+ */
+ refresh(): void {
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Updates the analysis status for a file
+ * @param filePath Path to the file
+ * @param status New status ('queued', 'passed', 'failed', etc.)
+ */
+ setStatus(filePath: string, status: string): void {
+ const normalizedPath = normalizePath(filePath);
+ this.fileStatuses.set(normalizedPath, status);
+
+ // Clear smells if status is outdated
+ if (status === 'outdated') {
+ this.fileSmells.delete(normalizedPath);
+ }
+
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Sets the detected smells for a file
+ * @param filePath Path to the file
+ * @param smells Array of detected smells
+ */
+ setSmells(filePath: string, smells: Smell[]): void {
+ this.fileSmells.set(filePath, smells);
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Removes a file from the tree view
+ * @param filePath Path to the file to remove
+ * @returns Whether the file was found and removed
+ */
+ public removeFile(filePath: string): boolean {
+ const normalizedPath = normalizePath(filePath);
+ const exists = this.fileStatuses.has(normalizedPath);
+ if (exists) {
+ this.fileStatuses.delete(normalizedPath);
+ this.fileSmells.delete(normalizedPath);
+ }
+ return exists;
+ }
+
+ /**
+ * Clears all file statuses and smells from the view
+ */
+ public clearAllStatuses(): void {
+ this.fileStatuses.clear();
+ this.fileSmells.clear();
+ this._onDidChangeTreeData.fire();
+ }
+
+ getTreeItem(element: TreeItem | SmellTreeItem): vscode.TreeItem {
+ return element;
+ }
+
+ /**
+ * Builds the tree view hierarchy
+ * @param element The parent element or undefined for root items
+ * @returns Promise resolving to child tree items
+ */
+ async getChildren(
+ element?: TreeItem | SmellTreeItem,
+ ): Promise<(TreeItem | SmellTreeItem)[]> {
+ const rootPath = this.context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+ if (!rootPath) {
+ return [];
+ }
+
+ // Smell nodes are leaf nodes - no children
+ if (element instanceof SmellTreeItem) {
+ return [];
+ }
+
+ // If this is a file node, show its smells
+ if (
+ element?.contextValue === 'file' ||
+ element?.contextValue === 'file_with_smells'
+ ) {
+ const smells = this.fileSmells.get(element.fullPath) ?? [];
+ return smells.map((smell) => new SmellTreeItem(smell));
+ }
+
+ // Root element - show either single file or folder contents
+ if (!element) {
+ const stat = fs.statSync(rootPath);
+ if (stat.isFile()) {
+ return [this.createTreeItem(rootPath, true)];
+ } else if (stat.isDirectory()) {
+ return [this.createTreeItem(rootPath, false)]; // Show root folder as top node
+ }
+ }
+
+ // Folder node - build its contents
+ const currentPath = element?.resourceUri?.fsPath;
+ if (!currentPath) return [];
+
+ const childNodes = buildPythonTree(currentPath);
+ return childNodes.map(({ fullPath, isFile }) =>
+ this.createTreeItem(fullPath, isFile),
+ );
+ }
+
+ /**
+ * Creates a tree item for a file or folder
+ * @param filePath Path to the file/folder
+ * @param isFile Whether this is a file (false for folders)
+ * @returns Configured TreeItem instance
+ */
+ private createTreeItem(filePath: string, isFile: boolean): TreeItem {
+ const label = path.basename(filePath);
+ const status =
+ this.fileStatuses.get(normalizePath(filePath)) ?? 'not_yet_detected';
+ const icon = isFile ? getStatusIcon(status) : new vscode.ThemeIcon('folder');
+ const tooltip = isFile ? getStatusMessage(status) : undefined;
+
+ // Determine collapsible state:
+ // - Folders are always collapsible
+ // - Files are collapsible only if they have smells
+ const collapsibleState = isFile
+ ? this.fileSmells.has(filePath) && this.fileSmells.get(filePath)!.length > 0
+ ? vscode.TreeItemCollapsibleState.Collapsed
+ : vscode.TreeItemCollapsibleState.None
+ : vscode.TreeItemCollapsibleState.Collapsed;
+
+ const baseContext = isFile ? 'file' : 'directory';
+ const item = new TreeItem(label, filePath, collapsibleState, baseContext);
+ item.iconPath = icon;
+ item.tooltip = tooltip;
+
+ // Mark files with smells with special context
+ if (
+ isFile &&
+ this.fileSmells.has(filePath) &&
+ this.fileSmells.get(filePath)!.length > 0
+ ) {
+ item.contextValue = 'file_with_smells';
+ }
+
+ // Show outdated status in description
+ if (status === 'outdated') {
+ item.description = 'outdated';
+ }
+
+ return item;
+ }
+}
+
+/**
+ * Tree item representing a file or folder in the smells view
+ */
+export class TreeItem extends vscode.TreeItem {
+ constructor(
+ label: string,
+ public readonly fullPath: string,
+ collapsibleState: vscode.TreeItemCollapsibleState,
+ contextValue: string,
+ ) {
+ super(label, collapsibleState);
+ this.resourceUri = vscode.Uri.file(fullPath);
+ this.contextValue = contextValue;
+
+ // Make files clickable to open them
+ if (contextValue === 'file' || contextValue === 'file_with_smells') {
+ this.command = {
+ title: 'Open File',
+ command: 'vscode.open',
+ arguments: [this.resourceUri],
+ };
+ }
+ }
+}
+
+/**
+ * Tree item representing a detected code smell
+ */
+export class SmellTreeItem extends vscode.TreeItem {
+ constructor(public readonly smell: Smell) {
+ // Format label with acronym and line numbers
+ const acronym = getAcronymByMessageId(smell.messageId) ?? smell.messageId;
+ const lines = smell.occurences
+ ?.map((occ) => occ.line)
+ .filter((line) => line !== undefined)
+ .sort((a, b) => a - b)
+ .join(', ');
+
+ const label = lines ? `${acronym}: Line ${lines}` : acronym;
+ super(label, vscode.TreeItemCollapsibleState.None);
+
+ this.tooltip = smell.message;
+ this.contextValue = 'smell';
+ this.iconPath = new vscode.ThemeIcon('snake');
+
+ // Set up command to jump to the first occurrence
+ const firstLine = smell.occurences?.[0]?.line;
+ if (smell.path && typeof firstLine === 'number') {
+ this.command = {
+ title: 'Jump to Smell',
+ command: 'ecooptimizer.jumpToSmell',
+ arguments: [smell.path, firstLine - 1],
+ };
+ }
+ }
+}
+
+/**
+ * Gets the appropriate icon for a file's analysis status
+ * @param status Analysis status string
+ * @returns ThemeIcon with appropriate icon and color
+ */
+export function getStatusIcon(status: string): vscode.ThemeIcon {
+ switch (status) {
+ case 'queued':
+ return new vscode.ThemeIcon(
+ 'sync~spin',
+ new vscode.ThemeColor('charts.yellow'),
+ );
+ case 'passed':
+ return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green'));
+ case 'no_issues':
+ return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.blue'));
+ case 'failed':
+ return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red'));
+ case 'outdated':
+ return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.orange'));
+ case 'server_down':
+ return new vscode.ThemeIcon(
+ 'server-process',
+ new vscode.ThemeColor('charts.red'),
+ );
+ case 'refactoring':
+ return new vscode.ThemeIcon('robot', new vscode.ThemeColor('charts.purple'));
+ case 'accept-refactoring':
+ return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.yellow'));
+ default:
+ return new vscode.ThemeIcon('circle-outline');
+ }
+}
+
+/**
+ * Gets a human-readable message for an analysis status
+ * @param status Analysis status string
+ * @returns Descriptive status message
+ */
+export function getStatusMessage(status: string): string {
+ switch (status) {
+ case 'queued':
+ return 'Analyzing Smells';
+ case 'passed':
+ return 'Smells Successfully Detected';
+ case 'failed':
+ return 'Error Detecting Smells';
+ case 'no_issues':
+ return 'No Smells Found';
+ case 'outdated':
+ return 'File Outdated - Needs Reanalysis';
+ case 'server_down':
+ return 'Server Unavailable';
+ case 'refactoring':
+ return 'Refactoring Currently Ongoing';
+ case 'accept-refactoring':
+ return 'Successfully Refactored - Needs Reanalysis';
+ default:
+ return 'Smells Not Yet Detected';
+ }
+}
diff --git a/src/types.d.ts b/src/types.d.ts
deleted file mode 100644
index a52ddf3..0000000
--- a/src/types.d.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-export interface Occurrence {
- line: number;
- endLine?: number;
- column: number;
- endColumn?: number;
-}
-
-export interface AdditionalInfo {
- // CRC
- repetitions?: number;
- callString?: string;
- // SCL
- concatTarget?: string;
- innerLoopLine?: number;
-}
-
-export interface Smell {
- type: string; // Type of the smell (e.g., "performance", "convention")
- symbol: string; // Symbolic identifier for the smell (e.g., "cached-repeated-calls")
- message: string; // Detailed description of the smell
- messageId: string; // Unique ID for the smell
- confidence: string; // Confidence level (e.g., "HIGH", "MEDIUM")
- path: string; // Optional: absolute file path
- module: string; // Optional: Module name
- obj?: string; // Optional: Object name associated with the smell (if applicable)
- occurences: Occurrence[]; // Optional: List of occurrences for repeated calls
- additionalInfo: AdditionalInfo;
-}
-
-export interface ChangedFile {
- original: string;
- refactored: string;
-}
-
-export interface RefactoredData {
- tempDir: string;
- targetFile: ChangedFile;
- energySaved: number;
- affectedFiles: ChangedFile[];
-}
-
-export interface RefactorOutput {
- refactoredData?: RefactoredData; // Refactored code as a string
- updatedSmells: Smell[]; //
-}
-
-export interface ActiveDiff {
- files: ChangedFile[];
- isOpen: boolean;
- firstOpen: boolean;
-}
-
-export type SmellDetails = {
- symbol: string;
- message: string;
- colour: string; // RGB colour as a string
-};
diff --git a/src/ui/fileHighlighter.ts b/src/ui/fileHighlighter.ts
index 1d95989..e91d62b 100644
--- a/src/ui/fileHighlighter.ts
+++ b/src/ui/fileHighlighter.ts
@@ -1,54 +1,134 @@
import * as vscode from 'vscode';
-import { getEditor } from '../utils/editorUtils';
-import { ContextManager } from '../context/contextManager';
-import { HoverManager } from './hoverManager';
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
+import { ConfigManager } from '../context/configManager';
+import { getEnabledSmells } from '../utils/smellsData';
+/**
+ * The `FileHighlighter` class is responsible for managing and applying visual highlights
+ * to code smells in the VS Code editor. It uses cached smell data to determine which
+ * lines to highlight and applies decorations to the editor accordingly.
+ */
export class FileHighlighter {
- private static instance: FileHighlighter;
- private contextManager: ContextManager;
+ private static instance: FileHighlighter | undefined;
private decorations: vscode.TextEditorDecorationType[] = [];
- private constructor(contextManager: ContextManager) {
- this.contextManager = contextManager;
+ private constructor(private smellsCacheManager: SmellsCacheManager) {
+ this.smellsCacheManager.onSmellsUpdated((target) => {
+ if (target === 'all') {
+ this.updateHighlightsForVisibleEditors();
+ } else {
+ this.updateHighlightsForFile(target);
+ }
+ });
}
- public static getInstance(contextManager: ContextManager): FileHighlighter {
+ /**
+ * Retrieves the singleton instance of the `FileHighlighter` class.
+ * If the instance does not exist, it is created.
+ *
+ * @param smellsCacheManager - The manager responsible for caching and providing smell data.
+ * @returns The singleton instance of `FileHighlighter`.
+ */
+ public static getInstance(
+ smellsCacheManager: SmellsCacheManager,
+ ): FileHighlighter {
if (!FileHighlighter.instance) {
- FileHighlighter.instance = new FileHighlighter(contextManager);
+ FileHighlighter.instance = new FileHighlighter(smellsCacheManager);
}
return FileHighlighter.instance;
}
+ /**
+ * Updates highlights for a specific file if it is currently open in a visible editor.
+ *
+ * @param filePath - The file path of the target file to update highlights for.
+ */
+ private updateHighlightsForFile(filePath: string): void {
+ if (!filePath.endsWith('.py')) {
+ return;
+ }
+
+ const editor = vscode.window.visibleTextEditors.find(
+ (e) => e.document.uri.fsPath === filePath,
+ );
+ if (editor) {
+ this.highlightSmells(editor);
+ }
+ }
+
+ /**
+ * Updates highlights for all currently visible editors.
+ */
+ public updateHighlightsForVisibleEditors(): void {
+ vscode.window.visibleTextEditors.forEach((editor) => {
+ if (!editor.document.fileName.endsWith('.py')) {
+ return;
+ }
+ this.highlightSmells(editor);
+ });
+ }
+
+ /**
+ * Resets all active highlights by disposing of all decorations.
+ */
public resetHighlights(): void {
this.decorations.forEach((decoration) => decoration.dispose());
this.decorations = [];
}
- public highlightSmells(editor: vscode.TextEditor, smells: Smell[]): void {
+ /**
+ * Highlights code smells in the given editor based on cached smell data.
+ * Resets existing highlights before applying new ones.
+ *
+ * @param editor - The text editor to apply highlights to.
+ */
+ public highlightSmells(editor: vscode.TextEditor): void {
this.resetHighlights();
- const config = vscode.workspace.getConfiguration('ecooptimizer.detection');
- const smellsConfig = config.get<{
- [key: string]: { enabled: boolean; colour: string };
- }>('smells', {});
- const useSingleColour = config.get('useSingleColour', false);
- const singleHighlightColour = config.get(
+ const smells = this.smellsCacheManager.getCachedSmells(
+ editor.document.uri.fsPath,
+ );
+
+ if (!smells) {
+ return;
+ }
+
+ const smellColours = ConfigManager.get<{
+ [key: string]: string;
+ }>('smellsColours', {});
+
+ const useSingleColour = ConfigManager.get('useSingleColour', false);
+ const singleHighlightColour = ConfigManager.get(
'singleHighlightColour',
'rgba(255, 204, 0, 0.5)',
);
- const highlightStyle = config.get('highlightStyle', 'underline');
+ const highlightStyle = ConfigManager.get('highlightStyle', 'underline');
const activeSmells = new Set(smells.map((smell) => smell.symbol));
+ const enabledSmells = getEnabledSmells();
+
activeSmells.forEach((smellType) => {
- const smellConfig = smellsConfig[smellType];
- if (smellConfig?.enabled) {
- const colour = useSingleColour ? singleHighlightColour : smellConfig.colour;
+ const smellColour = smellColours[smellType];
+
+ if (enabledSmells[smellType]) {
+ const colour = useSingleColour ? singleHighlightColour : smellColour;
+
this.highlightSmell(editor, smells, smellType, colour, highlightStyle);
}
});
}
+ /**
+ * Highlights a specific type of smell in the given editor.
+ * Filters smell occurrences to ensure they are valid and match the target smell type.
+ *
+ * @param editor - The text editor to apply highlights to.
+ * @param smells - The list of all smells for the file.
+ * @param targetSmell - The specific smell type to highlight.
+ * @param colour - The colour to use for the highlight.
+ * @param style - The style of the highlight (e.g., underline, flashlight, border-arrow).
+ */
private highlightSmell(
editor: vscode.TextEditor,
smells: Smell[],
@@ -59,9 +139,10 @@ export class FileHighlighter {
const smellLines: vscode.DecorationOptions[] = smells
.filter((smell: Smell) => {
const valid = smell.occurences.every((occurrence: { line: number }) =>
- isValidLine(occurrence.line),
+ isValidLine(occurrence.line, editor.document.lineCount),
);
const isCorrectType = smell.symbol === targetSmell;
+
return valid && isCorrectType;
})
.map((smell: Smell) => {
@@ -70,17 +151,22 @@ export class FileHighlighter {
const indexStart = lineText.length - lineText.trimStart().length;
const indexEnd = lineText.trimEnd().length + 2;
const range = new vscode.Range(line, indexStart, line, indexEnd);
-
- const hoverManager = HoverManager.getInstance(this.contextManager, smells);
- return { range, hoverMessage: hoverManager.hoverContent || undefined };
+ return { range };
});
- console.log('Highlighting smell:', targetSmell, colour, style, smellLines);
const decoration = this.getDecoration(colour, style);
+
editor.setDecorations(decoration, smellLines);
this.decorations.push(decoration);
}
+ /**
+ * Creates a text editor decoration type based on the given colour and style.
+ *
+ * @param colour - The colour to use for the decoration.
+ * @param style - The style of the decoration (e.g., underline, flashlight, border-arrow).
+ * @returns A `vscode.TextEditorDecorationType` object representing the decoration.
+ */
private getDecoration(
colour: string,
style: string,
@@ -117,14 +203,15 @@ export class FileHighlighter {
}
}
-function isValidLine(line: any): boolean {
- return (
+function isValidLine(line: any, lineCount: number): boolean {
+ const isValid =
line !== undefined &&
line !== null &&
typeof line === 'number' &&
Number.isFinite(line) &&
line > 0 &&
Number.isInteger(line) &&
- line <= getEditor()!.document.lineCount
- );
+ line <= lineCount;
+
+ return isValid;
}
diff --git a/src/ui/hoverManager.ts b/src/ui/hoverManager.ts
index 09df83e..7e8e0af 100644
--- a/src/ui/hoverManager.ts
+++ b/src/ui/hoverManager.ts
@@ -1,115 +1,89 @@
import * as vscode from 'vscode';
-import {
- refactorSelectedSmell,
- refactorAllSmellsOfType,
-} from '../commands/refactorSmell';
-import { ContextManager } from '../context/contextManager';
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
-export class HoverManager {
- private static instance: HoverManager;
- private smells: Smell[];
- public hoverContent: vscode.MarkdownString;
- private vscodeContext: vscode.ExtensionContext;
+/**
+ * Provides hover information for detected code smells in Python files.
+ * Shows smell details and quick actions when hovering over affected lines.
+ */
+export class HoverManager implements vscode.HoverProvider {
+ constructor(private smellsCacheManager: SmellsCacheManager) {}
- static getInstance(contextManager: ContextManager, smells: Smell[]): HoverManager {
- if (!HoverManager.instance) {
- HoverManager.instance = new HoverManager(contextManager, smells);
- } else {
- HoverManager.instance.updateSmells(smells);
- }
- return HoverManager.instance;
+ /**
+ * Registers the hover provider with VS Code
+ * @param context The extension context for managing disposables
+ */
+ public register(context: vscode.ExtensionContext): void {
+ const selector: vscode.DocumentSelector = {
+ language: 'python',
+ scheme: 'file', // Only show for local files, not untitled documents
+ };
+ const disposable = vscode.languages.registerHoverProvider(selector, this);
+ context.subscriptions.push(disposable);
}
- public constructor(
- private contextManager: ContextManager,
- smells: Smell[],
- ) {
- this.smells = smells || [];
- this.vscodeContext = contextManager.context;
- this.hoverContent = this.registerHoverProvider() ?? new vscode.MarkdownString();
- this.registerCommands();
- }
+ /**
+ * Generates hover content when hovering over lines with detected smells
+ * @param document The active text document
+ * @param position The cursor position where hover was triggered
+ * @returns Hover content or undefined if no smells found
+ */
+ public provideHover(
+ document: vscode.TextDocument,
+ position: vscode.Position,
+ _token: vscode.CancellationToken,
+ ): vscode.ProviderResult {
+ const filePath = document.uri.fsPath;
- public updateSmells(smells: Smell[]): void {
- this.smells = smells || [];
- }
+ if (!filePath.endsWith('.py')) return;
- // Register hover provider for Python files
- public registerHoverProvider(): void {
- this.vscodeContext.subscriptions.push(
- vscode.languages.registerHoverProvider(
- { scheme: 'file', language: 'python' },
- {
- provideHover: (document, position, _token) => {
- const hoverContent = this.getHoverContent(document, position);
- return hoverContent ? new vscode.Hover(hoverContent) : null;
- },
- },
- ),
- );
- }
+ const smells = this.smellsCacheManager.getCachedSmells(filePath);
+ if (!smells || smells.length === 0) return;
- // hover content for detected smells
- getHoverContent(
- document: vscode.TextDocument,
- position: vscode.Position,
- ): vscode.MarkdownString | null {
- const lineNumber = position.line + 1; // convert to 1-based index
- console.log('line number: ' + position.line);
- // filter to find the smells on current line
- const smellsOnLine = this.smells.filter((smell) =>
- smell.occurences.some(
- (occurrence) =>
- occurrence.line === lineNumber ||
- (occurrence.endLine &&
- lineNumber >= occurrence.line &&
- lineNumber <= occurrence.endLine),
- ),
+ // Convert VS Code position to 1-based line number
+ const lineNumber = position.line + 1;
+
+ // Find smells that occur on this line
+ const smellsAtLine = smells.filter((smell) =>
+ smell.occurences.some((occ) => occ.line === lineNumber),
);
- console.log('smells: ' + smellsOnLine);
+ if (smellsAtLine.length === 0) return;
+
+ // Helper to escape markdown special characters
+ const escape = (text: string): string => {
+ return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&');
+ };
+
+ // Build markdown content with smell info and actions
+ const markdown = new vscode.MarkdownString();
+ markdown.isTrusted = true; // Allow command URIs
+ markdown.supportHtml = true;
+ markdown.supportThemeIcons = true;
- if (smellsOnLine.length === 0) {
- return null;
- }
+ // Add each smell's info and actions
+ smellsAtLine.forEach((smell) => {
+ // Basic smell info
+ const messageLine = `${escape(smell.message)} (**${escape(smell.messageId)}**)`;
+ const divider = '\n\n---\n\n'; // Visual separator
- const hoverContent = new vscode.MarkdownString();
- hoverContent.isTrusted = true; // Allow command links
+ // Command URIs for quick actions
+ const refactorSmellCmd = `command:ecooptimizer.refactorSmell?${encodeURIComponent(JSON.stringify(smell))} "Fix this specific smell"`;
+ const refactorTypeCmd = `command:ecooptimizer.refactorAllSmellsOfType?${encodeURIComponent(
+ JSON.stringify({
+ fullPath: filePath,
+ smellType: smell.messageId,
+ }),
+ )} "Fix all similar smells"`;
- smellsOnLine.forEach((smell) => {
- hoverContent.appendMarkdown(
- `**${smell.symbol}:** ${smell.message}\t\t` +
- `[Refactor](command:extension.refactorThisSmell?${encodeURIComponent(
- JSON.stringify(smell),
- )})\t\t` +
- `---[Refactor all smells of this type...](command:extension.refactorAllSmellsOfType?${encodeURIComponent(
- JSON.stringify(smell),
- )})\n\n`,
+ // Build the hover content
+ markdown.appendMarkdown(messageLine);
+ markdown.appendMarkdown(divider);
+ markdown.appendMarkdown(`[$(tools) Refactor Smell](${refactorSmellCmd}) | `);
+ markdown.appendMarkdown(
+ `[$(tools) Refactor All of This Type](${refactorTypeCmd})`,
);
- console.log(hoverContent);
});
- return hoverContent;
- }
-
- // Register commands for refactor actions
- public registerCommands(): void {
- this.vscodeContext.subscriptions.push(
- vscode.commands.registerCommand(
- 'extension.refactorThisSmell',
- async (smell: Smell) => {
- const contextManager = new ContextManager(this.vscodeContext);
- await refactorSelectedSmell(contextManager, smell);
- },
- ),
- // clicking "Refactor All Smells of this Type..."
- vscode.commands.registerCommand(
- 'extension.refactorAllSmellsOfType',
- async (smell: Smell) => {
- const contextManager = new ContextManager(this.vscodeContext);
- await refactorAllSmellsOfType(contextManager, smell.messageId);
- },
- ),
- );
+ return new vscode.Hover(markdown);
}
}
diff --git a/src/ui/lineSelectionManager.ts b/src/ui/lineSelectionManager.ts
index 29340ab..5b1c1e2 100644
--- a/src/ui/lineSelectionManager.ts
+++ b/src/ui/lineSelectionManager.ts
@@ -1,94 +1,93 @@
import * as vscode from 'vscode';
-import { ContextManager } from '../context/contextManager';
-import { envConfig } from '../utils/envConfig';
-import { SmellDetectRecord } from '../commands/detectSmells';
-import { hashContent } from '../utils/hashDocs';
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
+/**
+ * Manages line selection and decoration in a VS Code editor, specifically for
+ * displaying comments related to code smells.
+ */
export class LineSelectionManager {
- private contextManager;
private decoration: vscode.TextEditorDecorationType | null = null;
-
- public constructor(contextManager: ContextManager) {
- this.contextManager = contextManager;
+ private lastDecoratedLine: number | null = null;
+
+ constructor(private smellsCacheManager: SmellsCacheManager) {
+ // Listen for smell cache being cleared for any file
+ this.smellsCacheManager.onSmellsUpdated((targetFilePath) => {
+ if (targetFilePath === 'all') {
+ this.removeLastComment();
+ return;
+ }
+
+ const activeEditor = vscode.window.activeTextEditor;
+ if (activeEditor && activeEditor.document.uri.fsPath === targetFilePath) {
+ this.removeLastComment();
+ }
+ });
}
+ /**
+ * Removes the last applied decoration from the editor, if any.
+ */
public removeLastComment(): void {
if (this.decoration) {
- console.log('Removing decoration');
this.decoration.dispose();
+ this.decoration = null;
}
+ this.lastDecoratedLine = null;
}
+ /**
+ * Adds a comment to the currently selected line in the editor.
+ */
public commentLine(editor: vscode.TextEditor): void {
- this.removeLastComment();
-
- if (!editor) {
- return;
- }
+ if (!editor) return;
const filePath = editor.document.fileName;
- const smellsDetectRecord = this.contextManager.getWorkspaceData(
- envConfig.SMELL_MAP_KEY!,
- )[filePath] as SmellDetectRecord;
-
- if (!smellsDetectRecord) {
- return;
- }
-
- if (smellsDetectRecord.hash !== hashContent(editor.document.getText())) {
+ const smells = this.smellsCacheManager.getCachedSmells(filePath);
+ if (!smells) {
+ this.removeLastComment(); // If cache is gone, clear any previous comment
return;
}
const { selection } = editor;
-
- if (!selection.isSingleLine) {
- return;
- }
+ if (!selection.isSingleLine) return;
const selectedLine = selection.start.line;
- console.log(`selection: ${selectedLine}`);
- const smells = smellsDetectRecord.smells;
+ if (this.lastDecoratedLine === selectedLine) return;
- const smellsAtLine = smells.filter((smell) => {
- return smell.occurences[0].line === selectedLine + 1;
- });
+ this.removeLastComment();
+ this.lastDecoratedLine = selectedLine;
- if (smellsAtLine.length === 0) {
- return;
- }
+ const smellsAtLine = smells.filter((smell) =>
+ smell.occurences.some((occ) => occ.line === selectedLine + 1),
+ );
- let comment;
+ if (smellsAtLine.length === 0) return;
+ let comment = `🍂 Smell: ${smellsAtLine[0].symbol}`;
if (smellsAtLine.length > 1) {
- comment = `🍂 Smell: ${smellsAtLine[0].symbol} | (+${
- smellsAtLine.length - 1
- })`;
- } else {
- comment = `🍂 Smell: ${smellsAtLine[0].symbol}`;
+ comment += ` | (+${smellsAtLine.length - 1})`;
}
+ const themeColor = new vscode.ThemeColor('editorLineNumber.foreground');
this.decoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
after: {
contentText: comment,
- color: 'rgb(153, 211, 212)',
+ color: themeColor,
margin: '0 0 0 10px',
textDecoration: 'none',
},
});
- const selectionLine: vscode.Range[] = [];
-
- const line_text = editor.document.lineAt(selectedLine).text;
- const line_length = line_text.length;
- const indexStart = line_length - line_text.trimStart().length;
- const indexEnd = line_text.trimEnd().length + 1;
-
- selectionLine.push(
- new vscode.Range(selectedLine, indexStart, selectedLine, indexEnd),
+ const lineText = editor.document.lineAt(selectedLine).text;
+ const range = new vscode.Range(
+ selectedLine,
+ 0,
+ selectedLine,
+ lineText.trimEnd().length + 1,
);
- editor.setDecorations(this.decoration, selectionLine);
+ editor.setDecorations(this.decoration, [range]);
}
}
diff --git a/src/ui/refactorView.ts b/src/ui/refactorView.ts
deleted file mode 100644
index ebd9709..0000000
--- a/src/ui/refactorView.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import * as vscode from 'vscode';
-import path from 'path';
-import * as fs from 'fs';
-
-import { envConfig } from '../utils/envConfig';
-import { readFileSync } from 'fs';
-import { sidebarState } from '../utils/handleEditorChange';
-import { MultiRefactoredData } from '../commands/refactorSmell';
-
-export class RefactorSidebarProvider implements vscode.WebviewViewProvider {
- public static readonly viewType = 'extension.refactorSidebar';
- private _view?: vscode.WebviewView;
- private _file_map: Map = new Map();
-
- constructor(private readonly _context: vscode.ExtensionContext) {}
-
- resolveWebviewView(
- webviewView: vscode.WebviewView,
- // eslint-disable-next-line unused-imports/no-unused-vars
- context: vscode.WebviewViewResolveContext,
- _token: vscode.CancellationToken,
- ): void {
- this._view = webviewView;
- const webview = webviewView.webview;
-
- webview.options = {
- enableScripts: true,
- };
-
- webview.html = this._getHtml(webview);
-
- webviewView.onDidChangeVisibility(async () => {
- console.log('Webview is visible');
- if (webviewView.visible) {
- // Use acquireVsCodeApi to get the webview state
- const savedState = this._context.workspaceState.get(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- );
-
- if (savedState) {
- this.updateView();
- return;
- }
- }
- });
-
- webviewView.onDidDispose(() => {
- console.log('Webview Disposed');
- });
-
- webviewView.webview.onDidReceiveMessage(async (message) => {
- switch (message.command) {
- case 'selectFile':
- sidebarState.isOpening = true;
- console.log('Switching diff file view.');
- await vscode.commands.executeCommand(
- 'vscode.diff',
- vscode.Uri.file(message.original),
- vscode.Uri.file(message.refactored),
- 'Refactoring Comparison',
- );
- sidebarState.isOpening = false;
- break;
- case 'accept':
- await this.applyRefactoring();
- await this.closeViews();
- break;
- case 'reject':
- await this.closeViews();
- break;
- }
- });
- console.log('Initialized sidebar view');
- }
-
- async updateView(): Promise {
- console.log('Updating view');
- const refactoredData = this._context.workspaceState.get(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- )!;
-
- this._file_map.set(
- vscode.Uri.file(refactoredData.targetFile.original),
- vscode.Uri.file(refactoredData.targetFile.refactored),
- );
-
- refactoredData.affectedFiles.forEach(({ original, refactored }) => {
- this._file_map!.set(vscode.Uri.file(original), vscode.Uri.file(refactored));
- });
-
- if (this._view) {
- this.openView(refactoredData);
- }
- }
-
- private async openView(refactoredData: RefactoredData): Promise {
- const diffView = this._context.workspaceState.get(
- envConfig.ACTIVE_DIFF_KEY!,
- )!;
-
- if (diffView.isOpen) {
- console.log('starting view');
- this._view!.show(true);
- this._view!.webview.postMessage({
- command: 'update',
- data: refactoredData,
- sep: path.sep,
- });
- } else {
- console.log('Gonna pause');
- this.pauseView();
- }
- }
-
- async pauseView(): Promise {
- console.log('pausing view');
- this._view!.webview.postMessage({ command: 'pause' });
- }
-
- async clearView(): Promise {
- await this._view?.webview.postMessage({ command: 'clear' });
- this._file_map = new Map();
-
- console.log('View cleared');
- }
-
- private _getHtml(webview: vscode.Webview): string {
- const scriptUri = webview.asWebviewUri(
- vscode.Uri.file(path.join(this._context.extensionPath, 'media', 'script.js')),
- );
- const customCssUri = webview.asWebviewUri(
- vscode.Uri.file(path.join(this._context.extensionPath, 'media', 'style.css')),
- );
- const vscodeCssUri = webview.asWebviewUri(
- vscode.Uri.file(path.join(this._context.extensionPath, 'media', 'vscode.css')),
- );
- const htmlPath = path.join(this._context.extensionPath, 'media', 'webview.html');
- let htmlContent = readFileSync(htmlPath, 'utf8');
-
- // Inject the script URI dynamically
- htmlContent = htmlContent.replace('${vscodeCssUri}', vscodeCssUri.toString());
- htmlContent = htmlContent.replace('${customCssUri}', customCssUri.toString());
- htmlContent = htmlContent.replace('${scriptUri}', scriptUri.toString());
-
- return htmlContent;
- }
-
- private async closeViews(): Promise {
- await this.clearView();
- try {
- await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
- await vscode.commands.executeCommand('workbench.view.explorer');
-
- await this._context.workspaceState.update(
- envConfig.ACTIVE_DIFF_KEY!,
- undefined,
- );
-
- const tempDirs =
- this._context.workspaceState.get(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- )?.tempDir ||
- this._context.workspaceState.get(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- )?.tempDirs;
-
- if (Array.isArray(tempDirs)) {
- for (const dir in tempDirs) {
- await fs.promises.rm(dir, { recursive: true, force: true });
- }
- } else if (tempDirs) {
- await fs.promises.rm(tempDirs, { recursive: true, force: true });
- }
- } catch (err) {
- console.error('Error closing views', err);
- }
-
- console.log('Closed views');
-
- await this._context.workspaceState.update(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- undefined,
- );
- }
-
- private async applyRefactoring(): Promise {
- try {
- for (const [original, refactored] of this._file_map.entries()) {
- const content = await vscode.workspace.fs.readFile(refactored);
- await vscode.workspace.fs.writeFile(original, content);
- console.log(`Applied refactoring to ${original.fsPath}`);
- }
- vscode.window.showInformationMessage('Refactoring applied successfully!');
- } catch (error) {
- console.error('Error applying refactoring:', error);
- }
- }
-}
diff --git a/src/utils/TreeStructureBuilder.ts b/src/utils/TreeStructureBuilder.ts
new file mode 100644
index 0000000..8ad1a4a
--- /dev/null
+++ b/src/utils/TreeStructureBuilder.ts
@@ -0,0 +1,117 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { normalizePath } from './normalizePath';
+
+/**
+ * Options for configuring tree node appearance and behavior in the VS Code UI
+ */
+export interface TreeNodeOptions {
+ /** Determines context menu commands and visibility rules */
+ contextValue: string;
+ /** Optional icon from VS Code's icon set */
+ icon?: vscode.ThemeIcon;
+ /** Tooltip text shown on hover */
+ tooltip?: string;
+ /** Command to execute when node is clicked */
+ command?: vscode.Command;
+}
+
+/**
+ * Represents a node in the file system tree structure
+ */
+export interface TreeNode {
+ /** Display name in the tree view */
+ label: string;
+ /** Absolute filesystem path */
+ fullPath: string;
+ /** Whether this represents a file (true) or directory (false) */
+ isFile: boolean;
+ /** Additional UI/behavior configuration */
+ options?: TreeNodeOptions;
+}
+
+/**
+ * Builds a hierarchical tree structure of Python files and directories containing Python files
+ * @param rootPath - The absolute path to start building the tree from
+ * @returns Array of TreeNode objects representing the directory structure
+ */
+export function buildPythonTree(rootPath: string): TreeNode[] {
+ const nodes: TreeNode[] = [];
+
+ try {
+ const entries = fs.readdirSync(rootPath);
+
+ for (const entry of entries) {
+ const fullPath = normalizePath(path.join(rootPath, entry));
+ const stat = fs.statSync(fullPath);
+
+ if (stat.isDirectory()) {
+ // Only include directories that contain Python files
+ if (containsPythonFiles(fullPath)) {
+ nodes.push({
+ label: entry,
+ fullPath,
+ isFile: false,
+ options: {
+ contextValue: 'folder',
+ icon: vscode.ThemeIcon.Folder,
+ },
+ });
+ }
+ } else if (stat.isFile() && entry.endsWith('.py')) {
+ nodes.push({
+ label: entry,
+ fullPath,
+ isFile: true,
+ options: {
+ contextValue: 'file',
+ icon: vscode.ThemeIcon.File,
+ },
+ });
+ }
+ }
+ } catch (err) {
+ vscode.window.showErrorMessage(`Failed to read directory: ${rootPath}`);
+ console.error(`Directory read error: ${err}`);
+ }
+
+ return nodes.sort((a, b) => {
+ // Directories first, then alphabetical
+ if (!a.isFile && b.isFile) return -1;
+ if (a.isFile && !b.isFile) return 1;
+ return a.label.localeCompare(b.label);
+ });
+}
+
+/**
+ * Recursively checks if a directory contains any Python files
+ * @param folderPath - Absolute path to the directory to check
+ * @returns True if any .py files exist in this directory or subdirectories
+ */
+function containsPythonFiles(folderPath: string): boolean {
+ try {
+ const entries = fs.readdirSync(folderPath);
+
+ for (const entry of entries) {
+ const fullPath = normalizePath(path.join(folderPath, entry));
+ const stat = fs.statSync(fullPath);
+
+ if (stat.isFile() && entry.endsWith('.py')) {
+ return true;
+ }
+
+ if (stat.isDirectory()) {
+ // Short-circuit if any subdirectory contains Python files
+ if (containsPythonFiles(fullPath)) {
+ return true;
+ }
+ }
+ }
+ } catch (err) {
+ vscode.window.showErrorMessage(`Failed to scan directory: ${folderPath}`);
+ console.error(`Directory scan error: ${err}`);
+ }
+
+ return false;
+}
diff --git a/src/utils/configManager.ts b/src/utils/configManager.ts
deleted file mode 100644
index 45e49f5..0000000
--- a/src/utils/configManager.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as vscode from 'vscode';
-
-export class ConfigManager {
- // resolve ${workspaceFolder} placeholder
- private static resolvePath(path: string): string {
- const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
- return path.replace('${workspaceFolder}', workspaceFolder);
- }
-
- // get workspace path
- static getWorkspacePath(): string {
- const rawPath = vscode.workspace
- .getConfiguration('ecooptimizer')
- .get('projectWorkspacePath', '');
- const resolvedPath =
- rawPath || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
-
- // write to both User and Workspace settings if not already set
- this.writeSetting('projectWorkspacePath', resolvedPath);
-
- return resolvedPath;
- }
-
- // get logs output path
- static getLogsOutputPath(): string {
- const rawPath = vscode.workspace
- .getConfiguration('ecooptimizer')
- .get('logsOutputPath', '');
- const workspacePath = this.getWorkspacePath();
- const resolvedPath = rawPath || `${workspacePath}/logs`;
-
- // write to both User and Workspace settings if not already set
- this.writeSetting('logsOutputPath', resolvedPath);
-
- return resolvedPath;
- }
-
- // listen for configuration changes
- static onConfigChange(callback: () => void): void {
- vscode.workspace.onDidChangeConfiguration((event) => {
- if (
- event.affectsConfiguration('ecooptimizer.projectWorkspacePath') ||
- event.affectsConfiguration('ecooptimizer.logsOutputPath')
- ) {
- callback();
- }
- });
- }
-
- // write settings to both User and Workspace if necessary
- private static writeSetting(setting: string, value: string): void {
- const config = vscode.workspace.getConfiguration('ecooptimizer');
-
- // inspect current values in both User and Workspace settings
- const currentValueGlobal = config.inspect(setting)?.globalValue;
- const currentValueWorkspace = config.inspect(setting)?.workspaceValue;
-
- // update User Settings (Global) if empty
- if (!currentValueGlobal || currentValueGlobal.trim() === '') {
- config.update(setting, value, vscode.ConfigurationTarget.Global);
- }
-
- // update Workspace Settings if empty
- if (!currentValueWorkspace || currentValueWorkspace.trim() === '') {
- config.update(setting, value, vscode.ConfigurationTarget.Workspace);
- }
- }
-}
diff --git a/src/utils/editorUtils.ts b/src/utils/editorUtils.ts
deleted file mode 100644
index e15d1ca..0000000
--- a/src/utils/editorUtils.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as vscode from 'vscode';
-
-/**
- * Gets the active editor and its file path if an editor is open.
- * @returns {{ editor: vscode.TextEditor | undefined, filePath: string | undefined }}
- * An object containing the active editor and the file path, or undefined for both if no editor is open.
- */
-export function getEditorAndFilePath(): {
- editor: vscode.TextEditor | undefined;
- filePath: string | undefined;
-} {
- const activeEditor = vscode.window.activeTextEditor;
- const filePath = activeEditor?.document.uri.fsPath;
- return { editor: activeEditor, filePath };
-}
-
-/**
- * Gets the active editor if an editor is open.
- */
-export function getEditor(): vscode.TextEditor | undefined {
- return vscode.window.activeTextEditor;
-}
diff --git a/src/utils/envConfig.ts b/src/utils/envConfig.ts
index 60bd31d..45f3bd0 100644
--- a/src/utils/envConfig.ts
+++ b/src/utils/envConfig.ts
@@ -1,24 +1,17 @@
-import * as dotenv from 'dotenv';
-
-dotenv.config();
-
export interface EnvConfig {
SERVER_URL?: string;
- SMELL_MAP_KEY?: string;
- FILE_CHANGES_KEY?: string;
- LAST_USED_SMELLS_KEY?: string;
- CURRENT_REFACTOR_DATA_KEY?: string;
- ACTIVE_DIFF_KEY?: string;
- SMELL_LINTING_ENABLED_KEY: string;
+ SMELL_CACHE_KEY?: string;
+ HASH_PATH_MAP_KEY?: string;
+ WORKSPACE_METRICS_DATA?: string;
+ WORKSPACE_CONFIGURED_PATH?: string;
+ UNFINISHED_REFACTORING?: string;
}
export const envConfig: EnvConfig = {
- SERVER_URL: process.env.SERVER_URL,
- SMELL_MAP_KEY: process.env.SMELL_MAP_KEY,
- FILE_CHANGES_KEY: process.env.FILE_CHANGES_KEY,
- LAST_USED_SMELLS_KEY: process.env.LAST_USED_SMELLS_KEY,
- CURRENT_REFACTOR_DATA_KEY: process.env.CURRENT_REFACTOR_DATA_KEY,
- ACTIVE_DIFF_KEY: process.env.ACTIVE_DIFF_KEY,
- SMELL_LINTING_ENABLED_KEY:
- process.env.SMELL_LINTING_ENABLED_KEY || 'eco.smellLintingEnabled',
+ SERVER_URL: '127.0.0.1:8000',
+ SMELL_CACHE_KEY: 'smellCacheKey',
+ HASH_PATH_MAP_KEY: 'hashMapKey',
+ WORKSPACE_METRICS_DATA: 'workspaceMetrics',
+ WORKSPACE_CONFIGURED_PATH: 'workspacePath',
+ UNFINISHED_REFACTORING: 'pastRefactor',
};
diff --git a/src/utils/handleEditorChange.ts b/src/utils/handleEditorChange.ts
deleted file mode 100644
index 123745c..0000000
--- a/src/utils/handleEditorChange.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import * as vscode from 'vscode';
-import { setTimeout } from 'timers/promises';
-
-import { envConfig } from './envConfig';
-import { ContextManager } from '../context/contextManager';
-
-interface DiffInfo {
- original: vscode.Uri;
- modified: vscode.Uri;
-}
-
-export let sidebarState = { isOpening: false };
-
-export async function handleEditorChanges(
- contextManager: ContextManager,
- editors: readonly vscode.TextEditor[],
-): Promise {
- console.log('Detected visible editor change');
- const diffState = contextManager.getWorkspaceData(
- envConfig.ACTIVE_DIFF_KEY!,
- );
- const refactorData = contextManager.getWorkspaceData(
- envConfig.CURRENT_REFACTOR_DATA_KEY!,
- );
-
- if (sidebarState.isOpening) {
- return;
- }
-
- if (!diffState) {
- console.log('No active refactoring session');
- return;
- }
-
- // console.log(`diffstate: ${diffState.isOpen}`);
- // console.log(`diffstate: ${JSON.stringify(diffState)}`);
- // console.log(`Editors: ${JSON.stringify(editors)}`);
-
- // Is a diff editor for a refactoring
- const isDiffRefactorEditor = isDiffEditorOpen(editors, diffState);
-
- if (diffState.isOpen) {
- // User either closed or switched diff editor
- // console.log(`refactor data: ${JSON.stringify(refactorData)}`);
- // console.log(`is diff editor: ${isDiffRefactorEditor}`);
-
- if (isDiffRefactorEditor === undefined) {
- return;
- }
-
- if ((!isDiffRefactorEditor || !refactorData) && !diffState.firstOpen) {
- console.log('Diff editor no longer active');
- diffState.isOpen = false;
- // console.log(`diffstate: ${diffState.isOpen}`);
- // console.log(`diffstate: ${JSON.stringify(diffState)}`);
- contextManager.setWorkspaceData(envConfig.ACTIVE_DIFF_KEY!, diffState);
- await setTimeout(500);
- // vscode.commands.executeCommand(
- // 'ecooptimizer.pauseRefactorSidebar'
- // );
- return;
- }
- if (diffState.firstOpen) {
- diffState.firstOpen = false;
- contextManager.setWorkspaceData(envConfig.ACTIVE_DIFF_KEY!, diffState);
- await setTimeout(500);
- }
- // switched from one diff editor to another, no handling needed
- console.log('continuing');
- return;
- }
-
- // Diff editor was reopened (switch back to)
- else if (isDiffRefactorEditor) {
- console.log('Opening Sidebar');
- // console.log(`diffstate: ${diffState.isOpen}`);
- diffState.isOpen = true;
- // console.log(`diffstate: ${JSON.stringify(diffState)}`);
- contextManager.setWorkspaceData(envConfig.ACTIVE_DIFF_KEY!, diffState);
- await setTimeout(500);
- vscode.commands.executeCommand('ecooptimizer.showRefactorSidebar');
- }
- console.log('Doing nothing');
-}
-
-function isDiffEditorOpen(
- editors: readonly vscode.TextEditor[],
- diffState: ActiveDiff,
-): boolean | undefined {
- console.log('Checking if editor is a diff editor');
- if (!editors.length) {
- console.log('No editors found');
- return undefined;
- }
-
- // @ts-ignore
- const diffInfo: DiffInfo[] = editors[0].diffInformation;
- // console.log(`Diff Info: ${JSON.stringify(diffInfo)}`);
-
- if (!diffInfo && editors.length === 2) {
- console.log('Checking first case');
-
- return diffState.files.some((file) => {
- // console.log(`file: ${JSON.stringify(file)}`);
- return (
- (file.original === editors[0].document.uri.toString() &&
- file.refactored === editors[1].document.uri.toString()) ||
- (file.refactored === editors[0].document.uri.toString() &&
- file.original === editors[1].document.uri.toString())
- );
- });
- } else if (diffInfo && diffInfo.length === 1) {
- console.log('Checking second case');
- return diffState.files.some((file) => {
- // console.log(`file: ${JSON.stringify(file)}`);
- return (
- (file.original === diffInfo[0].original.toString() &&
- file.refactored === diffInfo[0].modified.toString()) ||
- (file.original === diffInfo[0].modified.toString() &&
- file.refactored === diffInfo[0].original.toString())
- );
- });
- }
-
- return false;
-}
diff --git a/src/utils/handleSmellSettings.ts b/src/utils/handleSmellSettings.ts
deleted file mode 100644
index 2fad66b..0000000
--- a/src/utils/handleSmellSettings.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as vscode from 'vscode';
-import { wipeWorkCache } from '../commands/wipeWorkCache';
-import { ContextManager } from '../context/contextManager';
-
-/**
- * Fetches the current enabled smells from VS Code settings.
- */
-export function getEnabledSmells(): {
- [key: string]: boolean;
-} {
- const smellConfig = vscode.workspace
- .getConfiguration('detection')
- .get('smells', {}) as { [key: string]: { enabled: boolean; colour: string } };
-
- return Object.fromEntries(
- Object.entries(smellConfig).map(([smell, config]) => [smell, config.enabled]),
- );
-}
-
-/**
- * Handles when a user updates the smell filter in settings.
- * It wipes the cache and notifies the user about changes.
- */
-export function handleSmellFilterUpdate(
- previousSmells: { [key: string]: boolean },
- contextManager: ContextManager,
-): void {
- const currentSmells = getEnabledSmells();
- let smellsChanged = false;
-
- Object.entries(currentSmells).forEach(([smell, isEnabled]) => {
- if (previousSmells[smell] !== isEnabled) {
- smellsChanged = true;
- vscode.window.showInformationMessage(
- isEnabled
- ? `Eco: Enabled detection of ${formatSmellName(smell)}.`
- : `Eco: Disabled detection of ${formatSmellName(smell)}.`,
- );
- }
- });
-
- if (smellsChanged) {
- console.log('Eco: Smell preferences changed! Wiping cache.');
- wipeWorkCache(contextManager, 'settings');
- }
-}
-
-/**
- * Formats the smell name from kebab-case to a readable format.
- */
-export function formatSmellName(smellKey: string): string {
- return smellKey.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
-}
diff --git a/src/utils/hashDocs.ts b/src/utils/hashDocs.ts
deleted file mode 100644
index 3baa19c..0000000
--- a/src/utils/hashDocs.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import crypto from 'crypto';
-import { ContextManager } from '../context/contextManager';
-import { envConfig } from './envConfig';
-import * as vscode from 'vscode';
-
-// Function to hash the document content
-export function hashContent(content: string): string {
- return crypto.createHash('sha256').update(content).digest('hex');
-}
-
-// Function to update the stored hashes in workspace storage
-export async function updateHash(
- contextManager: ContextManager,
- document: vscode.TextDocument,
-): Promise {
- const lastSavedHashes = contextManager.getWorkspaceData(
- envConfig.FILE_CHANGES_KEY!,
- {},
- );
- const lastHash = lastSavedHashes[document.fileName];
- const currentHash = hashContent(document.getText());
-
- if (!lastHash || lastHash !== currentHash) {
- console.log(`Document ${document.fileName} has changed since last save.`);
- lastSavedHashes[document.fileName] = currentHash;
- await contextManager.setWorkspaceData(
- envConfig.FILE_CHANGES_KEY!,
- lastSavedHashes,
- );
- }
-}
diff --git a/src/utils/initializeStatusesFromCache.ts b/src/utils/initializeStatusesFromCache.ts
new file mode 100644
index 0000000..27beaeb
--- /dev/null
+++ b/src/utils/initializeStatusesFromCache.ts
@@ -0,0 +1,108 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs/promises';
+import { SmellsCacheManager } from '../context/SmellsCacheManager';
+import { SmellsViewProvider } from '../providers/SmellsViewProvider';
+import { ecoOutput } from '../extension';
+import { normalizePath } from './normalizePath';
+import { envConfig } from './envConfig';
+
+/**
+ * Initializes file statuses and smells in the SmellsViewProvider from the smell cache.
+
+ * @param context The extension context containing workspace configuration
+ * @param smellsCacheManager The cache manager instance
+ * @param smellsViewProvider The view provider to update with cached data
+ */
+export async function initializeStatusesFromCache(
+ context: vscode.ExtensionContext,
+ smellsCacheManager: SmellsCacheManager,
+ smellsViewProvider: SmellsViewProvider,
+): Promise {
+ ecoOutput.info('workspace key: ', envConfig.WORKSPACE_CONFIGURED_PATH);
+
+ // Get configured workspace path from extension state
+ let configuredPath = context.workspaceState.get(
+ envConfig.WORKSPACE_CONFIGURED_PATH!,
+ );
+
+ if (!configuredPath) {
+ ecoOutput.warn(
+ '[CacheInit] No configured workspace path found - skipping cache initialization',
+ );
+ return;
+ }
+
+ configuredPath = normalizePath(configuredPath);
+ ecoOutput.info(
+ `[CacheInit] Starting cache initialization for workspace: ${configuredPath}`,
+ );
+
+ // Get all cached file paths and initialize counters
+ const pathMap = smellsCacheManager.getAllFilePaths();
+ ecoOutput.trace(`[CacheInit] Found ${pathMap.length} files in cache`);
+ ecoOutput.trace(`[CacheInit] Found ${pathMap} files in cache`);
+
+ let validFiles = 0;
+ let removedFiles = 0;
+ let filesWithSmells = 0;
+ let cleanFiles = 0;
+
+ // Process each cached file
+ for (const filePath of pathMap) {
+ ecoOutput.trace(`[CacheInit] Processing cache entry: ${filePath}`);
+
+ // Skip files outside the current workspace
+ if (!filePath.startsWith(configuredPath)) {
+ ecoOutput.trace(
+ `[CacheInit] File outside workspace - removing from cache: ${filePath}`,
+ );
+ await smellsCacheManager.clearCachedSmellsForFile(filePath);
+ removedFiles++;
+ continue;
+ }
+
+ // Verify file exists on disk
+ try {
+ await fs.access(filePath);
+ ecoOutput.trace(`[CacheInit] File verified: ${filePath}`);
+ } catch {
+ ecoOutput.trace(
+ `[CacheInit] File not found - removing from cache: ${filePath}`,
+ );
+ await smellsCacheManager.clearCachedSmellsForFile(filePath);
+ removedFiles++;
+ continue;
+ }
+
+ // Get cached smells for valid files
+ const smells = smellsCacheManager.getCachedSmells(filePath);
+ if (smells !== undefined) {
+ validFiles++;
+
+ // Update view provider based on smell data
+ if (smells.length > 0) {
+ ecoOutput.trace(
+ `[CacheInit] Found ${smells.length} smells for file: ${filePath}`,
+ );
+ smellsViewProvider.setStatus(filePath, 'passed');
+ smellsViewProvider.setSmells(filePath, smells);
+ filesWithSmells++;
+ } else {
+ ecoOutput.trace(`[CacheInit] File has no smells: ${filePath}`);
+ smellsViewProvider.setStatus(filePath, 'no_issues');
+ cleanFiles++;
+ }
+ } else {
+ ecoOutput.trace(
+ `[CacheInit] No cache data found for file (should not happen): ${filePath}`,
+ );
+ }
+ }
+
+ // Log summary statistics
+ ecoOutput.info(
+ `[CacheInit] Cache initialization complete. ` +
+ `Results: ${validFiles} valid files (${filesWithSmells} with smells, ${cleanFiles} clean), ` +
+ `${removedFiles} files removed from cache`,
+ );
+}
diff --git a/src/utils/normalizePath.ts b/src/utils/normalizePath.ts
new file mode 100644
index 0000000..7071fcb
--- /dev/null
+++ b/src/utils/normalizePath.ts
@@ -0,0 +1,8 @@
+/**
+ * Normalizes file paths for consistent comparison and caching
+ * @param filePath - The file path to normalize
+ * @returns Lowercase version of the path for case-insensitive operations
+ */
+export function normalizePath(filePath: string): string {
+ return filePath.toLowerCase();
+}
diff --git a/src/utils/refactorActionButtons.ts b/src/utils/refactorActionButtons.ts
new file mode 100644
index 0000000..f4c9a93
--- /dev/null
+++ b/src/utils/refactorActionButtons.ts
@@ -0,0 +1,71 @@
+import * as vscode from 'vscode';
+import { ecoOutput } from '../extension';
+
+let acceptButton: vscode.StatusBarItem | undefined;
+let rejectButton: vscode.StatusBarItem | undefined;
+
+/**
+ * Create and register the status bar buttons (called once at activation).
+ */
+export function initializeRefactorActionButtons(
+ context: vscode.ExtensionContext,
+): void {
+ ecoOutput.trace('Initializing refactor action buttons...');
+
+ acceptButton = vscode.window.createStatusBarItem(
+ vscode.StatusBarAlignment.Right,
+ 0,
+ );
+ rejectButton = vscode.window.createStatusBarItem(
+ vscode.StatusBarAlignment.Right,
+ 1,
+ );
+
+ acceptButton.text = '$(check) ACCEPT REFACTOR';
+ acceptButton.command = 'ecooptimizer.acceptRefactoring';
+ acceptButton.tooltip = 'Accept and apply the suggested refactoring';
+ acceptButton.color = new vscode.ThemeColor('charts.green');
+
+ rejectButton.text = '$(x) REJECT REFACTOR';
+ rejectButton.command = 'ecooptimizer.rejectRefactoring';
+ rejectButton.tooltip = 'Reject the suggested refactoring';
+ rejectButton.color = new vscode.ThemeColor('charts.red');
+
+ context.subscriptions.push(acceptButton, rejectButton);
+
+ ecoOutput.trace('Status bar buttons created and registered.');
+}
+
+/**
+ * Show the status bar buttons when a refactoring is in progress.
+ */
+export function showRefactorActionButtons(): void {
+ if (!acceptButton || !rejectButton) {
+ ecoOutput.trace(
+ '❌ Tried to show refactor buttons but they are not initialized.',
+ );
+ return;
+ }
+
+ ecoOutput.trace('Showing refactor action buttons...');
+ acceptButton.show();
+ rejectButton.show();
+ vscode.commands.executeCommand('setContext', 'refactoringInProgress', true);
+}
+
+/**
+ * Hide the status bar buttons when the refactoring ends.
+ */
+export function hideRefactorActionButtons(): void {
+ if (!acceptButton || !rejectButton) {
+ ecoOutput.trace(
+ '❌ Tried to hide refactor buttons but they are not initialized.',
+ );
+ return;
+ }
+
+ ecoOutput.replace('Hiding refactor action buttons...');
+ acceptButton.hide();
+ rejectButton.hide();
+ vscode.commands.executeCommand('setContext', 'refactoringInProgress', false);
+}
diff --git a/src/utils/serverStatus.ts b/src/utils/serverStatus.ts
deleted file mode 100644
index 752f410..0000000
--- a/src/utils/serverStatus.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import * as vscode from 'vscode';
-import { EventEmitter } from 'events';
-
-export enum ServerStatusType {
- UNKNOWN = 'unknown',
- UP = 'up',
- DOWN = 'down',
-}
-
-class ServerStatus extends EventEmitter {
- private status: ServerStatusType = ServerStatusType.UNKNOWN;
-
- getStatus(): ServerStatusType {
- return this.status;
- }
-
- setStatus(newStatus: ServerStatusType.UP | ServerStatusType.DOWN): void {
- if (this.status !== newStatus) {
- if (newStatus === ServerStatusType.UP) {
- if (this.status !== ServerStatusType.UNKNOWN) {
- vscode.window.showInformationMessage(
- 'Server connection re-established. Smell detection and refactoring functionality resumed.',
- );
- }
- } else {
- vscode.window.showWarningMessage("Can't connect to ecooptimizer server.");
- }
- this.status = newStatus;
- this.emit('change', newStatus); // Notify listeners
- }
- }
-}
-
-// Singleton instance
-export const serverStatus = new ServerStatus();
diff --git a/src/utils/smellDetails.ts b/src/utils/smellDetails.ts
deleted file mode 100644
index bcc2b8c..0000000
--- a/src/utils/smellDetails.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-export const SMELL_MAP: Map = new Map([
- [
- 'R1729',
- {
- symbol: 'use-a-generator',
- message:
- 'Refactor to use a generator expression instead of a list comprehension inside `any()` or `all()`. This improves memory efficiency by avoiding the creation of an intermediate list.',
- },
- ],
- [
- 'R0913',
- {
- symbol: 'too-many-arguments',
- message:
- 'Refactor the function to reduce the number of parameters. Functions with too many arguments can become difficult to maintain and understand. Consider breaking it into smaller, more manageable functions.',
- },
- ],
- [
- 'R6301',
- {
- symbol: 'no-self-use',
- message:
- "Refactor the method to make it static, as it does not use `self`. Static methods do not require an instance and improve clarity and performance when the method doesn't depend on instance data.",
- },
- ],
- [
- 'LLE001',
- {
- symbol: 'long-lambda-expression',
- message:
- 'Refactor the lambda expression to improve readability. Long lambda expressions can be confusing; breaking them into named functions can make the code more understandable and maintainable.',
- },
- ],
- [
- 'LMC001',
- {
- symbol: 'long-message-chain',
- message:
- 'Refactor the message chain to improve readability and performance. Long chains of method calls can be hard to follow and may impact performance. Consider breaking them into smaller steps.',
- },
- ],
- [
- 'UVA001',
- {
- symbol: 'unused-variables-and-attributes',
- message:
- 'Remove unused variables or attributes to clean up the code. Keeping unused elements in the code increases its complexity without providing any benefit, making it harder to maintain.',
- },
- ],
- [
- 'LEC001',
- {
- symbol: 'long-element-chain',
- message:
- 'Refactor the long element chain for better performance and clarity. Chains of nested elements are harder to read and can lead to inefficiency, especially when accessing deep levels repeatedly.',
- },
- ],
- [
- 'CRC001',
- {
- symbol: 'cached-repeated-calls',
- message:
- 'Refactor by caching repeated function calls to improve performance. Repeated calls to the same function can be avoided by storing the result, which saves processing time and enhances performance.',
- },
- ],
- [
- 'SCL001',
- {
- symbol: 'string-concat-loop',
- message:
- 'Refactor to use list accumulation instead of string concatenation inside a loop. Concatenating strings in a loop is inefficient; list accumulation and joining are faster and use less memory.',
- },
- ],
-]);
diff --git a/src/utils/smellsData.ts b/src/utils/smellsData.ts
new file mode 100644
index 0000000..655fbe2
--- /dev/null
+++ b/src/utils/smellsData.ts
@@ -0,0 +1,166 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { ecoOutput } from '../extension';
+
+/**
+ * Defines the structure of the smell configuration in smells.json.
+ * Used by FilterSmellsProvider.ts (modifies JSON based on user input).
+ */
+export interface FilterSmellConfig {
+ name: string;
+ message_id: string;
+ acronym: string;
+ smell_description: string;
+ enabled: boolean;
+ analyzer_options?: Record<
+ string,
+ { label: string; description: string; value: number | string }
+ >;
+}
+
+/**
+ * Defines the structure of enabled smells sent to the backend.
+ */
+interface DetectSmellConfig {
+ message_id: string;
+ acronym: string;
+ options: Record;
+}
+
+let filterSmells: Record;
+let enabledSmells: Record;
+
+/**
+ * Loads the full smells configuration from smells.json.
+ * @param version - The version of the smells configuration to load.
+ * @returns A dictionary of smells with their respective configuration.
+ */
+export function loadSmells(version: 'working' | 'default' = 'working'): void {
+ const filePath = path.join(
+ __dirname,
+ '..',
+ 'data',
+ `${version}_smells_config.json`,
+ );
+
+ if (!fs.existsSync(filePath)) {
+ vscode.window.showErrorMessage(
+ 'Configuration file missing: smells.json could not be found.',
+ );
+ }
+
+ try {
+ filterSmells = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ enabledSmells = parseSmells(filterSmells);
+
+ ecoOutput.info(`[smellsData.ts] Loaded smells configuration: ${version}`);
+ } catch (error) {
+ vscode.window.showErrorMessage(
+ 'Error loading smells.json. Please check the file format.',
+ );
+ console.error('ERROR: Failed to parse smells.json', error);
+ }
+}
+
+/**
+ * Saves the smells configuration to smells.json.
+ * @param smells - The smells data to be saved.
+ */
+export function saveSmells(smells: Record): void {
+ filterSmells = smells;
+
+ const filePath = path.join(__dirname, '..', 'data', 'working_smells_config.json');
+
+ enabledSmells = parseSmells(filterSmells);
+ try {
+ fs.writeFileSync(filePath, JSON.stringify(smells, null, 2));
+ } catch (error) {
+ vscode.window.showErrorMessage('Error saving smells.json.');
+ console.error('ERROR: Failed to write smells.json', error);
+ }
+}
+
+/**
+ * Extracts raw smells data from the loaded configuration.
+ * @returns A dictionary of smell config data for smell filtering.
+ */
+export function getFilterSmells(): Record {
+ return filterSmells;
+}
+
+/**
+ * Extracts enabled smells from the loaded configuration.
+ * @returns A dictionary of enabled smells formatted for backend processing.
+ */
+export function getEnabledSmells(): Record {
+ return enabledSmells;
+}
+
+/**
+ * Parses the raw smells into a formatted object.
+ * @param smells - The smells data to be saved.
+ * @returns A dictionary of enabled smells formatted for backend processing.
+ */
+function parseSmells(
+ smells: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(smells)
+ .filter(([, smell]) => smell.enabled)
+ .map(([smellKey, smellData]) => [
+ smellKey,
+ {
+ message_id: smellData.message_id,
+ acronym: smellData.acronym,
+ options: Object.fromEntries(
+ Object.entries(smellData.analyzer_options ?? {}).map(
+ ([optionKey, optionData]) => [
+ optionKey,
+ typeof optionData.value === 'string' ||
+ typeof optionData.value === 'number'
+ ? optionData.value
+ : String(optionData.value),
+ ],
+ ),
+ ),
+ },
+ ]),
+ );
+}
+
+/**
+ * Returns the acronym for a given message ID.
+ * @param messageId - The message ID to look up (e.g., "R0913").
+ * @returns The acronym (e.g., "LPL") or undefined if not found.
+ */
+export function getAcronymByMessageId(messageId: string): string | undefined {
+ const match = Object.values(filterSmells).find(
+ (smell) => smell.message_id === messageId,
+ );
+ return match?.acronym;
+}
+
+/**
+ * Returns the full name for a given message ID.
+ * @param messageId - The message ID to look up (e.g., "R0913").
+ * @returns The full name (e.g., "Long Parameter List") or undefined if not found.
+ */
+export function getNameByMessageId(messageId: string): string | undefined {
+ const match = Object.values(filterSmells).find(
+ (smell) => smell.message_id === messageId,
+ );
+ return match?.name;
+}
+
+/**
+ * Returns the description for a given message ID.
+ * @param messageId - The message ID to look up (e.g., "R0913").
+ * @returns The description or undefined if not found.
+ */
+export function getDescriptionByMessageId(messageId: string): string | undefined {
+ const match = Object.values(filterSmells).find(
+ (smell) => smell.message_id === messageId,
+ );
+ return match?.smell_description; // This assumes your FilterSmellConfig has a description field
+}
diff --git a/src/utils/trackedDiffEditors.ts b/src/utils/trackedDiffEditors.ts
new file mode 100644
index 0000000..be54e61
--- /dev/null
+++ b/src/utils/trackedDiffEditors.ts
@@ -0,0 +1,35 @@
+// utils/trackedDiffEditors.ts
+import * as vscode from 'vscode';
+
+export const trackedDiffs = new Set();
+
+export function registerDiffEditor(
+ original: vscode.Uri,
+ modified: vscode.Uri,
+): void {
+ trackedDiffs.add(`${original.toString()}::${modified.toString()}`);
+}
+
+export function isTrackedDiffEditor(
+ original: vscode.Uri,
+ modified: vscode.Uri,
+): boolean {
+ return trackedDiffs.has(`${original.toString()}::${modified.toString()}`);
+}
+
+export async function closeAllTrackedDiffEditors(): Promise {
+ const tabs = vscode.window.tabGroups.all.flatMap((group) => group.tabs);
+
+ for (const tab of tabs) {
+ if (tab.input && (tab.input as any).modified && (tab.input as any).original) {
+ const original = (tab.input as any).original as vscode.Uri;
+ const modified = (tab.input as any).modified as vscode.Uri;
+
+ if (isTrackedDiffEditor(original, modified)) {
+ await vscode.window.tabGroups.close(tab, true);
+ }
+ }
+ }
+
+ trackedDiffs.clear();
+}
diff --git a/test/api/backend.test.ts b/test/api/backend.test.ts
index 2b7428f..1dde1d0 100644
--- a/test/api/backend.test.ts
+++ b/test/api/backend.test.ts
@@ -1,207 +1,278 @@
+/* eslint-disable unused-imports/no-unused-imports */
+import path from 'path';
+
+import { envConfig } from '../../src/utils/envConfig';
import {
checkServerStatus,
initLogs,
fetchSmells,
- refactorSmell,
+ backendRefactorSmell,
+ backendRefactorSmellType,
} from '../../src/api/backend';
-import { serverStatus } from '../../src/utils/serverStatus';
-import { ServerStatusType } from '../../src/utils/serverStatus';
-import * as vscode from '../mocks/vscode-mock';
+import { serverStatus, ServerStatusType } from '../../src/emitters/serverStatus';
+import { ecoOutput } from '../../src/extension';
+
+// Mock dependencies
+jest.mock('../../src/emitters/serverStatus');
+jest.mock('../../src/extension');
+jest.mock('../../src/utils/envConfig');
+jest.mock('path', () => ({
+ basename: jest.fn((path) => path.split('/').pop()),
+}));
+
+// Mock global fetch
+global.fetch = jest.fn() as jest.Mock;
+
+describe('Backend Service', () => {
+ const mockServerUrl = 'localhost:8000';
+ const mockLogDir = '/path/to/logs';
+ const mockFilePath = '/project/src/file.py';
+ const mockWorkspacePath = '/project';
+ const mockSmell = {
+ symbol: 'test-smell',
+ path: mockFilePath,
+ occurences: [{ line: 1 }],
+ message: 'Test smell message',
+ messageId: 'test-001',
+ } as unknown as Smell;
-describe('backend', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('checkServerStatus', () => {
- test('checkServerStatus should update serverStatus to UP on success', async () => {
- global.fetch = jest.fn(() => Promise.resolve({ ok: true })) as jest.Mock;
-
- const setStatusSpy = jest.spyOn(serverStatus, 'setStatus');
+ it('should set status UP when server is healthy', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
await checkServerStatus();
- expect(setStatusSpy).toHaveBeenCalledWith(ServerStatusType.UP);
+ expect(fetch).toHaveBeenCalledWith(`http://${mockServerUrl}/health`);
+ expect(serverStatus.setStatus).toHaveBeenCalledWith(ServerStatusType.UP);
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ '[backend.ts] Backend server is healthy',
+ );
});
- test('checkServerStatus should update serverStatus to DOWN on non-success', async () => {
- global.fetch = jest.fn(() => Promise.resolve({ ok: false })) as jest.Mock;
-
- const setStatusSpy = jest.spyOn(serverStatus, 'setStatus');
+ it('should set status DOWN when server responds with error', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500 });
await checkServerStatus();
- expect(setStatusSpy).toHaveBeenCalledWith(ServerStatusType.DOWN);
+ expect(serverStatus.setStatus).toHaveBeenCalledWith(ServerStatusType.DOWN);
+ expect(ecoOutput.warn).toHaveBeenCalledWith(
+ '[backend.ts] Backend server unhealthy status: 500',
+ );
});
- test('checkServerStatus should update serverStatus to DOWN on error', async () => {
- global.fetch = jest.fn(() =>
- Promise.reject("Can't connect to server"),
- ) as jest.Mock;
-
- const setStatusSpy = jest.spyOn(serverStatus, 'setStatus');
+ it('should set status DOWN and log error when request fails', async () => {
+ const mockError = new Error('Network error');
+ (fetch as jest.Mock).mockRejectedValueOnce(mockError);
await checkServerStatus();
- expect(setStatusSpy).toHaveBeenCalledWith(ServerStatusType.DOWN);
+ expect(serverStatus.setStatus).toHaveBeenCalledWith(ServerStatusType.DOWN);
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ '[backend.ts] Server connection failed: Network error',
+ );
});
});
describe('initLogs', () => {
- test('initLogs should return true on success', async () => {
- global.fetch = jest.fn(() => Promise.resolve({ ok: true })) as jest.Mock;
- const result = await initLogs('/path/to/logs');
+ it('should successfully initialize logs', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
+
+ const result = await initLogs(mockLogDir);
+
+ expect(fetch).toHaveBeenCalledWith(`http://${mockServerUrl}/logs/init`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ log_dir: mockLogDir }),
+ });
expect(result).toBe(true);
});
- test('initLogs should return false on non success', async () => {
- global.fetch = jest.fn(() => Promise.resolve({ ok: false })) as jest.Mock;
- const result = await initLogs('/path/to/logs');
+ it('should return false when server responds with not ok', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
+
+ const result = await initLogs(mockLogDir);
+
expect(result).toBe(false);
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Unable to initialize logging'),
+ );
});
- test('initLogs should return false on error', async () => {
- global.fetch = jest.fn(() => {
- throw new Error('Some error');
- }) as jest.Mock;
- const result = await initLogs('/path/to/logs');
+ it('should handle network errors', async () => {
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network failed'));
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Unable to reach the backend. Please check your connection.',
- );
+ const result = await initLogs(mockLogDir);
expect(result).toBe(false);
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ 'Eco: Unable to reach the backend. Please check your connection.',
+ );
});
});
describe('fetchSmells', () => {
- test('fetchSmells should return smells array on success', async () => {
- const mockSmells = [{ symbol: 'LongMethod', severity: 'HIGH' }];
- global.fetch = jest.fn(() =>
- Promise.resolve({ ok: true, json: () => Promise.resolve(mockSmells) }),
- ) as jest.Mock;
-
- const result = await fetchSmells('file.py', ['LongMethod']);
- expect(result).toEqual(mockSmells);
- });
-
- test('fetchSmells should return an empty array on status not ok', async () => {
- global.fetch = jest.fn(() =>
- Promise.resolve({ ok: false, status: 400, json: () => Promise.resolve([]) }),
- ) as jest.Mock;
-
- const result = await fetchSmells('file.py', ['LongMethod']);
-
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- `Eco: Failed to fetch smells`,
+ const mockEnabledSmells = { 'test-smell': { threshold: 0.5 } };
+ const mockSmellsResponse = [mockSmell];
+
+ it('should successfully fetch smells', async () => {
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ json: jest.fn().mockResolvedValueOnce(mockSmellsResponse),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await fetchSmells(mockFilePath, mockEnabledSmells);
+
+ expect(fetch).toHaveBeenCalledWith(`http://${mockServerUrl}/smells`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ file_path: mockFilePath,
+ enabled_smells: mockEnabledSmells,
+ }),
+ });
+ expect(result).toEqual({ smells: mockSmellsResponse, status: 200 });
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ '[backend.ts] Starting smell detection for: file.py',
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ '[backend.ts] Detection complete for file.py',
);
- expect(result).toEqual([]);
});
- test('fetchSmells should return an empty array on invalid response format', async () => {
- global.fetch = jest.fn(() =>
- Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(200) }),
- ) as jest.Mock;
+ it('should throw error when server responds with error', async () => {
+ const mockResponse = {
+ ok: false,
+ status: 500,
+ json: jest.fn().mockResolvedValueOnce({ detail: 'Server error' }),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
- const result = await fetchSmells('file.py', ['LongMethod']);
+ await expect(fetchSmells(mockFilePath, mockEnabledSmells)).rejects.toThrow(
+ 'Backend request failed (500)',
+ );
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- `Eco: Failed to fetch smells`,
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ '[backend.ts] Backend error details:',
+ { detail: 'Server error' },
);
- expect(result).toEqual([]);
});
- test('fetchSmells should return an empty array on error', async () => {
- global.fetch = jest.fn(() => {
- throw new Error('Some error');
- }) as jest.Mock;
+ it('should throw error when network fails', async () => {
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network failed'));
- const result = await fetchSmells('file.py', ['LongMethod']);
+ await expect(fetchSmells(mockFilePath, mockEnabledSmells)).rejects.toThrow(
+ 'Detection failed: Network failed',
+ );
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- `Eco: Failed to fetch smells`,
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ '[backend.ts] Smell detection failed for file.py: Network failed',
);
- expect(result).toEqual([]);
});
});
- describe('refactorSmell', () => {
- test('refactorSmell should return refactor result on success', async () => {
- const mockRefactorOutput = { success: true };
-
- global.fetch = jest.fn(() =>
- Promise.resolve({
- ok: true,
- json: () => Promise.resolve(mockRefactorOutput),
+ describe('backendRefactorSmell', () => {
+ it('should successfully refactor smell', async () => {
+ const mockResponse = {
+ ok: true,
+ json: jest.fn().mockResolvedValueOnce({ success: true }),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await backendRefactorSmell(mockSmell, mockWorkspacePath);
+
+ expect(fetch).toHaveBeenCalledWith(`http://${mockServerUrl}/refactor`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sourceDir: mockWorkspacePath,
+ smell: mockSmell,
}),
- ) as jest.Mock;
-
- (vscode.workspace as any).workspaceFolders = [
- { uri: { fsPath: '/mock/workspace' } },
- ];
-
- const result = await refactorSmell('/mock/workspace/file.py', {
- symbol: 'LongMethod',
- } as Smell);
- expect(result).toEqual(mockRefactorOutput);
+ });
+ expect(result).toEqual({ success: true });
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ '[backend.ts] Starting refactoring for smell: test-smell',
+ );
});
- test('refactorSmell should throw and error if no workspace found', async () => {
- const mockRefactorOutput = { success: true };
+ it('should throw error when no workspace path', async () => {
+ await expect(backendRefactorSmell(mockSmell, '')).rejects.toThrow(
+ 'No workspace path provided',
+ );
- global.fetch = jest.fn(() =>
- Promise.resolve({
- ok: true,
- json: () => Promise.resolve(mockRefactorOutput),
- }),
- ) as jest.Mock;
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ '[backend.ts] Refactoring aborted: No workspace path',
+ );
+ });
- (vscode.workspace as any).workspaceFolders = [
- { uri: { fsPath: '/mock/workspace' } },
- ];
+ it('should throw error when server responds with error', async () => {
+ const mockResponse = {
+ ok: false,
+ json: jest.fn().mockResolvedValueOnce({ detail: 'Refactor failed' }),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
await expect(
- refactorSmell('/mock/another-workspace/file.py', {
- symbol: 'LongMethod',
- } as Smell),
- ).rejects.toThrow(
- 'Eco: Unable to find a matching workspace folder for file: /mock/another-workspace/file.py',
+ backendRefactorSmell(mockSmell, mockWorkspacePath),
+ ).rejects.toThrow('Refactoring failed');
+
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ '[backend.ts] Refactoring failed: Refactor failed',
);
});
+ });
- test('refactorSmell should throw and error if not ok response', async () => {
- global.fetch = jest.fn(() =>
- Promise.resolve({
- ok: false,
- text: jest.fn().mockReturnValue('Some error text'),
- }),
- ) as jest.Mock;
-
- (vscode.workspace as any).workspaceFolders = [
- { uri: { fsPath: '/mock/workspace' } },
- ];
-
- await expect(
- refactorSmell('/mock/workspace/file.py', {
- symbol: 'LongMethod',
- } as Smell),
- ).rejects.toThrow('Some error text');
+ describe('backendRefactorSmellType', () => {
+ it('should successfully refactor smell type', async () => {
+ const mockResponse = {
+ ok: true,
+ json: jest.fn().mockResolvedValueOnce({ success: true }),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await backendRefactorSmellType(mockSmell, mockWorkspacePath);
+
+ expect(fetch).toHaveBeenCalledWith(
+ `http://${mockServerUrl}/refactor-by-type`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sourceDir: mockWorkspacePath,
+ smellType: 'test-smell',
+ firstSmell: mockSmell,
+ }),
+ },
+ );
+ expect(result).toEqual({ success: true });
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ '[backend.ts] Starting refactoring for smells of type "test-smell" in "/project/src/file.py"',
+ );
});
- test('refactorSmell should throw and error if function returns an error', async () => {
- global.fetch = jest.fn(() => {
- throw new Error('Some error');
- }) as jest.Mock;
+ it('should throw error when no workspace path', async () => {
+ await expect(backendRefactorSmellType(mockSmell, '')).rejects.toThrow(
+ 'No workspace path provided',
+ );
+ });
- (vscode.workspace as any).workspaceFolders = [
- { uri: { fsPath: '/mock/workspace' } },
- ];
+ it('should throw error when server responds with error', async () => {
+ const mockResponse = {
+ ok: false,
+ json: jest.fn().mockResolvedValueOnce({ detail: 'Type refactor failed' }),
+ };
+ (fetch as jest.Mock).mockResolvedValueOnce(mockResponse);
await expect(
- refactorSmell('/mock/workspace/file.py', {
- symbol: 'LongMethod',
- } as Smell),
- ).rejects.toThrow('Some error');
+ backendRefactorSmellType(mockSmell, mockWorkspacePath),
+ ).rejects.toThrow('Type refactor failed');
});
});
});
diff --git a/test/commands/configureWorkspace.test.ts b/test/commands/configureWorkspace.test.ts
new file mode 100644
index 0000000..8693124
--- /dev/null
+++ b/test/commands/configureWorkspace.test.ts
@@ -0,0 +1,84 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { configureWorkspace } from '../../src/commands/configureWorkspace';
+import { envConfig } from '../../src/utils/envConfig';
+
+jest.mock('fs');
+jest.mock('vscode', () => {
+ const original = jest.requireActual('vscode');
+ return {
+ ...original,
+ workspace: { workspaceFolders: [] },
+ window: {
+ showQuickPick: jest.fn(),
+ showInformationMessage: jest.fn(),
+ showErrorMessage: jest.fn(),
+ },
+ commands: {
+ executeCommand: jest.fn(),
+ },
+ };
+});
+
+describe('configureWorkspace (Jest)', () => {
+ const mockContext = {
+ workspaceState: {
+ update: jest.fn(),
+ },
+ } as unknown as vscode.ExtensionContext;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ // Mock a workspace folder
+ (vscode.workspace.workspaceFolders as any) = [
+ {
+ uri: { fsPath: '/project' },
+ },
+ ];
+
+ // Mock fs behavior
+ (fs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
+ if (dirPath === '/project') {
+ return ['main.py', 'subdir'];
+ } else if (dirPath === '/project/subdir') {
+ return ['__init__.py'];
+ }
+ return [];
+ });
+
+ (fs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
+ isDirectory: () => !filePath.endsWith('.py'),
+ }));
+
+ // Mock quick pick
+ (vscode.window.showQuickPick as jest.Mock).mockResolvedValue({
+ label: 'project',
+ description: '/project',
+ detail: 'Python content: main.py',
+ folderPath: '/project',
+ });
+
+ envConfig.WORKSPACE_CONFIGURED_PATH = 'myWorkspaceKey';
+ });
+
+ it('should detect Python folders and configure the workspace', async () => {
+ await configureWorkspace(mockContext);
+
+ expect(mockContext.workspaceState.update).toHaveBeenCalledWith(
+ 'myWorkspaceKey',
+ '/project',
+ );
+
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'setContext',
+ 'workspaceState.workspaceConfigured',
+ true,
+ );
+
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Configured workspace: project',
+ );
+ });
+});
diff --git a/test/commands/detectSmells.test.ts b/test/commands/detectSmells.test.ts
index 4767c45..8475e64 100644
--- a/test/commands/detectSmells.test.ts
+++ b/test/commands/detectSmells.test.ts
@@ -1,243 +1,343 @@
-// test/detect-smells.test.ts
-import { ContextManager } from '../../src/context/contextManager';
-import { FileHighlighter } from '../../src/ui/fileHighlighter';
+// test/detection.test.ts
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+ detectSmellsFile,
+ detectSmellsFolder,
+} from '../../src/commands/detection/detectSmells';
+import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { serverStatus, ServerStatusType } from '../../src/emitters/serverStatus';
+import { ecoOutput } from '../../src/extension';
+
+import context from '../mocks/context-mock';
+
+// Mock the external dependencies
+jest.mock('fs');
+jest.mock('path');
+jest.mock('../../src/api/backend');
+jest.mock('../../src/utils/smellsData');
+jest.mock('../../src/providers/SmellsViewProvider');
+jest.mock('../../src/context/SmellsCacheManager');
+jest.mock('../../src/emitters/serverStatus');
+jest.mock('../../src/extension');
+
+describe('detectSmellsFile', () => {
+ let smellsViewProvider: SmellsViewProvider;
+ let smellsCacheManager: SmellsCacheManager;
+ const mockFilePath = '/path/to/file.py';
-import vscode from '../mocks/vscode-mock';
+ beforeEach(() => {
+ // Reset all mocks before each test
+ jest.clearAllMocks();
-import * as backend from '../../src/api/backend';
-import * as hashDocs from '../../src/utils/hashDocs';
-import * as editorUtils from '../../src/utils/editorUtils';
-import * as SmellSettings from '../../src/utils/handleSmellSettings';
+ // Setup mock instances
+ smellsViewProvider = new SmellsViewProvider(
+ context as unknown as vscode.ExtensionContext,
+ );
+ smellsCacheManager = new SmellsCacheManager(
+ context as unknown as vscode.ExtensionContext,
+ );
-import { detectSmells } from '../../src/commands/detectSmells';
-import { serverStatus, ServerStatusType } from '../../src/utils/serverStatus';
-import { wipeWorkCache } from '../../src/commands/wipeWorkCache';
-import { envConfig } from '../../src/utils/envConfig';
+ // Mock vscode.Uri
+ (vscode.Uri.file as jest.Mock).mockImplementation((path) => ({
+ scheme: 'file',
+ path,
+ }));
-jest.mock('../../src/commands/wipeWorkCache', () => ({
- wipeWorkCache: jest.fn(),
-}));
+ // Mock path.basename
+ (path.basename as jest.Mock).mockImplementation((p) => p.split('/').pop());
+ });
-jest.mock('../../src/utils/handleSmellSettings.ts', () => ({
- getEnabledSmells: jest.fn().mockImplementation(() => ({
- smell1: true,
- smell2: true,
- })),
-}));
+ it('should skip non-file URIs', async () => {
+ (vscode.Uri.file as jest.Mock).mockReturnValueOnce({ scheme: 'untitled' });
-describe('detectSmells', () => {
- let contextManagerMock: ContextManager;
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- beforeEach(() => {
- // Reset all mocks before each test
- jest.clearAllMocks();
+ expect(smellsViewProvider.setStatus).not.toHaveBeenCalled();
+ expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
+ });
- // Mock ContextManager
- contextManagerMock = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as unknown as ContextManager;
+ it('should skip non-Python files', async () => {
+ const nonPythonPath = '/path/to/file.txt';
+
+ await detectSmellsFile(nonPythonPath, smellsViewProvider, smellsCacheManager);
+
+ expect(smellsViewProvider.setStatus).not.toHaveBeenCalled();
+ expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
});
- it('should show an error if no active editor is found', async () => {
- jest
- .spyOn(editorUtils, 'getEditorAndFilePath')
- .mockReturnValue({ editor: undefined, filePath: undefined });
+ it('should use cached smells when available', async () => {
+ const mockCachedSmells = [{ id: 'smell1' }];
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(true);
+ (smellsCacheManager.getCachedSmells as jest.Mock).mockReturnValue(
+ mockCachedSmells,
+ );
- await detectSmells(contextManagerMock);
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- // Assert error message was shown
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: No active editor found.',
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('Using cached results'),
+ );
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'passed',
+ );
+ expect(smellsViewProvider.setSmells).toHaveBeenCalledWith(
+ mockFilePath,
+ mockCachedSmells,
);
});
- it('should show an error if no file path is found', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValue({
- editor: vscode.window.activeTextEditor,
- filePath: undefined,
- });
+ it('should handle server down state', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.DOWN);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
- await detectSmells(contextManagerMock);
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- // Assert error message was shown
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Active editor has no valid file path.',
+ expect(vscode.window.showWarningMessage).toHaveBeenCalled();
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'server_down',
);
});
- it('should show a warning if no smells are enabled', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
- });
-
- jest
- .spyOn(SmellSettings, 'getEnabledSmells')
- .mockReturnValueOnce({ smell1: false, smell2: false });
+ it('should warn when no smells are enabled', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (
+ require('../../src/utils/smellsData').getEnabledSmells as jest.Mock
+ ).mockReturnValue({});
- await detectSmells(contextManagerMock);
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- // Assert warning message was shown
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
- 'Eco: No smells are enabled! Detection skipped.',
+ 'No smell detectors enabled in settings',
+ );
+ expect(smellsViewProvider.setStatus).not.toHaveBeenCalledWith(
+ mockFilePath,
+ 'queued',
);
});
- it('should use cached smells when hash is unchanged, same smells enabled', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
+ it('should fetch and process smells successfully', async () => {
+ const mockSmells = [{ id: 'smell1' }];
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (
+ require('../../src/utils/smellsData').getEnabledSmells as jest.Mock
+ ).mockReturnValue({
+ smell1: { options: {} },
+ });
+ (require('../../src/api/backend').fetchSmells as jest.Mock).mockResolvedValue({
+ smells: mockSmells,
+ status: 200,
});
- jest.spyOn(hashDocs, 'hashContent').mockReturnValue('someHash');
-
- jest
- .spyOn(contextManagerMock, 'getWorkspaceData')
- .mockReturnValueOnce({ smell1: true, smell2: true })
- .mockReturnValueOnce({
- 'fake.path': {
- hash: 'someHash',
- smells: [],
- },
- });
-
- jest.spyOn(serverStatus, 'getStatus').mockReturnValue(ServerStatusType.UP);
-
- await detectSmells(contextManagerMock);
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- expect(vscode.window.showInformationMessage).toHaveBeenNthCalledWith(
- 1,
- 'Eco: Using cached smells for fake.path',
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'queued',
+ );
+ expect(smellsCacheManager.setCachedSmells).toHaveBeenCalledWith(
+ mockFilePath,
+ mockSmells,
+ );
+ expect(smellsViewProvider.setSmells).toHaveBeenCalledWith(
+ mockFilePath,
+ mockSmells,
+ );
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'passed',
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('Detected 1 smells'),
);
});
- it('should fetch new smells on changed enabled smells', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
+ it('should handle no smells found', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (
+ require('../../src/utils/smellsData').getEnabledSmells as jest.Mock
+ ).mockReturnValue({
+ smell1: { options: {} },
+ });
+ (require('../../src/api/backend').fetchSmells as jest.Mock).mockResolvedValue({
+ smells: [],
+ status: 200,
});
- jest.spyOn(hashDocs, 'hashContent').mockReturnValue('someHash');
- jest.spyOn(hashDocs, 'updateHash').mockResolvedValue();
-
- jest
- .spyOn(contextManagerMock, 'getWorkspaceData')
- .mockReturnValueOnce({ smell1: true, smell2: false })
- .mockReturnValueOnce({});
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- jest.spyOn(backend, 'fetchSmells').mockResolvedValueOnce([]);
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'no_issues',
+ );
+ expect(smellsCacheManager.setCachedSmells).toHaveBeenCalledWith(
+ mockFilePath,
+ [],
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('File has no detectable smells'),
+ );
+ });
- jest.spyOn(serverStatus, 'getStatus').mockReturnValue(ServerStatusType.UP);
+ it('should handle API errors', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (
+ require('../../src/utils/smellsData').getEnabledSmells as jest.Mock
+ ).mockReturnValue({
+ smell1: { options: {} },
+ });
+ (require('../../src/api/backend').fetchSmells as jest.Mock).mockResolvedValue({
+ smells: [],
+ status: 500,
+ });
- await detectSmells(contextManagerMock);
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- expect(wipeWorkCache).toHaveBeenCalled();
- expect(hashDocs.updateHash).toHaveBeenCalled();
- expect(backend.fetchSmells).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalledTimes(2);
+ expect(vscode.window.showErrorMessage).toHaveBeenCalled();
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'failed',
+ );
});
- it('should fetch new smells on hash change, same enabled smells', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
+ it('should handle unexpected errors', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+ (smellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (
+ require('../../src/utils/smellsData').getEnabledSmells as jest.Mock
+ ).mockReturnValue({
+ smell1: { options: {} },
});
+ (require('../../src/api/backend').fetchSmells as jest.Mock).mockRejectedValue(
+ new Error('API failed'),
+ );
- jest.spyOn(hashDocs, 'hashContent').mockReturnValue('someHash');
- jest.spyOn(hashDocs, 'updateHash').mockResolvedValue();
+ await detectSmellsFile(mockFilePath, smellsViewProvider, smellsCacheManager);
- jest
- .spyOn(contextManagerMock, 'getWorkspaceData')
- .mockReturnValueOnce({ smell1: true, smell2: true })
- .mockReturnValueOnce({
- 'fake.path': {
- hash: 'differentHash',
- smells: [],
- },
- });
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Analysis failed: API failed',
+ );
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockFilePath,
+ 'failed',
+ );
+ expect(ecoOutput.error).toHaveBeenCalled();
+ });
+});
- jest.spyOn(serverStatus, 'getStatus').mockReturnValue(ServerStatusType.UP);
+describe('detectSmellsFolder', () => {
+ let smellsViewProvider: SmellsViewProvider;
+ let smellsCacheManager: SmellsCacheManager;
+ const mockFolderPath = '/path/to/folder';
- jest.spyOn(backend, 'fetchSmells').mockResolvedValueOnce([]);
+ beforeEach(() => {
+ jest.clearAllMocks();
- await detectSmells(contextManagerMock);
+ smellsViewProvider = new SmellsViewProvider(
+ context as unknown as vscode.ExtensionContext,
+ );
+ smellsCacheManager = new SmellsCacheManager(
+ context as unknown as vscode.ExtensionContext,
+ );
- expect(hashDocs.updateHash).toHaveBeenCalled();
- expect(backend.fetchSmells).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalledTimes(1);
+ // Mock vscode.window.withProgress
+ (vscode.window.withProgress as jest.Mock).mockImplementation((_, callback) => {
+ return callback();
+ });
+
+ // Mock path.basename
+ (path.basename as jest.Mock).mockImplementation((p) => p.split('/').pop());
});
- it('should return if no cached smells and server down', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
- });
+ it('should show progress notification', async () => {
+ (fs.readdirSync as jest.Mock).mockReturnValue([]);
- jest.spyOn(hashDocs, 'hashContent').mockReturnValue('someHash');
- jest.spyOn(hashDocs, 'updateHash').mockResolvedValue();
+ await detectSmellsFolder(mockFolderPath, smellsViewProvider, smellsCacheManager);
- jest
- .spyOn(contextManagerMock, 'getWorkspaceData')
- .mockReturnValueOnce({ smell1: true, smell2: true })
- .mockReturnValueOnce({});
+ expect(vscode.window.withProgress).toHaveBeenCalled();
+ });
- jest.spyOn(serverStatus, 'getStatus').mockReturnValue(ServerStatusType.DOWN);
+ it('should handle empty folder', async () => {
+ (fs.readdirSync as jest.Mock).mockReturnValue([]);
- await detectSmells(contextManagerMock);
+ await detectSmellsFolder(mockFolderPath, smellsViewProvider, smellsCacheManager);
- expect(vscode.window.showWarningMessage).toHaveBeenLastCalledWith(
- 'Action blocked: Server is down and no cached smells exist for this file version.',
+ expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
+ expect.stringContaining('No Python files found'),
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('Found 0 files to analyze'),
);
});
- it('should highlight smells if smells are found', async () => {
- jest.spyOn(editorUtils, 'getEditorAndFilePath').mockReturnValueOnce({
- editor: vscode.window.activeTextEditor,
- filePath: 'fake.path',
- });
+ it('should process Python files in folder', async () => {
+ const mockFiles = ['file1.py', 'subdir/file2.py', 'ignore.txt'];
- jest.spyOn(hashDocs, 'hashContent').mockReturnValue('someHash');
+ (fs.readdirSync as jest.Mock).mockImplementation((dir) => {
+ if (dir === mockFolderPath) return mockFiles;
+ if (dir === mockFolderPath + 'subdir') return ['file2.py'];
+ console.log('Here');
+ return mockFiles;
+ });
jest
- .spyOn(contextManagerMock, 'getWorkspaceData')
- .mockReturnValueOnce({ smell1: true, smell2: true })
+ .spyOn(fs, 'statSync')
+ .mockReturnValueOnce({
+ isDirectory: (): boolean => false,
+ isFile: (): boolean => true,
+ } as unknown as fs.Stats)
.mockReturnValueOnce({
- 'fake.path': {
- hash: 'someHash',
- smells: [{} as unknown as Smell],
- },
- });
+ isDirectory: (): boolean => true,
+ } as unknown as fs.Stats)
+ .mockReturnValueOnce({
+ isDirectory: (): boolean => false,
+ isFile: (): boolean => true,
+ } as unknown as fs.Stats)
+ .mockReturnValueOnce({
+ isDirectory: (): boolean => false,
+ isFile: (): boolean => true,
+ } as unknown as fs.Stats);
- jest.spyOn(serverStatus, 'getStatus').mockReturnValue(ServerStatusType.UP);
+ jest
+ .spyOn(String.prototype, 'endsWith')
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false);
- const mockHighlightSmells = jest.fn();
jest
- .spyOn(FileHighlighter.prototype, 'highlightSmells')
- .mockImplementation(mockHighlightSmells);
+ .spyOn(path, 'join')
+ .mockReturnValueOnce(mockFolderPath + '/file1.py')
+ .mockReturnValueOnce(mockFolderPath + '/subdir')
+ .mockReturnValueOnce(mockFolderPath + '/subdir' + '/file2.py')
+ .mockReturnValueOnce(mockFolderPath + 'ignore.txt');
- await detectSmells(contextManagerMock);
+ await detectSmellsFolder(mockFolderPath, smellsViewProvider, smellsCacheManager);
- expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(2);
- expect(vscode.window.showInformationMessage).toHaveBeenNthCalledWith(
- 1,
- 'Eco: Using cached smells for fake.path',
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Analyzing 2 Python files...',
);
-
- expect(vscode.window.showInformationMessage).toHaveBeenNthCalledWith(
- 2,
- 'Eco: Highlighted 1 smells.',
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('Found 2 files to analyze'),
);
+ });
- expect(mockHighlightSmells).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- true,
- );
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'setContext',
- 'eco.smellLintingEnabled',
- true,
+ it('should handle directory scan errors', async () => {
+ (fs.readdirSync as jest.Mock).mockImplementation(() => {
+ throw new Error('Permission denied');
+ });
+
+ await detectSmellsFolder(mockFolderPath, smellsViewProvider, smellsCacheManager);
+
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Scan error: Permission denied'),
);
});
});
diff --git a/test/commands/exportMetricsData.test.ts b/test/commands/exportMetricsData.test.ts
new file mode 100644
index 0000000..84a59ba
--- /dev/null
+++ b/test/commands/exportMetricsData.test.ts
@@ -0,0 +1,186 @@
+// test/exportMetrics.test.ts
+import * as vscode from 'vscode';
+import { dirname } from 'path';
+import { writeFileSync } from 'fs';
+import { exportMetricsData } from '../../src/commands/views/exportMetricsData';
+import { envConfig } from '../../src/utils/envConfig';
+import * as fs from 'fs';
+
+// Mock dependencies
+jest.mock('path');
+jest.mock('fs');
+
+describe('exportMetricsData', () => {
+ let mockContext: vscode.ExtensionContext;
+ const mockMetricsData = {
+ '/path/to/file1.py': {
+ energySaved: 0.5,
+ smellType: 'test-smell',
+ timestamp: Date.now(),
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup mock context
+ mockContext = {
+ workspaceState: {
+ get: jest.fn(),
+ update: jest.fn(),
+ },
+ } as unknown as vscode.ExtensionContext;
+
+ // Mock path.dirname
+ (dirname as jest.Mock).mockImplementation((path) => `/parent/${path}`);
+
+ // Mock fs.writeFileSync
+ jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
+ });
+
+ it('should show info message when no metrics data exists', async () => {
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ console.log('Mock:', key, envConfig.WORKSPACE_METRICS_DATA);
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return {};
+ return undefined;
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'No metrics data available to export.',
+ );
+ });
+
+ it('should show error when no workspace path is configured', async () => {
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return undefined;
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ return undefined; // No workspace path
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'No configured workspace path found.',
+ );
+ });
+
+ it('should export to workspace directory when path is a directory', async () => {
+ const workspacePath = '/workspace/path';
+
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return workspacePath;
+ return undefined;
+ });
+
+ // Mock fs.stat to return directory
+ (vscode.workspace.fs.stat as jest.Mock).mockResolvedValue({
+ type: vscode.FileType.Directory,
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.Uri.joinPath).toHaveBeenCalledWith(
+ expect.anything(),
+ 'metrics-data.json',
+ );
+ expect(writeFileSync).toHaveBeenCalled();
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ expect.stringContaining('metrics-data.json'),
+ );
+ });
+
+ it('should export to parent directory when path is a file', async () => {
+ const workspacePath = '/workspace/path/file.txt';
+
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return workspacePath;
+ return undefined;
+ });
+
+ // Mock fs.stat to return file
+ (vscode.workspace.fs.stat as jest.Mock).mockResolvedValue({
+ type: vscode.FileType.File,
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(dirname).toHaveBeenCalledWith(workspacePath);
+ expect(vscode.Uri.joinPath).toHaveBeenCalledWith(
+ expect.anything(),
+ 'metrics-data.json',
+ );
+ expect(writeFileSync).toHaveBeenCalled();
+ });
+
+ it('should show error for invalid workspace path type', async () => {
+ const workspacePath = '/workspace/path';
+
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return workspacePath;
+ return undefined;
+ });
+
+ // Mock fs.stat to return unknown type
+ (vscode.workspace.fs.stat as jest.Mock).mockResolvedValue({
+ type: vscode.FileType.Unknown,
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Invalid workspace path type.',
+ );
+ });
+
+ it('should handle filesystem access errors', async () => {
+ const workspacePath = '/workspace/path';
+
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return workspacePath;
+ return undefined;
+ });
+
+ // Mock fs.stat to throw error
+ (vscode.workspace.fs.stat as jest.Mock).mockRejectedValue(
+ new Error('Access denied'),
+ );
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to access workspace path'),
+ );
+ });
+
+ it('should handle file write errors', async () => {
+ const workspacePath = '/workspace/path';
+
+ (mockContext.workspaceState.get as jest.Mock).mockImplementation((key) => {
+ if (key === envConfig.WORKSPACE_METRICS_DATA) return mockMetricsData;
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) return workspacePath;
+ return undefined;
+ });
+
+ // Mock fs.stat to return directory
+ (vscode.workspace.fs.stat as jest.Mock).mockResolvedValue({
+ type: vscode.FileType.Directory,
+ });
+
+ // Mock writeFileSync to throw error
+ (writeFileSync as jest.Mock).mockImplementation(() => {
+ throw new Error('Write failed');
+ });
+
+ await exportMetricsData(mockContext);
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to export metrics data'),
+ );
+ });
+});
diff --git a/test/commands/filterSmells.test.ts b/test/commands/filterSmells.test.ts
new file mode 100644
index 0000000..5939e58
--- /dev/null
+++ b/test/commands/filterSmells.test.ts
@@ -0,0 +1,150 @@
+// test/commands/registerFilterSmellCommands.test.ts
+import * as vscode from 'vscode';
+import { registerFilterSmellCommands } from '../../src/commands/views/filterSmells';
+import { FilterViewProvider } from '../../src/providers/FilterViewProvider';
+
+// Mock the FilterViewProvider
+jest.mock('../../src/providers/FilterViewProvider');
+
+describe('registerFilterSmellCommands', () => {
+ let mockContext: vscode.ExtensionContext;
+ let mockFilterProvider: jest.Mocked;
+ let mockCommands: jest.Mocked;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup mock context
+ mockContext = {
+ subscriptions: [],
+ } as unknown as vscode.ExtensionContext;
+
+ // Setup mock filter provider
+ mockFilterProvider = {
+ toggleSmell: jest.fn(),
+ updateOption: jest.fn(),
+ refresh: jest.fn(),
+ setAllSmellsEnabled: jest.fn(),
+ resetToDefaults: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ // Mock commands
+ mockCommands = vscode.commands as jest.Mocked;
+ });
+
+ it('should register toggleSmellFilter command', () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ // Verify command registration
+ expect(mockCommands.registerCommand).toHaveBeenCalledWith(
+ 'ecooptimizer.toggleSmellFilter',
+ expect.any(Function),
+ );
+
+ // Test the command handler
+ const [, handler] = (mockCommands.registerCommand as jest.Mock).mock.calls[0];
+ handler('test-smell');
+ expect(mockFilterProvider.toggleSmell).toHaveBeenCalledWith('test-smell');
+ });
+
+ it('should register editSmellFilterOption command with valid input', async () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ // Mock showInputBox to return valid number
+ (vscode.window.showInputBox as jest.Mock).mockResolvedValue('42');
+
+ // Get the command handler
+ const editCommandCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.editSmellFilterOption');
+ const [, handler] = editCommandCall;
+
+ // Test with valid item
+ await handler({ smellKey: 'test-smell', optionKey: 'threshold', value: 10 });
+
+ expect(vscode.window.showInputBox).toHaveBeenCalledWith({
+ prompt: 'Enter a new value for threshold',
+ value: '10',
+ validateInput: expect.any(Function),
+ });
+ expect(mockFilterProvider.updateOption).toHaveBeenCalledWith(
+ 'test-smell',
+ 'threshold',
+ 42,
+ );
+ expect(mockFilterProvider.refresh).toHaveBeenCalled();
+ });
+
+ it('should handle editSmellFilterOption with invalid input', async () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ // Mock showInputBox to return invalid input
+ (vscode.window.showInputBox as jest.Mock).mockResolvedValue('not-a-number');
+
+ const editCommandCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.editSmellFilterOption');
+ const [, handler] = editCommandCall;
+
+ await handler({ smellKey: 'test-smell', optionKey: 'threshold', value: 10 });
+
+ expect(mockFilterProvider.updateOption).not.toHaveBeenCalled();
+ });
+
+ it('should show error for editSmellFilterOption with missing keys', async () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ const editCommandCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.editSmellFilterOption');
+ const [, handler] = editCommandCall;
+
+ await handler({});
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Error: Missing smell or option key.',
+ );
+ });
+
+ it('should register selectAllFilterSmells command', () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ const selectAllCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.selectAllFilterSmells');
+ const [, handler] = selectAllCall;
+
+ handler();
+ expect(mockFilterProvider.setAllSmellsEnabled).toHaveBeenCalledWith(true);
+ });
+
+ it('should register deselectAllFilterSmells command', () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ const deselectAllCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.deselectAllFilterSmells');
+ const [, handler] = deselectAllCall;
+
+ handler();
+ expect(mockFilterProvider.setAllSmellsEnabled).toHaveBeenCalledWith(false);
+ });
+
+ it('should register setFilterDefaults command', () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ const setDefaultsCall = (
+ mockCommands.registerCommand as jest.Mock
+ ).mock.calls.find((call) => call[0] === 'ecooptimizer.setFilterDefaults');
+ const [, handler] = setDefaultsCall;
+
+ handler();
+ expect(mockFilterProvider.resetToDefaults).toHaveBeenCalled();
+ });
+
+ it('should add all commands to context subscriptions', () => {
+ registerFilterSmellCommands(mockContext, mockFilterProvider);
+
+ // Verify all commands were added to subscriptions
+ expect(mockContext.subscriptions).toHaveLength(5);
+ });
+});
diff --git a/test/commands/refactorSmell.test.ts b/test/commands/refactorSmell.test.ts
index 0cfb772..451492d 100644
--- a/test/commands/refactorSmell.test.ts
+++ b/test/commands/refactorSmell.test.ts
@@ -1,323 +1,402 @@
+// test/refactor.test.ts
import * as vscode from 'vscode';
import * as fs from 'fs';
-
-import { refactorSelectedSmell, cleanTemps } from '../../src/commands/refactorSmell';
-import { ContextManager } from '../../src/context/contextManager';
-import { refactorSmell } from '../../src/api/backend';
-import { FileHighlighter } from '../../src/ui/fileHighlighter';
+import * as path from 'path';
+import {
+ refactor,
+ startRefactorSession,
+} from '../../src/commands/refactor/refactor';
+import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider';
+import { RefactoringDetailsViewProvider } from '../../src/providers/RefactoringDetailsViewProvider';
+import { serverStatus, ServerStatusType } from '../../src/emitters/serverStatus';
+import { ecoOutput } from '../../src/extension';
import { envConfig } from '../../src/utils/envConfig';
-import { Smell } from '../../src/types';
-
-// mock VSCode APIs
-jest.mock('vscode', () => ({
- window: {
- showErrorMessage: jest.fn(),
- showWarningMessage: jest.fn(),
- showInformationMessage: jest.fn(),
- withProgress: jest.fn((options, task) => task()),
- activeTextEditor: undefined,
- showTextDocument: jest.fn().mockResolvedValue(undefined),
- },
- workspace: {
- save: jest.fn(),
- getConfiguration: jest.fn(),
- openTextDocument: jest.fn().mockImplementation(async (uri) => ({
- // Mock TextDocument object
- uri: typeof uri === 'string' ? { fsPath: uri } : uri,
- fileName: typeof uri === 'string' ? uri : uri.fsPath,
- getText: jest.fn().mockReturnValue('mock content'),
- })),
- },
- ProgressLocation: {
- Notification: 1,
- },
- Uri: {
- file: jest.fn((path) => ({
- toString: (): string => `file://${path}`,
- fsPath: path,
- })),
- },
- commands: {
- executeCommand: jest.fn(),
- },
- ViewColumn: {
- Beside: 2,
- },
-}));
-
-// mock backend API
-jest.mock('../../src/api/backend', () => ({
- refactorSmell: jest.fn(),
-}));
-
-// mock setTimeout
-jest.mock('timers/promises', () => ({
- setTimeout: jest.fn().mockResolvedValue(undefined),
-}));
-
-describe('refactorSmell', () => {
- let mockContextManager: jest.Mocked;
- let fileHighlighterSpy: jest.SpyInstance;
- let mockEditor: any;
- let mockDocument: any;
- let mockSelection: any;
-
- const createMockSmell = (line: number): Smell => ({
- messageId: 'R0913',
- type: 'refactor',
- message: 'Too many arguments (8/6)',
- confidence: 'HIGH',
- path: 'fake.py',
- symbol: 'too-many-arguments',
- module: 'test-module',
- occurences: [
- {
- line,
- column: 1,
- },
- ],
- additionalInfo: {},
- });
+import context from '../mocks/context-mock';
+import { MetricsViewProvider } from '../../src/providers/MetricsViewProvider';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { acceptRefactoring } from '../../src/commands/refactor/acceptRefactoring';
+import { rejectRefactoring } from '../../src/commands/refactor/rejectRefactoring';
+
+// Mock all external dependencies
+jest.mock('vscode');
+jest.mock('path');
+jest.mock('fs');
+jest.mock('../../src/api/backend');
+jest.mock('../../src/providers/SmellsViewProvider');
+jest.mock('../../src/providers/RefactoringDetailsViewProvider');
+jest.mock('../../src/emitters/serverStatus');
+jest.mock('../../src/extension');
+jest.mock('../../src/utils/refactorActionButtons');
+jest.mock('../../src/utils/trackedDiffEditors');
+
+const mockContext = context as unknown as vscode.ExtensionContext;
+
+describe('refactor', () => {
+ let smellsViewProvider: SmellsViewProvider;
+ let refactoringDetailsViewProvider: RefactoringDetailsViewProvider;
+ const mockSmell = {
+ symbol: 'testSmell',
+ path: '/path/to/file.py',
+ type: 'testType',
+ } as unknown as Smell;
beforeEach(() => {
- // reset all mocks
jest.clearAllMocks();
- // setup mock context manager
- mockContextManager = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as any;
+ smellsViewProvider = new SmellsViewProvider({} as vscode.ExtensionContext);
+ refactoringDetailsViewProvider = new RefactoringDetailsViewProvider();
- // setup mock selection
- mockSelection = {
- start: { line: 0 }, // Line 1 in VS Code's 0-based indexing
- end: { line: 0 },
- };
+ (path.basename as jest.Mock).mockImplementation((p) => p.split('/').pop());
+
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.UP);
+
+ context.workspaceState.get.mockImplementation((key: string) => {
+ if (key === envConfig.WORKSPACE_CONFIGURED_PATH) {
+ return '/workspace/path';
+ }
+ return undefined;
+ });
+ });
+
+ it('should show error when no workspace is configured', async () => {
+ (context.workspaceState.get as jest.Mock).mockReturnValue(undefined);
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ mockSmell,
+ mockContext,
+ );
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Please configure workspace first',
+ );
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Refactoring aborted: No workspace configured'),
+ );
+ });
+
+ it('should show warning when backend is down', async () => {
+ (serverStatus.getStatus as jest.Mock).mockReturnValue(ServerStatusType.DOWN);
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ mockSmell,
+ mockContext,
+ );
+
+ expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
+ 'Cannot refactor - backend service unavailable',
+ );
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockSmell.path,
+ 'server_down',
+ );
+ });
- // setup mock document
- mockDocument = {
- getText: jest.fn().mockReturnValue('mock content'),
- uri: { fsPath: '/test/file.ts' },
+ it('should initiate single smell refactoring', async () => {
+ const mockRefactoredData = {
+ targetFile: {
+ original: '/original/path',
+ refactored: '/refactored/path',
+ },
+ affectedFiles: [],
+ energySaved: 0.5,
+ tempDir: '/temp/dir',
};
- fileHighlighterSpy = jest.spyOn(FileHighlighter, 'getInstance').mockReturnValue({
- highlightSmells: jest.fn(),
- } as any);
+ (
+ require('../../src/api/backend').backendRefactorSmell as jest.Mock
+ ).mockResolvedValue(mockRefactoredData);
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ mockSmell,
+ mockContext,
+ );
+
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ expect.stringContaining('Refactoring testSmell...'),
+ );
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockSmell.path,
+ 'queued',
+ );
+ expect(mockContext.workspaceState.update).toHaveBeenCalled();
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('Refactoring completed for file.py'),
+ );
+ });
- // setup mock editor
- mockEditor = {
- document: mockDocument,
- selection: mockSelection,
+ it('should initiate refactoring all smells of type', async () => {
+ const mockRefactoredData = {
+ targetFile: {
+ original: '/original/path',
+ refactored: '/refactored/path',
+ },
+ affectedFiles: [],
+ energySaved: 1.2,
+ tempDir: '/temp/dir',
};
- // reset vscode.window.activeTextEditor
- (vscode.window as any).activeTextEditor = mockEditor;
+ (
+ require('../../src/api/backend').backendRefactorSmellType as jest.Mock
+ ).mockResolvedValue(mockRefactoredData);
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ mockSmell,
+ mockContext,
+ true,
+ );
+
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ expect.stringContaining('Refactoring all smells of type testSmell...'),
+ );
+ expect(
+ require('../../src/api/backend').backendRefactorSmellType,
+ ).toHaveBeenCalled();
+ });
- // reset commands mock
- (vscode.commands.executeCommand as jest.Mock).mockResolvedValue(undefined);
+ it('should handle refactoring failure', async () => {
+ const error = new Error('Backend error');
+ (
+ require('../../src/api/backend').backendRefactorSmell as jest.Mock
+ ).mockRejectedValue(error);
+
+ await refactor(
+ smellsViewProvider,
+ refactoringDetailsViewProvider,
+ mockSmell,
+ mockContext,
+ );
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Refactoring failed. See output for details.',
+ );
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Refactoring failed: Backend error'),
+ );
+ expect(
+ refactoringDetailsViewProvider.resetRefactoringDetails,
+ ).toHaveBeenCalled();
+ expect(
+ require('../../src/utils/refactorActionButtons').hideRefactorActionButtons,
+ ).toHaveBeenCalled();
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ mockSmell.path,
+ 'failed',
+ );
});
- describe('refactorSelectedSmell', () => {
- it('should show error when no active editor', async () => {
- (vscode.window as any).activeTextEditor = undefined;
+ describe('startRefactorSession', () => {
+ let refactoringDetailsViewProvider: RefactoringDetailsViewProvider;
+ const mockSmell = {
+ symbol: 'testSmell',
+ path: 'original/path/to/file.py',
+ } as unknown as Smell;
+ const mockRefactoredData = {
+ targetFile: {
+ original: 'original/path/to/file.py',
+ refactored: 'refactored/path/to/file.py',
+ },
+ affectedFiles: [],
+ energySaved: 0.5,
+ tempDir: '/refactored',
+ };
- await refactorSelectedSmell(mockContextManager);
+ beforeEach(() => {
+ jest.clearAllMocks();
+ refactoringDetailsViewProvider = new RefactoringDetailsViewProvider();
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Unable to proceed as no active editor or file path found.',
- );
+ // Mock path.basename
+ (path.basename as jest.Mock).mockImplementation((p) => p.split('/').pop());
+
+ // Mock vscode.Uri.file
+ (vscode.Uri.file as jest.Mock).mockImplementation((path) => ({ path }));
});
- it('should show error when no smells detected', async () => {
- mockContextManager.getWorkspaceData.mockImplementation((key) => {
- if (key === envConfig.SMELL_MAP_KEY) {
- return {
- '/test/file.ts': {
- smells: [],
- },
- };
- }
- return undefined;
- });
+ it('should update refactoring details and show diff', async () => {
+ await startRefactorSession(
+ mockSmell,
+ mockRefactoredData,
+ refactoringDetailsViewProvider,
+ );
- await refactorSelectedSmell(mockContextManager);
+ expect(
+ refactoringDetailsViewProvider.updateRefactoringDetails,
+ ).toHaveBeenCalledWith(
+ mockSmell,
+ mockRefactoredData.targetFile,
+ mockRefactoredData.affectedFiles,
+ mockRefactoredData.energySaved,
+ );
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: No smells detected in the file for refactoring.',
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'vscode.diff',
+ expect.anything(),
+ expect.anything(),
+ 'Refactoring Comparison (file.py)',
+ { preview: false },
+ );
+
+ expect(
+ require('../../src/utils/trackedDiffEditors').registerDiffEditor,
+ ).toHaveBeenCalled();
+
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'ecooptimizer.refactorView.focus',
+ );
+
+ expect(
+ require('../../src/utils/refactorActionButtons').showRefactorActionButtons,
+ ).toHaveBeenCalled();
+
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Refactoring complete. Estimated savings: 0.5 kg CO2',
);
});
- it('should show error when no matching smell found for selected line', async () => {
- const mockSmells = [createMockSmell(5)];
-
- mockContextManager.getWorkspaceData.mockImplementation((key) => {
- if (key === envConfig.SMELL_MAP_KEY) {
- return {
- '/test/file.ts': {
- smells: mockSmells,
- },
- };
- }
- return undefined;
- });
+ it('should handle missing energy data', async () => {
+ const dataWithoutEnergy = { ...mockRefactoredData, energySaved: undefined };
- await refactorSelectedSmell(mockContextManager);
+ await startRefactorSession(
+ mockSmell,
+ dataWithoutEnergy,
+ refactoringDetailsViewProvider,
+ );
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: No matching smell found for refactoring.',
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Refactoring complete. Estimated savings: N/A kg CO2',
);
});
+ });
- it('should successfully refactor a smell when found', async () => {
- const mockSmells = [createMockSmell(1)];
-
- const mockRefactorResult = {
- refactoredData: {
- tempDir: '/tmp/test',
- targetFile: {
- original: '/test/file.ts',
- refactored: '/test/file.refactored.ts',
- },
- affectedFiles: [
- {
- original: '/test/other.ts',
- refactored: '/test/other.refactored.ts',
- },
- ],
- energySaved: 10,
- },
- updatedSmells: [
- {
- ...createMockSmell(1),
- messageId: 'updated-smell',
- symbol: 'UpdatedSmell',
- message: 'Updated message',
- },
- ],
- };
+ describe('acceptRefactoring', () => {
+ let metricsDataProvider: { updateMetrics: jest.Mock };
+ let smellsCacheManager: { clearCachedSmellsForFile: jest.Mock };
- mockContextManager.getWorkspaceData.mockImplementation((key) => {
- if (key === envConfig.SMELL_MAP_KEY) {
- return {
- '/test/file.ts': {
- smells: mockSmells,
- },
- };
- }
- return undefined;
- });
+ beforeEach(() => {
+ metricsDataProvider = {
+ updateMetrics: jest.fn(),
+ };
+ smellsCacheManager = {
+ clearCachedSmellsForFile: jest.fn(),
+ };
- (refactorSmell as jest.Mock).mockResolvedValue(mockRefactorResult);
+ // Mock refactoring details
+ refactoringDetailsViewProvider.targetFile = {
+ original: '/original/path',
+ refactored: '/refactored/path',
+ };
+ refactoringDetailsViewProvider.affectedFiles = [
+ { original: '/affected/original', refactored: '/affected/refactored' },
+ ];
+ refactoringDetailsViewProvider.energySaved = 0.5;
+ refactoringDetailsViewProvider.targetSmell = mockSmell;
+ });
- await refactorSelectedSmell(mockContextManager);
+ it('should apply refactoring changes successfully', async () => {
+ await acceptRefactoring(
+ mockContext,
+ refactoringDetailsViewProvider,
+ metricsDataProvider as unknown as MetricsViewProvider,
+ smellsCacheManager as unknown as SmellsCacheManager,
+ smellsViewProvider,
+ );
- expect(vscode.workspace.save).toHaveBeenCalled();
- expect(refactorSmell).toHaveBeenCalledWith('/test/file.ts', mockSmells[0]);
- expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Refactoring report available in sidebar.',
+ expect(fs.copyFileSync).toHaveBeenCalledTimes(2);
+ expect(metricsDataProvider.updateMetrics).toHaveBeenCalled();
+ expect(smellsCacheManager.clearCachedSmellsForFile).toHaveBeenCalledTimes(2);
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ '/original/path',
+ 'outdated',
);
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'extension.refactorSidebar.focus',
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Refactoring successfully applied',
);
- expect(vscode.workspace.openTextDocument).toHaveBeenCalled();
- expect(vscode.window.showTextDocument).toHaveBeenCalled();
- expect(fileHighlighterSpy).toHaveBeenCalled();
});
- it('should handle refactoring failure', async () => {
- const mockSmells = [createMockSmell(1)];
-
- mockContextManager.getWorkspaceData.mockImplementation((key) => {
- if (key === envConfig.SMELL_MAP_KEY) {
- return {
- '/test/file.ts': {
- smells: mockSmells,
- },
- };
- }
- return undefined;
- });
+ it('should handle missing refactoring data', async () => {
+ refactoringDetailsViewProvider.targetFile = undefined;
- (refactorSmell as jest.Mock).mockRejectedValue(
- new Error('Refactoring failed'),
+ await acceptRefactoring(
+ mockContext,
+ refactoringDetailsViewProvider,
+ metricsDataProvider as unknown as MetricsViewProvider,
+ smellsCacheManager as unknown as SmellsCacheManager,
+ smellsViewProvider,
);
- await refactorSelectedSmell(mockContextManager);
-
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Refactoring failed. See console for details.',
+ 'No refactoring data available.',
);
});
- it('should handle given smell parameter', async () => {
- const givenSmell = createMockSmell(3);
- const mockSmells = [givenSmell];
-
- mockContextManager.getWorkspaceData.mockImplementation((key) => {
- if (key === envConfig.SMELL_MAP_KEY) {
- return {
- '/test/file.ts': {
- smells: mockSmells,
- },
- };
- }
- return undefined;
+ it('should handle filesystem errors', async () => {
+ (fs.copyFileSync as jest.Mock).mockImplementation(() => {
+ throw new Error('Filesystem error');
});
- const mockRefactorResult = {
- refactoredData: {
- tempDir: '/tmp/test',
- targetFile: {
- original: '/test/file.ts',
- refactored: '/test/file.refactored.ts',
- },
- affectedFiles: [
- {
- original: '/test/other.ts',
- refactored: '/test/other.refactored.ts',
- },
- ],
- energySaved: 10,
- },
- updatedSmells: [],
- };
-
- (refactorSmell as jest.Mock).mockResolvedValue(mockRefactorResult);
-
- await refactorSelectedSmell(mockContextManager, givenSmell);
-
- expect(refactorSmell).toHaveBeenCalledWith('/test/file.ts', givenSmell);
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'extension.refactorSidebar.focus',
+ await acceptRefactoring(
+ mockContext,
+ refactoringDetailsViewProvider,
+ metricsDataProvider as unknown as MetricsViewProvider,
+ smellsCacheManager as unknown as SmellsCacheManager,
+ smellsViewProvider,
);
- expect(vscode.workspace.openTextDocument).toHaveBeenCalled();
- expect(vscode.window.showTextDocument).toHaveBeenCalled();
- expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
- 'Eco: No updated smells detected after refactoring.',
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Failed to apply refactoring. Please try again.',
);
});
});
- describe('Clean Temp Directory', () => {
- it('removes one temp directory', async () => {
- const mockPastData = { tempDir: 'mock/temp/dir' };
-
- jest.spyOn(fs.promises, 'rm').mockResolvedValueOnce();
+ describe('rejectRefactoring', () => {
+ beforeEach(() => {
+ refactoringDetailsViewProvider.targetFile = {
+ original: '/original/path',
+ refactored: '/refactored/path',
+ };
+ });
- await cleanTemps(mockPastData);
+ it('should clean up after rejecting refactoring', async () => {
+ await rejectRefactoring(
+ mockContext,
+ refactoringDetailsViewProvider,
+ smellsViewProvider,
+ );
- expect(fs.promises.rm).toHaveBeenCalled();
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(
+ '/original/path',
+ 'passed',
+ );
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Refactoring changes discarded',
+ );
+ expect(mockContext.workspaceState.update).toHaveBeenCalledWith(
+ envConfig.UNFINISHED_REFACTORING!,
+ undefined,
+ );
});
- it('removes multiple temp directory', async () => {
- const mockPastData = { tempDirs: ['mock/temp/dir1', 'mock/temp/dir2'] };
-
- jest.spyOn(fs.promises, 'rm').mockResolvedValueOnce();
+ it('should handle errors during cleanup', async () => {
+ (smellsViewProvider.setStatus as jest.Mock).mockImplementation(() => {
+ throw new Error('Status update failed');
+ });
- await cleanTemps(mockPastData);
+ await rejectRefactoring(
+ mockContext,
+ refactoringDetailsViewProvider,
+ smellsViewProvider,
+ );
- expect(fs.promises.rm).toHaveBeenCalledTimes(2);
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Error during rejection cleanup'),
+ );
});
});
});
diff --git a/test/commands/resetConfiguration.test.ts b/test/commands/resetConfiguration.test.ts
new file mode 100644
index 0000000..e7d08ad
--- /dev/null
+++ b/test/commands/resetConfiguration.test.ts
@@ -0,0 +1,58 @@
+import * as vscode from 'vscode';
+import { resetConfiguration } from '../../src/commands/resetConfiguration';
+import { envConfig } from '../../src/utils/envConfig';
+
+jest.mock('vscode', () => {
+ const original = jest.requireActual('vscode');
+ return {
+ ...original,
+ window: {
+ showWarningMessage: jest.fn(),
+ },
+ commands: {
+ executeCommand: jest.fn(),
+ },
+ };
+});
+
+describe('resetConfiguration (Jest)', () => {
+ const mockContext = {
+ workspaceState: {
+ update: jest.fn(),
+ },
+ } as unknown as vscode.ExtensionContext;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ envConfig.WORKSPACE_CONFIGURED_PATH = 'myWorkspaceKey';
+ });
+
+ it('should reset workspace configuration when confirmed', async () => {
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue('Reset');
+
+ const result = await resetConfiguration(mockContext);
+
+ expect(mockContext.workspaceState.update).toHaveBeenCalledWith(
+ 'myWorkspaceKey',
+ undefined,
+ );
+
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'setContext',
+ 'workspaceState.workspaceConfigured',
+ false,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('should not reset workspace configuration if user cancels', async () => {
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await resetConfiguration(mockContext);
+
+ expect(mockContext.workspaceState.update).not.toHaveBeenCalled();
+ expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
+ expect(result).toBe(false);
+ });
+});
diff --git a/test/commands/toggleSmellLinting.test.ts b/test/commands/toggleSmellLinting.test.ts
deleted file mode 100644
index 8aefb2a..0000000
--- a/test/commands/toggleSmellLinting.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import * as vscode from 'vscode';
-import { ContextManager } from '../../src/context/contextManager';
-import { toggleSmellLinting } from '../../src/commands/toggleSmellLinting';
-import { FileHighlighter } from '../../src/ui/fileHighlighter';
-import { detectSmells } from '../../src/commands/detectSmells';
-import { envConfig } from '../../src/utils/envConfig';
-
-jest.mock('../../src/commands/detectSmells', () => ({
- detectSmells: jest.fn(),
-}));
-
-jest.mock('../../src/ui/fileHighlighter', () => ({
- FileHighlighter: {
- getInstance: jest.fn(),
- },
-}));
-
-describe('toggleSmellLinting', () => {
- let contextManagerMock: ContextManager;
- let fileHighlighterMock: FileHighlighter;
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- contextManagerMock = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as unknown as ContextManager;
-
- fileHighlighterMock = {
- resetHighlights: jest.fn(),
- } as unknown as FileHighlighter;
-
- (FileHighlighter.getInstance as jest.Mock).mockReturnValue(fileHighlighterMock);
- });
-
- it('should toggle from disabled to enabled state', async () => {
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue(false);
-
- await toggleSmellLinting(contextManagerMock);
-
- expect(detectSmells).toHaveBeenCalledWith(contextManagerMock);
-
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- true,
- );
-
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'setContext',
- 'eco.smellLintingEnabled',
- true,
- );
- });
-
- it('should toggle from enabled to disabled state', async () => {
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue(true);
-
- await toggleSmellLinting(contextManagerMock);
-
- expect(fileHighlighterMock.resetHighlights).toHaveBeenCalled();
-
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_LINTING_ENABLED_KEY,
- false,
- );
-
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'setContext',
- 'eco.smellLintingEnabled',
- false,
- );
-
- expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Smell linting turned off.',
- );
- });
-
- it('should handle errors and revert UI state', async () => {
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue(false);
-
- (detectSmells as jest.Mock).mockRejectedValue(new Error('Test error'));
-
- await toggleSmellLinting(contextManagerMock);
-
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Failed to toggle smell linting.',
- );
-
- expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
- 'setContext',
- 'eco.smellLintingEnabled',
- false,
- );
- });
-});
diff --git a/test/commands/wipeWorkCache.test.ts b/test/commands/wipeWorkCache.test.ts
index 38ff42e..ac85d1b 100644
--- a/test/commands/wipeWorkCache.test.ts
+++ b/test/commands/wipeWorkCache.test.ts
@@ -1,115 +1,130 @@
-import mockContextManager from '../mocks/contextManager-mock';
-import { wipeWorkCache } from '../../src/commands/wipeWorkCache';
-import vscode from '../mocks/vscode-mock';
-import { envConfig } from '../mocks/env-config-mock';
-import { updateHash } from '../../src/utils/hashDocs';
-
-// mock updateHash function
-jest.mock('../../src/utils/hashDocs', () => ({
- updateHash: jest.fn(),
-}));
+// test/wipeWorkCache.test.ts
+import * as vscode from 'vscode';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider';
+import { wipeWorkCache } from '../../src/commands/detection/wipeWorkCache';
+import context from '../mocks/context-mock';
+
+// Mock the external dependencies
+jest.mock('vscode');
+jest.mock('../../src/context/SmellsCacheManager');
+jest.mock('../../src/providers/SmellsViewProvider');
describe('wipeWorkCache', () => {
- beforeEach(() => {
- jest.clearAllMocks(); // reset mocks before each test
- });
+ let smellsCacheManager: SmellsCacheManager;
+ let smellsViewProvider: SmellsViewProvider;
- test('should clear stored smells cache with no reason provided', async () => {
- // call wipeWorkCache with contextManagerMock
- await wipeWorkCache(mockContextManager);
+ beforeEach(() => {
+ // Reset all mocks before each test
+ jest.clearAllMocks();
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_MAP_KEY,
- {},
+ // Setup mock instances
+ smellsCacheManager = new SmellsCacheManager(
+ context as unknown as vscode.ExtensionContext,
+ );
+ smellsViewProvider = new SmellsViewProvider(
+ context as unknown as vscode.ExtensionContext,
);
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledTimes(1); // only the smells cache should be cleared when no reason is provided
+
+ // Mock the showWarningMessage to return undefined by default
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(undefined);
});
- test('should clear stored smells cache when reason is settings', async () => {
- // call wipeWorkCache with contextManagerMock
- await wipeWorkCache(mockContextManager, 'settings');
+ it('should show confirmation dialog before clearing cache', async () => {
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_MAP_KEY,
- {},
+ expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
+ 'Are you sure you want to clear the entire workspace analysis? This action cannot be undone.',
+ { modal: true },
+ 'Confirm',
);
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledTimes(1); // only the smells cache should be cleared when reason is settings
});
- test('should clear file changes when reason is manual', async () => {
- // call wipeWorkCache with contextManagerMock
- await wipeWorkCache(mockContextManager, 'manual');
+ it('should clear cache and refresh UI when user confirms', async () => {
+ // Mock user confirming the action
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue('Confirm');
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.SMELL_MAP_KEY,
- {},
- );
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledWith(
- envConfig.FILE_CHANGES_KEY,
- {},
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
+
+ expect(smellsCacheManager.clearAllCachedSmells).toHaveBeenCalled();
+ expect(smellsViewProvider.clearAllStatuses).toHaveBeenCalled();
+ expect(smellsViewProvider.refresh).toHaveBeenCalled();
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Workspace analysis cleared successfully.',
);
- expect(mockContextManager.setWorkspaceData).toHaveBeenCalledTimes(2); // both caches should be cleared when reason is manul
});
- test('should log when there are no visible text editors', async () => {
- vscode.window.visibleTextEditors = []; // simulate no open editors
+ it('should not clear cache when user cancels', async () => {
+ // Mock user cancelling the action
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(undefined);
- const consoleSpy = jest.spyOn(console, 'log');
- await wipeWorkCache(mockContextManager);
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
- expect(consoleSpy).toHaveBeenCalledWith('Eco: No open files to update hash.');
+ expect(smellsCacheManager.clearAllCachedSmells).not.toHaveBeenCalled();
+ expect(smellsViewProvider.clearAllStatuses).not.toHaveBeenCalled();
+ expect(smellsViewProvider.refresh).not.toHaveBeenCalled();
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Operation cancelled.',
+ );
});
- test('should update hashes for visible text editors', async () => {
- vscode.window.visibleTextEditors = [
- {
- document: { fileName: 'file1.py', getText: jest.fn(() => 'file1 content') },
- } as any,
- {
- document: { fileName: 'file2.py', getText: jest.fn(() => 'file2 content') },
- } as any,
- ];
-
- await wipeWorkCache(mockContextManager);
- expect(updateHash).toHaveBeenCalledTimes(2); // should call updateHash for each open document
- });
- test('should display the correct message for default wipe', async () => {
- await wipeWorkCache(mockContextManager);
+ it('should not clear cache when user dismisses dialog', async () => {
+ // Mock user dismissing the dialog (different from cancelling)
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(undefined);
+
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
+ expect(smellsCacheManager.clearAllCachedSmells).not.toHaveBeenCalled();
+ expect(smellsViewProvider.clearAllStatuses).not.toHaveBeenCalled();
+ expect(smellsViewProvider.refresh).not.toHaveBeenCalled();
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Successfully wiped workspace cache! ✅',
+ 'Operation cancelled.',
);
});
- test('should display the correct message when reason is "settings"', async () => {
- await wipeWorkCache(mockContextManager, 'settings');
+ it('should handle case where user clicks something other than Confirm', async () => {
+ // Mock user clicking something else (e.g., a different button if more were added)
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(
+ 'Other Option',
+ );
+
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
+ expect(smellsCacheManager.clearAllCachedSmells).not.toHaveBeenCalled();
+ expect(smellsViewProvider.clearAllStatuses).not.toHaveBeenCalled();
+ expect(smellsViewProvider.refresh).not.toHaveBeenCalled();
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Smell detection settings changed. Cache wiped to apply updates. ✅',
+ 'Operation cancelled.',
);
});
- test('should display the correct message when reason is "manual"', async () => {
- await wipeWorkCache(mockContextManager, 'manual');
+ it('should show success message only after successful cache clearing', async () => {
+ // Mock user confirming the action
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue('Confirm');
+
+ // Mock a successful cache clearing
+ (smellsCacheManager.clearAllCachedSmells as jest.Mock).mockImplementation(() => {
+ // Simulate successful clearing
+ });
+
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Workspace cache manually wiped by user. ✅',
+ 'Workspace analysis cleared successfully.',
);
});
- test('should handle errors and display an error message', async () => {
- mockContextManager.setWorkspaceData.mockRejectedValue(new Error('Mocked Error'));
+ it('should still show cancellation message if confirmation is aborted', async () => {
+ // Simulate the confirmation dialog being closed without any selection
+ (vscode.window.showWarningMessage as jest.Mock).mockResolvedValue(undefined);
- const consoleErrorSpy = jest.spyOn(console, 'error');
+ await wipeWorkCache(smellsCacheManager, smellsViewProvider);
- await wipeWorkCache(mockContextManager);
-
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- 'Eco: Error while wiping workspace cache:',
- expect.any(Error),
+ expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
+ 'Operation cancelled.',
);
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- 'Eco: Failed to wipe workspace cache. See console for details.',
+ expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith(
+ 'Workspace analysis cleared successfully.',
);
});
});
diff --git a/test/listeners/workspaceModifiedListener.test.ts b/test/listeners/workspaceModifiedListener.test.ts
new file mode 100644
index 0000000..8d90dca
--- /dev/null
+++ b/test/listeners/workspaceModifiedListener.test.ts
@@ -0,0 +1,269 @@
+/* eslint-disable unused-imports/no-unused-imports */
+import * as vscode from 'vscode';
+import path from 'path';
+
+import { envConfig } from '../../src/utils/envConfig';
+import { WorkspaceModifiedListener } from '../../src/listeners/workspaceModifiedListener';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider';
+import { MetricsViewProvider } from '../../src/providers/MetricsViewProvider';
+import { ecoOutput } from '../../src/extension';
+import { detectSmellsFile } from '../../src/commands/detection/detectSmells';
+
+// Mock dependencies
+jest.mock('path', () => ({
+ basename: jest.fn((path) => path),
+}));
+jest.mock('../../src/extension');
+jest.mock('../../src/commands/detection/detectSmells');
+jest.mock('../../src/utils/envConfig');
+
+describe('WorkspaceModifiedListener', () => {
+ let mockContext: vscode.ExtensionContext;
+ let mockSmellsCacheManager: jest.Mocked;
+ let mockSmellsViewProvider: jest.Mocked;
+ let mockMetricsViewProvider: jest.Mocked;
+ let listener: WorkspaceModifiedListener;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockContext = {
+ workspaceState: {
+ get: jest.fn(),
+ },
+ } as unknown as vscode.ExtensionContext;
+
+ mockSmellsCacheManager = {
+ hasFileInCache: jest.fn(),
+ hasCachedSmells: jest.fn(),
+ clearCachedSmellsForFile: jest.fn(),
+ clearCachedSmellsByPath: jest.fn(),
+ getAllFilePaths: jest.fn(() => []),
+ } as unknown as jest.Mocked;
+
+ mockSmellsViewProvider = {
+ setStatus: jest.fn(),
+ removeFile: jest.fn(),
+ refresh: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ mockMetricsViewProvider = {
+ refresh: jest.fn(),
+ } as unknown as jest.Mocked;
+ });
+
+ describe('Initialization', () => {
+ it('should initialize without workspace path', () => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue(undefined);
+ new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ '[WorkspaceListener] No workspace configured - skipping file watcher',
+ );
+ });
+
+ it('should initialize with workspace path', () => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+
+ console.log((ecoOutput.trace as jest.Mock).mock);
+ expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalled();
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ '[WorkspaceListener] Watching Python files in /project/path',
+ );
+ });
+ });
+
+ describe('File Change Handling', () => {
+ beforeEach(() => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+ });
+
+ it('should handle file change with existing cache', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasFileInCache as jest.Mock).mockReturnValue(true);
+
+ await listener['handleFileChange'](filePath);
+
+ expect(mockSmellsCacheManager.clearCachedSmellsForFile).toHaveBeenCalledWith(
+ filePath,
+ );
+ expect(mockSmellsViewProvider.setStatus).toHaveBeenCalledWith(
+ filePath,
+ 'outdated',
+ );
+ expect(vscode.window.showInformationMessage).toHaveBeenCalled();
+ expect(mockSmellsViewProvider.refresh).toHaveBeenCalled();
+ });
+
+ it('should skip file change without cache', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasFileInCache as jest.Mock).mockReturnValue(false);
+
+ await listener['handleFileChange'](filePath);
+
+ expect(mockSmellsCacheManager.clearCachedSmellsForFile).not.toHaveBeenCalled();
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ '[WorkspaceListener] No cache to invalidate for /project/path/file.py',
+ );
+ });
+
+ it('should handle file change errors', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasFileInCache as jest.Mock).mockReturnValue(true);
+ (
+ mockSmellsCacheManager.clearCachedSmellsForFile as jest.Mock
+ ).mockRejectedValue(new Error('Cache error'));
+
+ await listener['handleFileChange'](filePath);
+
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Error handling file change: Cache error'),
+ );
+ });
+ });
+
+ describe('File Deletion Handling', () => {
+ beforeEach(() => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+ });
+
+ it('should handle file deletion with cache', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(true);
+ (mockSmellsViewProvider.removeFile as jest.Mock).mockReturnValue(true);
+
+ await listener['handleFileDeletion'](filePath);
+
+ expect(mockSmellsCacheManager.clearCachedSmellsByPath).toHaveBeenCalledWith(
+ filePath,
+ );
+ expect(mockSmellsViewProvider.removeFile).toHaveBeenCalledWith(filePath);
+ // expect(vscode.window.showInformationMessage).toHaveBeenCalled();
+ expect(mockSmellsViewProvider.refresh).toHaveBeenCalled();
+ });
+
+ it('should handle file deletion without cache', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(false);
+ (mockSmellsViewProvider.removeFile as jest.Mock).mockReturnValue(false);
+
+ await listener['handleFileDeletion'](filePath);
+
+ expect(mockSmellsCacheManager.clearCachedSmellsByPath).not.toHaveBeenCalled();
+ expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
+ });
+
+ it('should handle deletion errors', async () => {
+ const filePath = '/project/path/file.py';
+ (mockSmellsCacheManager.hasCachedSmells as jest.Mock).mockReturnValue(true);
+ (
+ mockSmellsCacheManager.clearCachedSmellsByPath as jest.Mock
+ ).mockRejectedValue(new Error('Deletion error'));
+
+ await listener['handleFileDeletion'](filePath);
+
+ expect(ecoOutput.error).toHaveBeenCalledWith(
+ expect.stringContaining('Error clearing cache: Deletion error'),
+ );
+ });
+ });
+
+ describe('Save Listener', () => {
+ it('should trigger smell detection on Python file save when enabled', () => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+ (
+ require('../../src/extension').isSmellLintingEnabled as jest.Mock
+ ).mockReturnValue(true);
+
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+
+ // Trigger save event
+ const onDidSave = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock
+ .calls[0][0];
+ const mockDocument = {
+ languageId: 'python',
+ uri: { fsPath: '/project/path/file.py' },
+ };
+ onDidSave(mockDocument);
+
+ expect(detectSmellsFile).toHaveBeenCalledWith(
+ '/project/path/file.py',
+ mockSmellsViewProvider,
+ mockSmellsCacheManager,
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ '[WorkspaceListener] Smell linting is ON — auto-detecting smells for /project/path/file.py',
+ );
+ });
+
+ it('should skip non-Python files on save', () => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+
+ // Trigger save event
+ const onDidSave = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock
+ .calls[0][0];
+ const mockDocument = {
+ languageId: 'javascript',
+ uri: { fsPath: '/project/path/file.js' },
+ };
+ onDidSave(mockDocument);
+
+ expect(detectSmellsFile).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Disposal', () => {
+ it('should clean up resources on dispose', () => {
+ (mockContext.workspaceState.get as jest.Mock).mockReturnValue('/project/path');
+ listener = new WorkspaceModifiedListener(
+ mockContext,
+ mockSmellsCacheManager,
+ mockSmellsViewProvider,
+ mockMetricsViewProvider,
+ );
+
+ listener.dispose();
+
+ expect(listener['fileWatcher']?.dispose).toHaveBeenCalled();
+ expect(listener['saveListener']?.dispose).toHaveBeenCalled();
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ '[WorkspaceListener] Disposed all listeners',
+ );
+ });
+ });
+});
diff --git a/test/mocks/context-mock.ts b/test/mocks/context-mock.ts
new file mode 100644
index 0000000..6cd7580
--- /dev/null
+++ b/test/mocks/context-mock.ts
@@ -0,0 +1,31 @@
+// test/mocks/contextManager-mock.ts
+interface ContextStorage {
+ globalState: Record;
+ workspaceState: Record;
+}
+
+const contextStorage: ContextStorage = {
+ globalState: {},
+ workspaceState: {},
+};
+
+const mockExtensionContext = {
+ globalState: {
+ get: jest.fn((key: string, defaultVal?: any) => {
+ return contextStorage.globalState[key] ?? defaultVal;
+ }),
+ update: jest.fn(async (key: string, value: any) => {
+ contextStorage.globalState[key] = value;
+ }),
+ } as any,
+ workspaceState: {
+ get: jest.fn((key: string, defaultVal?: any) => {
+ return contextStorage.workspaceState[key] ?? defaultVal;
+ }),
+ update: jest.fn(async (key: string, value: any) => {
+ contextStorage.workspaceState[key] = value;
+ }),
+ } as any,
+};
+
+export default mockExtensionContext;
diff --git a/test/mocks/contextManager-mock.ts b/test/mocks/contextManager-mock.ts
deleted file mode 100644
index 745d9a6..0000000
--- a/test/mocks/contextManager-mock.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-// test/mocks/contextManager-mock.ts
-import * as vscode from 'vscode';
-
-interface ContextStorage {
- globalState: Record;
- workspaceState: Record;
-}
-
-const contextStorage: ContextStorage = {
- globalState: {},
- workspaceState: {},
-};
-
-const mockExtensionContext: Partial = {
- globalState: {
- get: jest.fn((key: string, defaultVal?: any) => {
- console.log(`MOCK getGlobalData: ${key}`);
- return contextStorage.globalState[key] ?? defaultVal;
- }),
- update: jest.fn(async (key: string, value: any) => {
- console.log(`MOCK setGlobalData: ${key}:${value}`);
- contextStorage.globalState[key] = value;
- }),
- } as any, // Casting to `any` to satisfy `vscode.ExtensionContext`
- workspaceState: {
- get: jest.fn((key: string, defaultVal?: any) => {
- console.log(`MOCK getWorkspaceData: ${key}`);
- return contextStorage.workspaceState[key] ?? defaultVal;
- }),
- update: jest.fn(async (key: string, value: any) => {
- console.log(`MOCK setWorkspaceData ${key}:${value}`);
- contextStorage.workspaceState[key] = value;
- }),
- } as any, // Casting to `any` to satisfy `vscode.ExtensionContext`
-};
-
-const mockContextManager = {
- context: mockExtensionContext as vscode.ExtensionContext,
- getGlobalData: jest.fn((key: string, defaultVal?: any) => {
- return contextStorage.globalState[key] ?? defaultVal;
- }),
- setGlobalData: jest.fn(async (key: string, value: any) => {
- contextStorage.globalState[key] = value;
- }),
- getWorkspaceData: jest.fn((key: string, defaultVal?: any) => {
- return contextStorage.workspaceState[key] ?? defaultVal;
- }),
- setWorkspaceData: jest.fn(async (key: string, value: any) => {
- contextStorage.workspaceState[key] = value;
- }),
-};
-
-export default mockContextManager;
diff --git a/test/mocks/env-config-mock.ts b/test/mocks/env-config-mock.ts
index c108fc2..e98c4df 100644
--- a/test/mocks/env-config-mock.ts
+++ b/test/mocks/env-config-mock.ts
@@ -1,17 +1,12 @@
-import { envConfig, EnvConfig } from '../../src/utils/envConfig';
+import { EnvConfig } from '../../src/utils/envConfig';
-jest.mock('../../src/utils/envConfig', () => {
- const mockEnvConfig: EnvConfig = {
- SERVER_URL: 'server-url',
- SMELL_MAP_KEY: 'smell-map-key',
- FILE_CHANGES_KEY: 'file-changes-key',
- LAST_USED_SMELLS_KEY: 'last-used-smells-key',
- CURRENT_REFACTOR_DATA_KEY: 'current-refactor-data-key',
- ACTIVE_DIFF_KEY: 'active-diff-key',
- SMELL_LINTING_ENABLED_KEY: 'smellLintingEnabledKey',
- };
+const mockEnvConfig: EnvConfig = {
+ SERVER_URL: 'localhost:8000',
+ SMELL_CACHE_KEY: 'value2',
+ HASH_PATH_MAP_KEY: 'value3',
+ WORKSPACE_METRICS_DATA: 'value4',
+ WORKSPACE_CONFIGURED_PATH: 'value5',
+ UNFINISHED_REFACTORING: 'value6',
+};
- return { envConfig: mockEnvConfig };
-});
-
-export { envConfig };
+export default mockEnvConfig;
diff --git a/test/mocks/vscode-mock.ts b/test/mocks/vscode-mock.ts
index e058729..7e97708 100644
--- a/test/mocks/vscode-mock.ts
+++ b/test/mocks/vscode-mock.ts
@@ -3,6 +3,7 @@ interface Config {
configGet: any;
filePath: any;
docText: any;
+ workspacePath: any;
}
// Configuration object to dynamically change values during tests
@@ -10,19 +11,23 @@ export const config: Config = {
configGet: { smell1: true, smell2: true },
filePath: 'fake.py',
docText: 'Mock document text',
+ workspacePath: '/workspace/path',
};
export const TextDocument = {
getText: jest.fn(() => config.docText),
fileName: config.filePath,
languageId: 'python',
- lineAt: jest.fn((line: number) => {
- console.log('MOCK lineAt:', line);
+ lineAt: jest.fn((_line: number) => {
return {
text: 'Mock line text',
};
}),
lineCount: 10,
+ uri: {
+ scheme: 'file',
+ fsPath: config.filePath,
+ },
};
// Mock for `vscode.TextEditor`
@@ -34,6 +39,7 @@ export const TextEditor = {
isSingleLine: true,
},
setDecorations: jest.fn(),
+ revealRange: jest.fn(),
};
export interface TextEditorDecorationType {
@@ -49,39 +55,111 @@ interface Window {
showErrorMessage: jest.Mock;
showWarningMessage: jest.Mock;
createTextEditorDecorationType: jest.Mock;
+ createOutputChannel: jest.Mock;
activeTextEditor: any;
visibleTextEditors: any[];
+ withProgress: jest.Mock;
+ showInputBox: jest.Mock;
+ showQuickPick: jest.Mock;
}
export const window: Window = {
- showInformationMessage: jest.fn(async (message: string) => {
- console.log('MOCK showInformationMessage:', message);
- return message;
+ showInformationMessage: jest.fn(async (message: string, options?: any) => {
+ return options?.modal ? 'Confirm' : message;
}),
showErrorMessage: jest.fn(async (message: string) => {
- console.log('MOCK showErrorMessage:', message);
return message;
}),
- showWarningMessage: jest.fn(async (message: string) => {
- console.log('MOCK showWarningMessage:', message);
- return message;
+ showWarningMessage: jest.fn(async (message: string, options?: any) => {
+ return options?.modal ? 'Confirm' : message;
}),
activeTextEditor: TextEditor,
visibleTextEditors: [],
createTextEditorDecorationType: jest.fn((_options: any) => {
- console.log('MOCK createTextEditorDecorationType:');
return textEditorDecorationType;
}),
+ createOutputChannel: jest.fn(() => ({
+ appendLine: jest.fn(),
+ show: jest.fn(),
+ clear: jest.fn(),
+ log: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ trace: jest.fn(),
+ })),
+ withProgress: jest.fn((options, task) => {
+ return task({
+ report: jest.fn(),
+ });
+ }),
+ showInputBox: jest.fn((val) => {}),
+ showQuickPick: jest.fn(),
};
+export enum FileType {
+ Directory = 1,
+ File = 2,
+}
+
interface Workspace {
getConfiguration: jest.Mock;
+ createFileSystemWatcher: jest.Mock;
+ onDidSaveTextDocument: jest.Mock;
+ findFiles: jest.Mock;
+ fs: {
+ readFile: jest.Mock;
+ writeFile: jest.Mock;
+ stat: jest.Mock;
+ };
}
export const workspace: Workspace = {
- getConfiguration: jest.fn((section?: string) => ({
- get: jest.fn(),
+ getConfiguration: jest.fn((_section?: string) => ({
+ get: jest.fn(() => config.configGet),
+ update: jest.fn(),
})),
+ createFileSystemWatcher: jest.fn(
+ () =>
+ ({
+ onDidCreate: jest.fn(),
+ onDidDelete: jest.fn(),
+ dispose: jest.fn(),
+ }) as unknown,
+ ),
+ onDidSaveTextDocument: jest.fn(
+ () =>
+ ({
+ dispose: jest.fn(),
+ }) as unknown,
+ ),
+ findFiles: jest.fn(),
+ fs: {
+ readFile: jest.fn(),
+ writeFile: jest.fn(),
+ stat: jest.fn(),
+ },
+};
+
+interface MockCommand {
+ title: string;
+ command: string;
+ arguments?: any[];
+ tooltip?: string;
+}
+
+export const Command = jest
+ .fn()
+ .mockImplementation((title: string, command: string, ...args: any[]) => {
+ return {
+ title,
+ command,
+ arguments: args,
+ tooltip: title,
+ };
+ }) as jest.Mock & {
+ prototype: MockCommand;
};
export const OverviewRulerLane = {
@@ -97,19 +175,77 @@ export const Range = class MockRange {
) {}
};
-// New mocks for hover functionality
export const languages = {
registerHoverProvider: jest.fn(() => ({
dispose: jest.fn(),
})),
+ registerCodeActionsProvider: jest.fn(),
};
+export enum ProgressLocation {
+ SourceControl = 1,
+ Window = 10,
+ Notification = 15,
+}
+
+// ProgressOptions interface
+interface ProgressOptions {
+ location: ProgressLocation | { viewId: string };
+ title?: string;
+ cancellable?: boolean;
+}
+
+// Progress mock
+interface Progress {
+ report(value: T): void;
+}
+
+// Window.withProgress mock implementation
+window.withProgress = jest.fn(
+ (
+ options: ProgressOptions,
+ task: (
+ progress: Progress<{ message?: string; increment?: number }>,
+ ) => Promise,
+ ) => {
+ const progress = {
+ report: jest.fn(),
+ };
+ return task(progress);
+ },
+);
+
export const commands = {
- registerCommand: jest.fn(),
- executeCommand: jest.fn(),
+ registerCommand: jest.fn((command: string, func: Function) => ({
+ dispose: jest.fn(),
+ })),
+ executeCommand: jest.fn((command: string) => {
+ if (command === 'setContext') {
+ return Promise.resolve();
+ }
+ return Promise.resolve();
+ }),
+};
+
+export const Uri = {
+ file: jest.fn((path: string) => ({
+ scheme: 'file',
+ path,
+ fsPath: path,
+ toString: (): string => path,
+ })),
+ joinPath: jest.fn((start: string, end: string) => {
+ const newPath = start + end;
+ return {
+ scheme: 'file',
+ path: newPath,
+ fsPath: newPath,
+ toString: (): string => newPath,
+ };
+ }),
+ parse: jest.fn(),
};
-// Mock VS Code classes
export const Position = class MockPosition {
constructor(
public line: number,
@@ -123,7 +259,13 @@ interface MockMarkdownString {
isTrusted: boolean;
}
-// Create a constructor function mock
+export class RelativePattern {
+ constructor(
+ public path: string,
+ pattern: string,
+ ) {}
+}
+
export const MarkdownString = jest.fn().mockImplementation(() => {
return {
appendMarkdown: jest.fn(function (this: any, value: string) {
@@ -141,8 +283,49 @@ export class MockHover {
constructor(public contents: MockMarkdownString) {}
}
+export enum TreeItemCollapsibleState {
+ None = 0,
+ Collapsed = 1,
+ Expanded = 2,
+}
+
+export class MockTreeItem {
+ constructor(
+ public label: string,
+ public collapsibleState?: TreeItemCollapsibleState,
+ public command?: MockCommand,
+ ) {}
+
+ iconPath?:
+ | string
+ | typeof Uri
+ | { light: string | typeof Uri; dark: string | typeof Uri };
+ description?: string;
+ tooltip?: string;
+ contextValue?: string;
+}
+
+export const TreeItem = MockTreeItem;
+
export const Hover = MockHover;
+export const ExtensionContext = {
+ subscriptions: [],
+ workspaceState: {
+ get: jest.fn((key: string) => {
+ if (key === 'workspaceConfiguredPath') {
+ return config.workspacePath;
+ }
+ return undefined;
+ }),
+ update: jest.fn(),
+ },
+ globalState: {
+ get: jest.fn(),
+ update: jest.fn(),
+ },
+};
+
export interface Vscode {
window: Window;
workspace: Workspace;
@@ -152,9 +335,17 @@ export interface Vscode {
languages: typeof languages;
commands: typeof commands;
OverviewRulerLane: typeof OverviewRulerLane;
+ ProgressLocation: typeof ProgressLocation;
+ FileType: typeof FileType;
+ RelativePattern: typeof RelativePattern;
Range: typeof Range;
Position: typeof Position;
Hover: typeof Hover;
+ Command: typeof Command;
+ Uri: typeof Uri;
+ TreeItem: typeof TreeItem;
+ TreeItemCollapsibleState: typeof TreeItemCollapsibleState;
+ ExtensionContext: typeof ExtensionContext;
}
const vscode: Vscode = {
@@ -166,9 +357,17 @@ const vscode: Vscode = {
languages,
commands,
OverviewRulerLane,
+ ProgressLocation,
+ FileType,
+ RelativePattern,
Range,
Position,
Hover,
+ Command,
+ Uri,
+ TreeItem,
+ TreeItemCollapsibleState,
+ ExtensionContext,
};
export default vscode;
diff --git a/test/setup.ts b/test/setup.ts
index bb487a4..f201390 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -1,3 +1,7 @@
+import mockEnvConfig from './mocks/env-config-mock';
+
jest.mock('vscode');
-jest.mock('./../src/utils/envConfig');
+jest.mock('../src/utils/envConfig', () => ({
+ envConfig: mockEnvConfig,
+}));
diff --git a/test/ui/fileHighlighter.test.ts b/test/ui/fileHighlighter.test.ts
index db45873..8fb7a67 100644
--- a/test/ui/fileHighlighter.test.ts
+++ b/test/ui/fileHighlighter.test.ts
@@ -1,122 +1,295 @@
+// test/fileHighlighter.test.ts
+import * as vscode from 'vscode';
import { FileHighlighter } from '../../src/ui/fileHighlighter';
-import { ContextManager } from '../../src/context/contextManager';
-import vscode from '../mocks/vscode-mock';
-import { HoverManager } from '../../src/ui/hoverManager';
-import { MarkdownString } from 'vscode';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { ConfigManager } from '../../src/context/configManager';
+import * as smellsData from '../../src/utils/smellsData';
+// Mock dependencies
jest.mock('vscode');
+jest.mock('../../src/context/SmellsCacheManager');
+jest.mock('../../src/context/configManager');
+jest.mock('../../src/utils/smellsData');
-describe('File Highlighter', () => {
- let contextManagerMock: ContextManager;
+describe('FileHighlighter', () => {
+ let smellsCacheManager: { getCachedSmells: jest.Mock; onSmellsUpdated: jest.Mock };
let fileHighlighter: FileHighlighter;
beforeEach(() => {
- // Reset all mocks before each test
jest.clearAllMocks();
- // Mock ContextManager
- contextManagerMock = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as unknown as ContextManager;
+ // Setup mock instances
+ smellsCacheManager = {
+ getCachedSmells: jest.fn(),
+ onSmellsUpdated: jest.fn(),
+ };
+ FileHighlighter['instance'] = undefined;
+ fileHighlighter = FileHighlighter.getInstance(
+ smellsCacheManager as unknown as SmellsCacheManager,
+ );
+
+ // Mock ConfigManager
+ (ConfigManager.get as jest.Mock).mockImplementation((key: string) => {
+ switch (key) {
+ case 'smellsColours':
+ return { smell1: 'rgba(255,0,0,0.5)', smell2: 'rgba(0,0,255,0.5)' };
+ case 'useSingleColour':
+ return false;
+ case 'singleHighlightColour':
+ return 'rgba(255,204,0,0.5)';
+ case 'highlightStyle':
+ return 'underline';
+ default:
+ return undefined;
+ }
+ });
- fileHighlighter = FileHighlighter.getInstance(contextManagerMock);
+ // Mock createTextEditorDecorationType
+ (vscode.window.createTextEditorDecorationType as jest.Mock).mockImplementation(
+ () => ({
+ dispose: jest.fn(),
+ }),
+ );
});
- it('should not reset highlight decorations on first init', () => {
- const smells = [
- {
- symbol: 'smell1',
- occurences: [{ line: 1 }],
- },
- ] as unknown as Smell[];
- const currentConfig = {
- smell1: {
- enabled: true,
- colour: 'rgba(1, 50, 0, 0.5)',
- },
- };
+ afterEach(() => {
+ jest.restoreAllMocks(); // Cleans up all spy mocks
+ (vscode.window.createTextEditorDecorationType as jest.Mock).mockClear();
+ });
+
+ describe('getInstance', () => {
+ it('should return singleton instance', () => {
+ const instance1 = FileHighlighter.getInstance(
+ smellsCacheManager as unknown as SmellsCacheManager,
+ );
+ const instance2 = FileHighlighter.getInstance(
+ smellsCacheManager as unknown as SmellsCacheManager,
+ );
+ expect(instance1).toBe(instance2);
+ });
+ });
+
+ describe('updateHighlightsForVisibleEditors', () => {
+ it('should call highlightSmells for each visible Python editor', () => {
+ // Mock highlightSmells to track calls
+ const highlightSpy = jest.spyOn(fileHighlighter, 'highlightSmells');
+
+ // Create a non-Python editor
+ const nonPythonEditor = {
+ document: {
+ fileName: '/path/to/file.js',
+ uri: { fsPath: '/path/to/file.js' },
+ },
+ } as unknown as vscode.TextEditor;
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
+ vscode.window.visibleTextEditors = [
+ nonPythonEditor,
+ vscode.window.activeTextEditor!,
+ ];
- jest.spyOn(HoverManager, 'getInstance').mockReturnValueOnce({
- hoverContent: 'hover content' as unknown as MarkdownString,
- } as unknown as HoverManager);
+ fileHighlighter.updateHighlightsForVisibleEditors();
- fileHighlighter.highlightSmells(vscode.window.activeTextEditor, smells);
+ // Verify highlightSmells was called exactly once (for the Python editor)
+ expect(highlightSpy).toHaveBeenCalledTimes(1);
- // Assert decorations were set
- expect(fileHighlighter['decorations'][0].dispose).not.toHaveBeenCalled();
+ // Clean up spy
+ highlightSpy.mockRestore();
+ });
});
- it('should create decorations', () => {
- const color = 'red';
- const decoration = fileHighlighter['getDecoration'](color, 'underline');
+ describe('updateHighlightsForFile', () => {
+ it('should call highlightSmells when matching Python file is visible', () => {
+ const highlightSpy = jest.spyOn(fileHighlighter, 'highlightSmells');
+
+ vscode.window.visibleTextEditors = [vscode.window.activeTextEditor!];
+
+ fileHighlighter['updateHighlightsForFile']('fake.py');
- // Assert decoration was created
- expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalled();
- expect(decoration).toBeDefined();
+ expect(highlightSpy).toHaveBeenCalledTimes(1);
+ highlightSpy.mockRestore();
+ });
+
+ it('should not call highlightSmells for non-matching files', () => {
+ const highlightSpy = jest.spyOn(fileHighlighter, 'highlightSmells');
+
+ fileHighlighter['updateHighlightsForFile']('/path/to/other.py');
+
+ expect(highlightSpy).not.toHaveBeenCalled();
+ highlightSpy.mockRestore();
+ });
+
+ it('should not call highlightSmells for non-Python files', () => {
+ const highlightSpy = jest.spyOn(fileHighlighter, 'highlightSmells');
+
+ fileHighlighter['updateHighlightsForFile']('/path/to/file.js');
+
+ expect(highlightSpy).not.toHaveBeenCalled();
+ highlightSpy.mockRestore();
+ });
});
- it('should highlight smells', () => {
- const smells = [
- {
- symbol: 'smell1',
- occurences: [{ line: 1 }],
- },
- ] as unknown as Smell[];
- const currentConfig = {
- smell1: {
- enabled: true,
- colour: 'rgba(88, 101, 200, 0.5)',
- },
- };
+ describe('highlightSmells', () => {
+ const mockEditor = vscode.window.activeTextEditor;
+ it('should highlight smells when cache has data', () => {
+ const mockSmells = [
+ {
+ symbol: 'smell1',
+ occurences: [{ line: 1 }, { line: 2 }],
+ },
+ {
+ symbol: 'smell2',
+ occurences: [{ line: 3 }],
+ },
+ ] as unknown as Smell[];
+
+ jest.spyOn(smellsData, 'getEnabledSmells').mockReturnValueOnce({
+ smell1: {} as any,
+ smell2: {} as any,
+ });
+
+ (smellsCacheManager.getCachedSmells as jest.Mock).mockReturnValueOnce(
+ mockSmells,
+ );
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
+ console.log(
+ 'Mock getCachedSmells implementation:',
+ smellsCacheManager.getCachedSmells.mock.results,
+ );
- jest.spyOn(HoverManager, 'getInstance').mockReturnValueOnce({
- hoverContent: 'hover content' as unknown as MarkdownString,
- } as unknown as HoverManager);
+ const editor = vscode.window.activeTextEditor;
- fileHighlighter.highlightSmells(vscode.window.activeTextEditor, smells);
+ fileHighlighter.highlightSmells(editor!);
- expect(vscode.window.activeTextEditor.setDecorations).toHaveBeenCalled();
- expect(
- vscode.window.activeTextEditor.setDecorations.mock.calls[0][1],
- ).toHaveLength(1);
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledTimes(2);
+ expect(editor!.setDecorations).toHaveBeenCalledTimes(2);
+ });
+
+ it('should not highlight when cache has no data', () => {
+ smellsCacheManager.getCachedSmells.mockReturnValueOnce(undefined);
+ fileHighlighter.highlightSmells(mockEditor!);
+ expect(mockEditor!.setDecorations).not.toHaveBeenCalled();
+ });
+
+ it('should only highlight enabled smells', () => {
+ jest.spyOn(smellsData, 'getEnabledSmells').mockReturnValueOnce({
+ smell1: {} as any,
+ });
+
+ const mockSmells = [
+ {
+ symbol: 'smell1',
+ occurences: [{ line: 1 }],
+ },
+ {
+ symbol: 'smell2',
+ occurences: [{ line: 2 }],
+ },
+ ];
+
+ smellsCacheManager.getCachedSmells.mockReturnValueOnce(mockSmells);
+
+ fileHighlighter.highlightSmells(mockEditor!);
+
+ expect(
+ (mockEditor?.setDecorations as jest.Mock).mock.calls[0][1],
+ ).toHaveLength(1);
+ });
+
+ it('should skip invalid line numbers', () => {
+ const mockSmells = [
+ {
+ symbol: 'smell1',
+ occurences: [{ line: 100 }], // Invalid line number
+ },
+ ];
+
+ jest.spyOn(smellsData, 'getEnabledSmells').mockReturnValueOnce({
+ smell1: {} as any,
+ smell2: {} as any,
+ });
+
+ smellsCacheManager.getCachedSmells.mockReturnValueOnce(mockSmells);
+
+ fileHighlighter.highlightSmells(mockEditor!);
+
+ expect(mockEditor?.setDecorations).toHaveBeenCalledWith(expect.anything(), []);
+ });
});
- it('should reset highlight decorations on subsequent calls', () => {
- const smells = [
- {
- symbol: 'smell1',
- occurences: [{ line: 1 }],
- },
- ] as unknown as Smell[];
- const currentConfig = {
- smell1: {
- enabled: true,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- };
+ describe('resetHighlights', () => {
+ it('should dispose all decorations', () => {
+ const mockEditor = vscode.window.activeTextEditor;
+ const mockDecoration = { dispose: jest.fn() };
+ (
+ vscode.window.createTextEditorDecorationType as jest.Mock
+ ).mockReturnValueOnce(mockDecoration);
+
+ jest.spyOn(smellsData, 'getEnabledSmells').mockReturnValueOnce({
+ smell1: {} as any,
+ smell2: {} as any,
+ });
+
+ const mockSmells = [{ symbol: 'smell1', occurences: [{ line: 1 }] }];
+ smellsCacheManager.getCachedSmells.mockReturnValueOnce(mockSmells);
+
+ fileHighlighter.highlightSmells(mockEditor!);
+ fileHighlighter.resetHighlights();
+
+ expect(mockDecoration.dispose).toHaveBeenCalled();
+ expect(fileHighlighter['decorations']).toHaveLength(0);
+ });
+ });
+
+ describe('getDecoration', () => {
+ it('should create underline decoration', () => {
+ (ConfigManager.get as jest.Mock).mockImplementation((key: string) =>
+ key === 'highlightStyle' ? 'underline' : undefined,
+ );
+
+ fileHighlighter['getDecoration']('rgba(255,0,0,0.5)', 'underline');
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({
+ textDecoration: 'wavy rgba(255,0,0,0.5) underline 1px',
+ });
+ });
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
+ it('should create flashlight decoration', () => {
+ (ConfigManager.get as jest.Mock).mockImplementation((key: string) =>
+ key === 'highlightStyle' ? 'flashlight' : undefined,
+ );
- jest.spyOn(HoverManager, 'getInstance').mockReturnValue({
- hoverContent: 'hover content' as unknown as MarkdownString,
- } as unknown as HoverManager);
+ fileHighlighter['getDecoration']('rgba(255,0,0,0.5)', 'flashlight');
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({
+ isWholeLine: true,
+ backgroundColor: 'rgba(255,0,0,0.5)',
+ });
+ });
- fileHighlighter.highlightSmells(vscode.window.activeTextEditor, smells);
+ it('should create border-arrow decoration', () => {
+ (ConfigManager.get as jest.Mock).mockImplementation((key: string) =>
+ key === 'highlightStyle' ? 'border-arrow' : undefined,
+ );
- fileHighlighter.highlightSmells(vscode.window.activeTextEditor, smells);
+ fileHighlighter['getDecoration']('rgba(255,0,0,0.5)', 'border-arrow');
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({
+ borderWidth: '1px 2px 1px 0',
+ borderStyle: 'solid',
+ borderColor: 'rgba(255,0,0,0.5)',
+ after: {
+ contentText: '▶',
+ margin: '0 0 0 5px',
+ color: 'rgba(255,0,0,0.5)',
+ fontWeight: 'bold',
+ },
+ overviewRulerColor: 'rgba(255,0,0,0.5)',
+ overviewRulerLane: vscode.OverviewRulerLane.Right,
+ });
+ });
- // Assert decorations were set
- expect(fileHighlighter['decorations'][0].dispose).toHaveBeenCalled();
+ it('should default to underline for unknown styles', () => {
+ fileHighlighter['getDecoration']('rgba(255,0,0,0.5)', 'unknown');
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({
+ textDecoration: 'wavy rgba(255,0,0,0.5) underline 1px',
+ });
+ });
});
});
diff --git a/test/ui/hoverManager.test.ts b/test/ui/hoverManager.test.ts
index 15af64e..57d22a9 100644
--- a/test/ui/hoverManager.test.ts
+++ b/test/ui/hoverManager.test.ts
@@ -1,202 +1,190 @@
-// test/hover-manager.test.ts
-// import vscode from '../mocks/vscode-mock';
+import * as vscode from 'vscode';
import { HoverManager } from '../../src/ui/hoverManager';
-import { ContextManager } from '../../src/context/contextManager';
-import { Smell, Occurrence } from '../../src/types';
-import vscode from 'vscode';
-
-jest.mock('vscode');
-
-jest.mock('../../src/commands/refactorSmell', () => ({
- refactorSelectedSmell: jest.fn(),
- refactorAllSmellsOfType: jest.fn(),
-}));
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+
+// Create a simple mock Uri implementation
+const mockUri = (path: string): vscode.Uri => ({
+ scheme: 'file',
+ authority: '',
+ path,
+ fsPath: path,
+ query: '',
+ fragment: '',
+ with: jest.fn(),
+ toString: jest.fn(() => path),
+ toJSON: jest.fn(() => ({ path })),
+});
-// Mock the vscode module using our custom mock
-// jest.mock('vscode', () => vscode);
+// Mock the vscode module with all required components
+jest.mock('vscode', () => {
+ const actualVscode = jest.requireActual('vscode');
-describe('HoverManager', () => {
- let contextManagerMock: ContextManager;
- let mockSmells: Smell[];
-
- const mockOccurrence: Occurrence = {
- line: 5,
- endLine: 7,
- column: 1,
- endColumn: 10,
+ // Mock MarkdownString implementation
+ const mockMarkdownString = {
+ isTrusted: true,
+ supportHtml: true,
+ supportThemeIcons: true,
+ appendMarkdown: jest.fn(),
};
- beforeEach(() => {
- jest.clearAllMocks();
-
- contextManagerMock = {
- context: {
- subscriptions: [],
- },
- getContext: () => ({ subscriptions: [] }),
- } as unknown as ContextManager;
+ return {
+ ...actualVscode,
+ languages: {
+ registerHoverProvider: jest.fn(),
+ },
+ MarkdownString: jest.fn(() => mockMarkdownString),
+ Hover: jest.fn(),
+ Position: jest.fn(),
+ Uri: {
+ file: jest.fn((path) => mockUri(path)),
+ parse: jest.fn((path) => mockUri(path)),
+ },
+ };
+});
- mockSmells = [
+describe('HoverManager', () => {
+ let hoverManager: HoverManager;
+ let mockSmellsCacheManager: jest.Mocked;
+ let mockContext: vscode.ExtensionContext;
+ let mockDocument: vscode.TextDocument;
+ let mockPosition: vscode.Position;
+
+ const createMockSmell = (messageId: string, line: number) => ({
+ type: 'performance',
+ symbol: 'test-smell',
+ message: 'Test smell message',
+ messageId,
+ confidence: 'HIGH',
+ path: '/test/file.py',
+ module: 'test',
+ occurences: [
{
- type: 'performance',
- symbol: 'CRS-001',
- message: 'Cached repeated calls',
- messageId: 'cached-repeated-calls',
- confidence: 'HIGH',
- path: '/test/file.py',
- module: 'test_module',
- occurences: [mockOccurrence],
- additionalInfo: {},
+ line,
+ column: 1,
+ endLine: line,
+ endColumn: 10,
},
- ];
- });
-
- it('should register hover provider for Python files', () => {
- new HoverManager(contextManagerMock, mockSmells);
-
- expect(vscode.languages.registerHoverProvider).toHaveBeenCalledWith(
- { scheme: 'file', language: 'python' },
- expect.objectContaining({
- provideHover: expect.any(Function),
- }),
- );
- });
-
- it('should subscribe hover provider correctly', () => {
- const spy = jest.spyOn(contextManagerMock.context.subscriptions, 'push');
- new HoverManager(contextManagerMock, mockSmells);
- expect(spy).toHaveBeenCalledWith(expect.anything());
+ ],
+ additionalInfo: {},
});
- it('should return null for hover content if there are no smells', () => {
- const manager = new HoverManager(contextManagerMock, []);
- const document = { fileName: '/test/file.py', getText: jest.fn() } as any;
- const position = { line: 4 } as any;
- expect(manager.getHoverContent(document, position)).toBeNull();
- });
-
- it('should update smells when getInstance is called again', () => {
- const initialSmells = [
- {
- type: 'performance',
- symbol: 'CRS-001',
- message: 'Cached repeated calls',
- messageId: 'cached-repeated-calls',
- confidence: 'HIGH',
- path: '/test/file.py',
- module: 'test_module',
- occurences: [mockOccurrence],
- additionalInfo: {},
- },
- ];
-
- const newSmells = [
- {
- type: 'memory',
- symbol: 'MEM-002',
- message: 'Memory leak detected',
- messageId: 'memory-leak',
- confidence: 'MEDIUM',
- path: '/test/file2.py',
- module: 'test_module_2',
- occurences: [mockOccurrence],
- additionalInfo: {},
- },
- ];
-
- const manager1 = HoverManager.getInstance(contextManagerMock, initialSmells);
- expect(manager1['smells']).toEqual(initialSmells);
-
- const manager2 = HoverManager.getInstance(contextManagerMock, newSmells);
- expect(manager2['smells']).toEqual(newSmells);
- expect(manager1).toBe(manager2); // Ensuring it's the same instance
- });
+ beforeEach(() => {
+ jest.clearAllMocks();
- it('should update smells correctly', () => {
- const manager = new HoverManager(contextManagerMock, mockSmells);
- const newSmells: Smell[] = [
- {
- type: 'security',
- symbol: 'SEC-003',
- message: 'Unsafe API usage',
- messageId: 'unsafe-api',
- confidence: 'HIGH',
- path: '/test/file3.py',
- module: 'security_module',
- occurences: [mockOccurrence],
- additionalInfo: {},
- },
- ];
+ mockSmellsCacheManager = {
+ getCachedSmells: jest.fn(),
+ } as unknown as jest.Mocked;
- manager.updateSmells(newSmells);
- expect(manager['smells']).toEqual(newSmells);
- });
+ mockContext = {
+ subscriptions: [],
+ } as unknown as vscode.ExtensionContext;
- it('should generate valid hover content', () => {
- const manager = new HoverManager(contextManagerMock, mockSmells);
- const document = {
+ mockDocument = {
+ uri: vscode.Uri.file('/test/file.py'),
fileName: '/test/file.py',
- getText: jest.fn(),
- } as any;
+ lineAt: jest.fn(),
+ } as unknown as vscode.TextDocument;
- const position = {
- line: 4, // 0-based line number (will become line 5 in 1-based)
+ mockPosition = {
+ line: 5,
character: 0,
- isBefore: jest.fn(),
- isBeforeOrEqual: jest.fn(),
- isAfter: jest.fn(),
- isAfterOrEqual: jest.fn(),
- translate: jest.fn(),
- with: jest.fn(),
- compareTo: jest.fn(),
- isEqual: jest.fn(),
- } as any; // Simplified type assertion since we don't need full Position type
-
- // Mock document text for line range
- document.getText.mockReturnValue('mock code content');
- const content = manager.getHoverContent(document, position);
-
- expect(content?.value).toBeDefined(); // Check value exists
- expect(content?.value).toContain('CRS-001');
- expect(content?.value).toContain('Cached repeated calls');
- expect(content?.isTrusted).toBe(true);
-
- // Verify basic structure for each smell
- expect(content?.value).toContain('**CRS-001:** Cached repeated calls');
- expect(content?.value).toContain(
- '[Refactor](command:extension.refactorThisSmell?',
- );
- expect(content?.value).toContain(
- '[Refactor all smells of this type...](command:extension.refactorAllSmellsOfType?',
- );
- // Verify command parameters are properly encoded
- const expectedSmellParam = encodeURIComponent(JSON.stringify(mockSmells[0]));
- expect(content?.value).toContain(
- `command:extension.refactorThisSmell?${expectedSmellParam}`,
- );
- expect(content?.value).toContain(
- `command:extension.refactorAllSmellsOfType?${expectedSmellParam}`,
- );
-
- // Verify formatting between elements
- expect(content?.value).toContain('\t\t'); // Verify tab separation
- expect(content?.value).toContain('\n\n'); // Verify line breaks between smells
-
- // // Verify empty case
- // expect(manager.getHoverContent(document, invalidPosition)).toBeNull();
+ } as unknown as vscode.Position;
+
+ hoverManager = new HoverManager(mockSmellsCacheManager);
});
- it('should register refactor commands', () => {
- new HoverManager(contextManagerMock, mockSmells);
+ describe('register', () => {
+ it('should register hover provider for Python files', () => {
+ hoverManager.register(mockContext);
- expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
- 'extension.refactorThisSmell',
- expect.any(Function),
- );
+ expect(vscode.languages.registerHoverProvider).toHaveBeenCalledWith(
+ { language: 'python', scheme: 'file' },
+ hoverManager,
+ );
+ expect(mockContext.subscriptions).toHaveLength(1);
+ });
+ });
- expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
- 'extension.refactorAllSmellsOfType',
- expect.any(Function),
- );
+ describe('provideHover', () => {
+ it('should return undefined for non-Python files', () => {
+ const jsDocument = {
+ uri: vscode.Uri.file('/test/file.js'),
+ fileName: '/test/file.js',
+ } as vscode.TextDocument;
+
+ const result = hoverManager.provideHover(
+ jsDocument,
+ mockPosition,
+ {} as vscode.CancellationToken,
+ );
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when no smells are cached', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue(undefined);
+ const result = hoverManager.provideHover(
+ mockDocument,
+ mockPosition,
+ {} as vscode.CancellationToken,
+ );
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when no smells at line', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([
+ createMockSmell('test-smell', 10), // Different line
+ ]);
+ const result = hoverManager.provideHover(
+ mockDocument,
+ mockPosition,
+ {} as vscode.CancellationToken,
+ );
+ expect(result).toBeUndefined();
+ });
+
+ it('should create hover for single smell at line', () => {
+ const mockSmell = createMockSmell('test-smell', 6); // line + 1
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([mockSmell]);
+
+ const result = hoverManager.provideHover(
+ mockDocument,
+ mockPosition,
+ {} as vscode.CancellationToken,
+ );
+
+ expect(vscode.MarkdownString).toHaveBeenCalled();
+ expect(vscode.Hover).toHaveBeenCalled();
+
+ // Get the mock MarkdownString instance
+ const markdownInstance = (vscode.MarkdownString as jest.Mock).mock.results[0]
+ .value;
+ expect(markdownInstance.appendMarkdown).toHaveBeenCalledWith(
+ expect.stringContaining('Test smell message'),
+ );
+ expect(markdownInstance.appendMarkdown).toHaveBeenCalledWith(
+ expect.stringContaining('command:ecooptimizer.refactorSmell'),
+ );
+ });
+
+ it('should escape special characters in messages', () => {
+ const mockSmell = {
+ ...createMockSmell('test-smell', 6),
+ message: 'Message with *stars* and _underscores_',
+ messageId: 'id_with*stars*',
+ };
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([mockSmell]);
+
+ hoverManager.provideHover(
+ mockDocument,
+ mockPosition,
+ {} as vscode.CancellationToken,
+ );
+
+ const markdownInstance = (vscode.MarkdownString as jest.Mock).mock.results[0]
+ .value;
+ expect(markdownInstance.appendMarkdown).toHaveBeenCalledWith(
+ expect.stringContaining('Message with \\*stars\\* and \\_underscores\\_'),
+ );
+ });
});
});
diff --git a/test/ui/lineSelection.test.ts b/test/ui/lineSelection.test.ts
index cd851d2..37ba48f 100644
--- a/test/ui/lineSelection.test.ts
+++ b/test/ui/lineSelection.test.ts
@@ -1,149 +1,221 @@
-// test/line-selection-manager.test.ts
+import * as vscode from 'vscode';
import { LineSelectionManager } from '../../src/ui/lineSelectionManager';
-import { ContextManager } from '../../src/context/contextManager';
-import vscode from 'vscode';
-
-jest.mock('vscode');
-
-jest.mock('../../src/utils/hashDocs', () => ({
- hashContent: jest.fn(() => 'mockHash'),
-}));
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+
+jest.mock('vscode', () => {
+ const actualVscode = jest.requireActual('vscode');
+ return {
+ ...actualVscode,
+ window: {
+ ...actualVscode.window,
+ createTextEditorDecorationType: jest.fn(),
+ activeTextEditor: undefined,
+ },
+ ThemeColor: jest.fn((colorName: string) => ({ id: colorName })),
+ };
+});
describe('LineSelectionManager', () => {
- let contextManagerMock: ContextManager;
+ let manager: LineSelectionManager;
+ let mockSmellsCacheManager: jest.Mocked;
let mockEditor: vscode.TextEditor;
- let lineSelectionManager: LineSelectionManager;
+ let mockDocument: vscode.TextDocument;
+ let mockDecorationType: vscode.TextEditorDecorationType;
+
+ // Helper function to create a mock smell
+ const createMockSmell = (symbol: string, line: number) => ({
+ type: 'performance',
+ symbol,
+ message: 'Test smell',
+ messageId: 'test-smell',
+ confidence: 'HIGH',
+ path: '/test/file.js',
+ module: 'test',
+ occurences: [
+ {
+ line,
+ column: 1,
+ endLine: line,
+ endColumn: 10,
+ },
+ ],
+ additionalInfo: {},
+ });
beforeEach(() => {
jest.clearAllMocks();
- contextManagerMock = {
- getWorkspaceData: jest.fn(() => ({
- '/test/file.py': {
- hash: 'mockHash',
- smells: [
- { symbol: 'PERF-001', occurences: [{ line: 5 }] },
- { symbol: 'SEC-002', occurences: [{ line: 5 }] },
- ],
- },
- })),
- } as unknown as ContextManager;
+ mockSmellsCacheManager = {
+ onSmellsUpdated: jest.fn(),
+ getCachedSmells: jest.fn(),
+ } as unknown as jest.Mocked;
- mockEditor = {
- document: {
- fileName: '/test/file.py',
- getText: jest.fn(() => 'mock content'),
- lineAt: jest.fn(() => ({ text: 'mock line content' })),
+ mockDocument = {
+ fileName: '/test/file.js',
+ lineAt: jest.fn().mockReturnValue({
+ text: 'const test = true;',
+ trimEnd: jest.fn().mockReturnValue('const test = true;'),
+ }),
+ uri: {
+ fsPath: '/test/file.js',
},
+ } as unknown as vscode.TextDocument;
+
+ mockEditor = {
+ document: mockDocument,
selection: {
- start: { line: 4 }, // 0-based index, maps to line 5
isSingleLine: true,
- } as any,
+ start: { line: 5 },
+ },
setDecorations: jest.fn(),
} as unknown as vscode.TextEditor;
- lineSelectionManager = new LineSelectionManager(contextManagerMock);
- });
+ mockDecorationType = {
+ dispose: jest.fn(),
+ } as unknown as vscode.TextEditorDecorationType;
- it('should remove last comment if decoration exists', () => {
- const disposeMock = jest.fn();
- (lineSelectionManager as any).decoration = { dispose: disposeMock };
+ (vscode.window.createTextEditorDecorationType as jest.Mock).mockReturnValue(
+ mockDecorationType,
+ );
- lineSelectionManager.removeLastComment();
- expect(disposeMock).toHaveBeenCalled();
- });
+ (vscode.window.activeTextEditor as unknown) = mockEditor;
- it('should not proceed if no editor is provided', () => {
- expect(() => lineSelectionManager.commentLine(null as any)).not.toThrow();
+ manager = new LineSelectionManager(mockSmellsCacheManager);
});
- it('should not add comment if no smells detected for file', () => {
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue({});
- lineSelectionManager.commentLine(mockEditor);
- expect(mockEditor.setDecorations).not.toHaveBeenCalled();
- });
+ describe('constructor', () => {
+ it('should initialize with empty decoration and null lastDecoratedLine', () => {
+ expect((manager as any).decoration).toBeNull();
+ expect((manager as any).lastDecoratedLine).toBeNull();
+ });
- it('should not add comment if document hash does not match', () => {
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue({
- '/test/file.py': { hash: 'differentHash', smells: [] },
+ it('should register smellsUpdated callback', () => {
+ expect(mockSmellsCacheManager.onSmellsUpdated).toHaveBeenCalled();
});
- lineSelectionManager.commentLine(mockEditor);
- expect(mockEditor.setDecorations).not.toHaveBeenCalled();
});
- it('should not add comment for multi-line selections', () => {
- // Set up multi-line selection
- (mockEditor.selection as any).isSingleLine = false;
+ describe('removeLastComment', () => {
+ it('should dispose decoration if it exists', () => {
+ (manager as any).decoration = mockDecorationType;
+ (manager as any).lastDecoratedLine = 5;
- lineSelectionManager.commentLine(mockEditor);
+ manager.removeLastComment();
- expect(mockEditor.setDecorations).not.toHaveBeenCalled();
+ expect(mockDecorationType.dispose).toHaveBeenCalled();
+ expect((manager as any).decoration).toBeNull();
+ expect((manager as any).lastDecoratedLine).toBeNull();
+ });
+
+ it('should do nothing if no decoration exists', () => {
+ manager.removeLastComment();
+ expect(mockDecorationType.dispose).not.toHaveBeenCalled();
+ });
});
- it('should not add comment when no smells exist at line', () => {
- // Mock smells array with no matching line
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue({
- '/test/file.py': {
- hash: 'mockHash',
- smells: [
- { symbol: 'PERF-001', occurences: [{ line: 6 }] }, // Different line
- { symbol: 'SEC-002', occurences: [{ line: 7 }] },
- ],
- },
+ describe('commentLine', () => {
+ it('should do nothing if no editor is provided', () => {
+ manager.commentLine(null as any);
+ expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled();
});
- lineSelectionManager.commentLine(mockEditor);
+ it('should do nothing if selection is multi-line', () => {
+ (mockEditor.selection as any).isSingleLine = false;
+ manager.commentLine(mockEditor);
+ expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled();
+ });
- expect(mockEditor.setDecorations).not.toHaveBeenCalled();
- });
+ it('should remove last comment if no smells are cached', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue(undefined);
+ const removeSpy = jest.spyOn(manager, 'removeLastComment');
- it('should display single smell comment without count', () => {
- // Mock single smell at line
- (contextManagerMock.getWorkspaceData as jest.Mock).mockReturnValue({
- '/test/file.py': {
- hash: 'mockHash',
- smells: [{ symbol: 'PERF-001', occurences: [{ line: 5 }] }],
- },
+ manager.commentLine(mockEditor);
+
+ expect(removeSpy).toHaveBeenCalled();
+ expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled();
});
- lineSelectionManager.commentLine(mockEditor);
+ it('should do nothing if no smells exist at selected line', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([
+ createMockSmell('LongMethod', 10), // Different line
+ ]);
- expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith(
- expect.objectContaining({
- after: expect.objectContaining({
- contentText: '🍂 Smell: PERF-001',
- }),
- }),
- );
- });
+ manager.commentLine(mockEditor);
+
+ expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled();
+ });
- it('should add a single-line comment if a smell is found', () => {
- lineSelectionManager.commentLine(mockEditor);
+ it('should create decoration for single smell at line', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([
+ createMockSmell('LongMethod', 6), // line + 1
+ ]);
- expect(mockEditor.setDecorations).toHaveBeenCalledWith(
- expect.any(Object),
- expect.any(Array),
- );
+ manager.commentLine(mockEditor);
+
+ expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalled();
+ expect(mockEditor.setDecorations).toHaveBeenCalledWith(
+ mockDecorationType,
+ expect.any(Array),
+ );
+ expect((manager as any).lastDecoratedLine).toBe(5);
+
+ const decorationConfig = (
+ vscode.window.createTextEditorDecorationType as jest.Mock
+ ).mock.calls[0][0];
+ expect(decorationConfig.after.contentText).toBe('🍂 Smell: LongMethod');
+ });
+
+ it('should create decoration with count for multiple smells at line', () => {
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([
+ createMockSmell('LongMethod', 6),
+ createMockSmell('ComplexCondition', 6),
+ ]);
+
+ manager.commentLine(mockEditor);
+
+ const decorationConfig = (
+ vscode.window.createTextEditorDecorationType as jest.Mock
+ ).mock.calls[0][0];
+ expect(decorationConfig.after.contentText).toContain(
+ '🍂 Smell: LongMethod | (+1)',
+ );
+ });
+
+ it('should not create decoration if same line is already decorated', () => {
+ (manager as any).lastDecoratedLine = 5;
+ mockSmellsCacheManager.getCachedSmells.mockReturnValue([
+ createMockSmell('LongMethod', 6),
+ ]);
+
+ manager.commentLine(mockEditor);
+
+ expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled();
+ });
});
- it('should display a combined comment if multiple smells exist', () => {
- lineSelectionManager.commentLine(mockEditor);
-
- // Verify the decoration type was created with correct options
- expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({
- isWholeLine: true,
- after: {
- contentText: expect.stringContaining('🍂 Smell: PERF-001 | (+1)'),
- color: 'rgb(153, 211, 212)',
- margin: '0 0 0 10px',
- textDecoration: 'none',
- },
+ describe('smellsUpdated callback', () => {
+ let smellsUpdatedCallback: (targetFilePath: string) => void;
+
+ beforeEach(() => {
+ smellsUpdatedCallback = (mockSmellsCacheManager.onSmellsUpdated as jest.Mock)
+ .mock.calls[0][0];
});
- // Verify decorations were applied to correct range
- expect(mockEditor.setDecorations).toHaveBeenCalledWith(
- expect.any(Object), // The decoration type instance
- [new vscode.Range(4, 0, 4, 0)], // Expected range
- );
+ it('should remove comment when cache is cleared for all files', () => {
+ const removeSpy = jest.spyOn(manager, 'removeLastComment');
+ smellsUpdatedCallback('all');
+ expect(removeSpy).toHaveBeenCalled();
+ });
+
+ it('should remove comment when cache is cleared for current file', () => {
+ const removeSpy = jest.spyOn(manager, 'removeLastComment');
+ smellsUpdatedCallback('/test/file.js');
+ expect(removeSpy).toHaveBeenCalled();
+ });
+
+ it('should not remove comment when cache is cleared for different file', () => {
+ const removeSpy = jest.spyOn(manager, 'removeLastComment');
+ smellsUpdatedCallback('/other/file.js');
+ expect(removeSpy).not.toHaveBeenCalled();
+ });
});
});
diff --git a/test/utils/handleSmellSettings.test.ts b/test/utils/handleSmellSettings.test.ts
deleted file mode 100644
index 0598534..0000000
--- a/test/utils/handleSmellSettings.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import {
- handleSmellFilterUpdate,
- getEnabledSmells,
- formatSmellName,
-} from '../../src/utils/handleSmellSettings';
-import { wipeWorkCache } from '../../src/commands/wipeWorkCache';
-import { ContextManager } from '../../src/context/contextManager';
-import vscode from '../mocks/vscode-mock';
-
-jest.mock('../../src/commands/wipeWorkCache', () => ({
- wipeWorkCache: jest.fn(),
-}));
-
-describe('Settings Page - handleSmellSettings.ts', () => {
- let contextManagerMock: ContextManager;
-
- beforeEach(() => {
- jest.clearAllMocks();
- contextManagerMock = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as unknown as ContextManager;
- });
-
- describe('getEnabledSmells', () => {
- it('should return the current enabled smells from settings', () => {
- const currentConfig = {
- 'cached-repeated-calls': {
- enabled: true,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- 'long-element-chain': {
- enabled: false,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- };
-
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
-
- const enabledSmells = getEnabledSmells();
-
- expect(enabledSmells).toEqual({
- 'cached-repeated-calls': true,
- 'long-element-chain': false,
- });
- });
-
- it('should return an empty object if no smells are set', () => {
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue({}),
- } as any);
-
- const enabledSmells = getEnabledSmells();
- expect(enabledSmells).toEqual({});
- });
- });
-
- describe('handleSmellFilterUpdate', () => {
- it('should detect when a smell is enabled and notify the user', () => {
- const previousSmells = { 'cached-repeated-calls': false };
- const currentConfig = {
- 'cached-repeated-calls': {
- enabled: true,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- };
-
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
-
- handleSmellFilterUpdate(previousSmells, contextManagerMock);
-
- expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Enabled detection of Cached Repeated Calls.',
- );
- expect(wipeWorkCache).toHaveBeenCalledWith(contextManagerMock, 'settings');
- });
-
- it('should detect when a smell is disabled and notify the user', () => {
- const previousSmells = { 'long-element-chain': true };
- const currentConfig = {
- 'long-element-chain': {
- enabled: false,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- };
-
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
-
- handleSmellFilterUpdate(previousSmells, contextManagerMock);
-
- expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
- 'Eco: Disabled detection of Long Element Chain.',
- );
- expect(wipeWorkCache).toHaveBeenCalledWith(contextManagerMock, 'settings');
- });
-
- it('should not wipe cache if no smells changed', () => {
- const previousSmells = { 'cached-repeated-calls': true };
- const currentConfig = {
- 'cached-repeated-calls': {
- enabled: true,
- colour: 'rgba(255, 204, 0, 0.5)',
- },
- };
-
- jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValueOnce({
- get: jest.fn().mockReturnValue(currentConfig),
- } as any);
-
- handleSmellFilterUpdate(previousSmells, contextManagerMock);
-
- expect(wipeWorkCache).not.toHaveBeenCalled();
- expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
- });
- });
-
- describe('formatSmellName', () => {
- it('should format kebab-case smell names to a readable format', () => {
- expect(formatSmellName('cached-repeated-calls')).toBe('Cached Repeated Calls');
- expect(formatSmellName('long-element-chain')).toBe('Long Element Chain');
- expect(formatSmellName('string-concat-loop')).toBe('String Concat Loop');
- });
-
- it('should return an empty string if given an empty input', () => {
- expect(formatSmellName('')).toBe('');
- });
- });
-});
diff --git a/test/utils/hashDocs.test.ts b/test/utils/hashDocs.test.ts
deleted file mode 100644
index 8f6aa27..0000000
--- a/test/utils/hashDocs.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { ContextManager } from '../../src/context/contextManager';
-
-import { TextDocument } from '../mocks/vscode-mock';
-import { updateHash } from '../../src/utils/hashDocs';
-
-import crypto from 'crypto';
-
-jest.mock('crypto');
-
-describe('Hashing Tools', () => {
- let contextManagerMock: ContextManager;
-
- beforeEach(() => {
- // Reset all mocks before each test
- jest.clearAllMocks();
-
- // Mock ContextManager
- contextManagerMock = {
- getWorkspaceData: jest.fn(),
- setWorkspaceData: jest.fn(),
- } as unknown as ContextManager;
- });
-
- it('should do nothing if the document hash has not changed', async () => {
- jest.spyOn(contextManagerMock, 'getWorkspaceData').mockReturnValueOnce({
- 'fake.py': 'mocked-hash',
- });
-
- await updateHash(contextManagerMock, TextDocument as any);
-
- expect(crypto.createHash).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).not.toHaveBeenCalled();
- });
-
- it('should update the workspace storage if the doc hash changed', async () => {
- jest.spyOn(contextManagerMock, 'getWorkspaceData').mockReturnValueOnce({
- 'fake.py': 'someHash',
- });
-
- await updateHash(contextManagerMock, TextDocument as any);
-
- expect(crypto.createHash).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalled();
- });
-
- it('should update the workspace storage if no hash exists for the doc', async () => {
- jest.spyOn(contextManagerMock, 'getWorkspaceData').mockReturnValueOnce({
- 'otherFake.py': 'someHash',
- });
-
- await updateHash(contextManagerMock, TextDocument as any);
-
- expect(crypto.createHash).toHaveBeenCalled();
- expect(contextManagerMock.setWorkspaceData).toHaveBeenCalled();
- });
-});
diff --git a/test/utils/initializeStatusesFromCache.test.ts b/test/utils/initializeStatusesFromCache.test.ts
new file mode 100644
index 0000000..89b1ec6
--- /dev/null
+++ b/test/utils/initializeStatusesFromCache.test.ts
@@ -0,0 +1,167 @@
+// test/cacheInitialization.test.ts
+import * as vscode from 'vscode';
+import * as fs from 'fs/promises';
+import { initializeStatusesFromCache } from '../../src/utils/initializeStatusesFromCache';
+import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider';
+import { SmellsCacheManager } from '../../src/context/SmellsCacheManager';
+import { ecoOutput } from '../../src/extension';
+import { envConfig } from '../../src/utils/envConfig';
+
+// Mock the external dependencies
+jest.mock('fs/promises');
+jest.mock('../../src/extension');
+jest.mock('../../src/utils/envConfig');
+jest.mock('../../src/providers/SmellsViewProvider');
+jest.mock('../../src/context/SmellsCacheManager');
+
+describe('initializeStatusesFromCache', () => {
+ let context: vscode.ExtensionContext;
+ let smellsViewProvider: SmellsViewProvider;
+ let smellsCacheManager: SmellsCacheManager;
+ const mockWorkspacePath = '/workspace/path';
+
+ beforeEach(() => {
+ // Reset all mocks before each test
+ jest.clearAllMocks();
+
+ // Setup mock instances
+ context = {
+ workspaceState: {
+ get: jest.fn(),
+ },
+ } as unknown as vscode.ExtensionContext;
+
+ smellsViewProvider = new SmellsViewProvider(context);
+ smellsCacheManager = new SmellsCacheManager(context);
+
+ // Mock envConfig
+ (envConfig.WORKSPACE_CONFIGURED_PATH as any) = 'WORKSPACE_PATH_KEY';
+ });
+
+ it('should skip initialization when no workspace path is configured', async () => {
+ (context.workspaceState.get as jest.Mock).mockReturnValue(undefined);
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(ecoOutput.warn).toHaveBeenCalledWith(
+ expect.stringContaining('No configured workspace path found'),
+ );
+ expect(smellsCacheManager.getAllFilePaths).not.toHaveBeenCalled();
+ });
+
+ it('should remove files outside the workspace from cache', async () => {
+ const outsidePath = '/other/path/file.py';
+ (context.workspaceState.get as jest.Mock).mockReturnValue(mockWorkspacePath);
+ (smellsCacheManager.getAllFilePaths as jest.Mock).mockReturnValue([outsidePath]);
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(smellsCacheManager.clearCachedSmellsForFile).toHaveBeenCalledWith(
+ outsidePath,
+ );
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ expect.stringContaining('File outside workspace'),
+ );
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining('1 files removed from cache'),
+ );
+ });
+
+ it('should remove non-existent files from cache', async () => {
+ const filePath = `${mockWorkspacePath}/file.py`;
+ (context.workspaceState.get as jest.Mock).mockReturnValue(mockWorkspacePath);
+ (smellsCacheManager.getAllFilePaths as jest.Mock).mockReturnValue([filePath]);
+ (fs.access as jest.Mock).mockRejectedValue(new Error('File not found'));
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(smellsCacheManager.clearCachedSmellsForFile).toHaveBeenCalledWith(
+ filePath,
+ );
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ expect.stringContaining('File not found - removing from cache'),
+ );
+ });
+
+ it('should set status for files with smells', async () => {
+ const filePath = `${mockWorkspacePath}/file.py`;
+ const mockSmells = [{ id: 'smell1' }];
+ (context.workspaceState.get as jest.Mock).mockReturnValue(mockWorkspacePath);
+ (smellsCacheManager.getAllFilePaths as jest.Mock).mockReturnValue([filePath]);
+ (fs.access as jest.Mock).mockResolvedValue(undefined);
+ (smellsCacheManager.getCachedSmells as jest.Mock).mockReturnValue(mockSmells);
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(filePath, 'passed');
+ expect(smellsViewProvider.setSmells).toHaveBeenCalledWith(filePath, mockSmells);
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ expect.stringContaining('Found 1 smells for file'),
+ );
+ });
+
+ it('should set status for clean files', async () => {
+ const filePath = `${mockWorkspacePath}/clean.py`;
+ (context.workspaceState.get as jest.Mock).mockReturnValue(mockWorkspacePath);
+ (smellsCacheManager.getAllFilePaths as jest.Mock).mockReturnValue([filePath]);
+ (fs.access as jest.Mock).mockResolvedValue(undefined);
+ (smellsCacheManager.getCachedSmells as jest.Mock).mockReturnValue([]);
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(smellsViewProvider.setStatus).toHaveBeenCalledWith(filePath, 'no_issues');
+ expect(ecoOutput.trace).toHaveBeenCalledWith(
+ expect.stringContaining('File has no smells'),
+ );
+ });
+
+ it('should log correct summary statistics', async () => {
+ const files = [
+ `${mockWorkspacePath}/file1.py`, // with smells
+ `${mockWorkspacePath}/file2.py`, // clean
+ '/outside/path/file3.py', // outside workspace
+ `${mockWorkspacePath}/missing.py`, // will fail access
+ ];
+ (context.workspaceState.get as jest.Mock).mockReturnValue(mockWorkspacePath);
+ (smellsCacheManager.getAllFilePaths as jest.Mock).mockReturnValue(files);
+ (fs.access as jest.Mock)
+ .mockResolvedValueOnce(undefined) // file1.py exists
+ .mockResolvedValueOnce(undefined) // file2.py exists
+ .mockRejectedValueOnce(new Error('File not found')); // missing.py doesn't exist
+ (smellsCacheManager.getCachedSmells as jest.Mock)
+ .mockReturnValueOnce([{ id: 'smell1' }]) // file1.py has smells
+ .mockReturnValueOnce([]); // file2.py is clean
+
+ await initializeStatusesFromCache(
+ context,
+ smellsCacheManager,
+ smellsViewProvider,
+ );
+
+ expect(ecoOutput.info).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '2 valid files (1 with smells, 1 clean), 2 files removed from cache',
+ ),
+ );
+ });
+});
diff --git a/test/utils/refactorActionButtons.test.ts b/test/utils/refactorActionButtons.test.ts
new file mode 100644
index 0000000..1b64119
--- /dev/null
+++ b/test/utils/refactorActionButtons.test.ts
@@ -0,0 +1,81 @@
+import * as vscode from 'vscode';
+import {
+ initializeRefactorActionButtons,
+ showRefactorActionButtons,
+ hideRefactorActionButtons,
+} from '../../src/utils/refactorActionButtons';
+
+jest.mock('vscode', () => {
+ const original = jest.requireActual('vscode');
+ return {
+ ...original,
+ StatusBarAlignment: {
+ Right: 2, // You can use the actual enum value or a string
+ },
+ window: {
+ createStatusBarItem: jest.fn(),
+ },
+ commands: {
+ executeCommand: jest.fn(),
+ },
+ ThemeColor: jest.fn().mockImplementation((color) => color),
+ };
+});
+
+jest.mock('../../src/extension', () => ({
+ ecoOutput: {
+ trace: jest.fn(),
+ replace: jest.fn(),
+ },
+}));
+
+describe('Refactor Action Buttons', () => {
+ const acceptMock = {
+ show: jest.fn(),
+ hide: jest.fn(),
+ };
+ const rejectMock = {
+ show: jest.fn(),
+ hide: jest.fn(),
+ };
+
+ const pushSpy = jest.fn();
+ const mockContext = {
+ subscriptions: { push: pushSpy },
+ } as unknown as vscode.ExtensionContext;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ pushSpy.mockClear();
+
+ (vscode.window.createStatusBarItem as jest.Mock)
+ .mockImplementationOnce(() => acceptMock)
+ .mockImplementationOnce(() => rejectMock);
+ });
+
+ it('should show the buttons and set context when shown', () => {
+ initializeRefactorActionButtons(mockContext);
+ showRefactorActionButtons();
+
+ expect(acceptMock.show).toHaveBeenCalled();
+ expect(rejectMock.show).toHaveBeenCalled();
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'setContext',
+ 'refactoringInProgress',
+ true,
+ );
+ });
+
+ it('should hide the buttons and clear context when hidden', () => {
+ initializeRefactorActionButtons(mockContext);
+ hideRefactorActionButtons();
+
+ expect(acceptMock.hide).toHaveBeenCalled();
+ expect(rejectMock.hide).toHaveBeenCalled();
+ expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
+ 'setContext',
+ 'refactoringInProgress',
+ false,
+ );
+ });
+});
diff --git a/test/utils/smellsData.test.ts b/test/utils/smellsData.test.ts
new file mode 100644
index 0000000..fdff91b
--- /dev/null
+++ b/test/utils/smellsData.test.ts
@@ -0,0 +1,211 @@
+// smellsData.test.ts
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+ loadSmells,
+ saveSmells,
+ getFilterSmells,
+ getEnabledSmells,
+ getAcronymByMessageId,
+ getNameByMessageId,
+ getDescriptionByMessageId,
+ FilterSmellConfig,
+} from '../../src/utils/smellsData';
+
+// Mock the modules
+jest.mock('vscode');
+jest.mock('fs');
+jest.mock('path');
+
+const mockSmellsConfig: Record = {
+ 'long-parameter-list': {
+ name: 'Long Parameter List',
+ message_id: 'R0913',
+ acronym: 'LPL',
+ smell_description: 'Method has too many parameters',
+ enabled: true,
+ analyzer_options: {
+ max_params: {
+ label: 'Maximum Parameters',
+ description: 'Maximum allowed parameters',
+ value: 5,
+ },
+ },
+ },
+ 'duplicate-code': {
+ name: 'Duplicate Code',
+ message_id: 'R0801',
+ acronym: 'DC',
+ smell_description: 'Code duplication detected',
+ enabled: false,
+ },
+};
+
+// Mock console.error to prevent test output pollution
+const originalConsoleError = console.error;
+beforeAll(() => {
+ console.error = jest.fn();
+});
+
+afterAll(() => {
+ console.error = originalConsoleError;
+});
+
+describe('smellsData', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup default mocks
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
+ (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockSmellsConfig));
+ (fs.writeFileSync as jest.Mock).mockImplementation(() => {});
+
+ // Mock path.join to return predictable paths
+ (path.join as jest.Mock).mockImplementation((...args: string[]) =>
+ args.join('/').replace(/\\/g, '/'),
+ );
+ });
+
+ describe('loadSmells', () => {
+ it('should load smells configuration successfully', () => {
+ loadSmells('working');
+
+ // Update path expectation to match actual implementation
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('data/working_smells_config.json'),
+ 'utf-8',
+ );
+ expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
+ });
+
+ it('should show error message when file is missing', () => {
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
+
+ loadSmells('working');
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Configuration file missing: smells.json could not be found.',
+ );
+ });
+
+ it('should show error message when file parsing fails', () => {
+ (fs.readFileSync as jest.Mock).mockImplementation(() => {
+ throw new Error('Parse error');
+ });
+
+ loadSmells('working');
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Error loading smells.json. Please check the file format.',
+ );
+ expect(console.error).toHaveBeenCalledWith(
+ 'ERROR: Failed to parse smells.json',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('saveSmells', () => {
+ it('should save smells configuration successfully', () => {
+ saveSmells(mockSmellsConfig);
+
+ // Update path expectation to match actual implementation
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('data/working_smells_config.json'),
+ JSON.stringify(mockSmellsConfig, null, 2),
+ );
+ expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
+ });
+
+ it('should show error message when file write fails', () => {
+ (fs.writeFileSync as jest.Mock).mockImplementation(() => {
+ throw new Error('Write error');
+ });
+
+ saveSmells(mockSmellsConfig);
+
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Error saving smells.json.',
+ );
+ expect(console.error).toHaveBeenCalledWith(
+ 'ERROR: Failed to write smells.json',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('getFilterSmells', () => {
+ it('should return the loaded filter smells', () => {
+ loadSmells('working');
+ const result = getFilterSmells();
+
+ expect(result).toEqual(mockSmellsConfig);
+ });
+ });
+
+ describe('getEnabledSmells', () => {
+ it('should return only enabled smells with parsed options', () => {
+ loadSmells('working');
+ const result = getEnabledSmells();
+
+ expect(result).toEqual({
+ 'long-parameter-list': {
+ message_id: 'R0913',
+ acronym: 'LPL',
+ options: {
+ max_params: 5,
+ },
+ },
+ });
+ });
+ });
+
+ describe('getAcronymByMessageId', () => {
+ it('should return the correct acronym for a message ID', () => {
+ loadSmells('working');
+ const result = getAcronymByMessageId('R0913');
+
+ expect(result).toBe('LPL');
+ });
+
+ it('should return undefined for unknown message ID', () => {
+ loadSmells('working');
+ const result = getAcronymByMessageId('UNKNOWN');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getNameByMessageId', () => {
+ it('should return the correct name for a message ID', () => {
+ loadSmells('working');
+ const result = getNameByMessageId('R0913');
+
+ expect(result).toBe('Long Parameter List');
+ });
+
+ it('should return undefined for unknown message ID', () => {
+ loadSmells('working');
+ const result = getNameByMessageId('UNKNOWN');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getDescriptionByMessageId', () => {
+ it('should return the correct description for a message ID', () => {
+ loadSmells('working');
+ const result = getDescriptionByMessageId('R0913');
+
+ expect(result).toBe('Method has too many parameters');
+ });
+
+ it('should return undefined for unknown message ID', () => {
+ loadSmells('working');
+ const result = getDescriptionByMessageId('UNKNOWN');
+
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/test/utils/trackedDiffEditors.test.ts b/test/utils/trackedDiffEditors.test.ts
new file mode 100644
index 0000000..252f58b
--- /dev/null
+++ b/test/utils/trackedDiffEditors.test.ts
@@ -0,0 +1,115 @@
+// utils/trackedDiffEditors.test.ts
+import * as vscode from 'vscode';
+import {
+ registerDiffEditor,
+ isTrackedDiffEditor,
+ closeAllTrackedDiffEditors,
+ trackedDiffs,
+} from '../../src/utils/trackedDiffEditors';
+
+// Mock the vscode API
+jest.mock('vscode', () => ({
+ window: {
+ tabGroups: {
+ close: jest.fn().mockResolvedValue(true),
+ all: [],
+ },
+ },
+ Uri: {
+ parse: jest.fn(),
+ },
+}));
+
+describe('trackedDiffEditors', () => {
+ const mockUri1 = { toString: () => 'file:///test1.txt' } as vscode.Uri;
+ const mockUri2 = { toString: () => 'file:///test2.txt' } as vscode.Uri;
+ const mockUri3 = { toString: () => 'file:///test3.txt' } as vscode.Uri;
+
+ beforeEach(() => {
+ // Clear the trackedDiffs set before each test
+ trackedDiffs.clear();
+ // Reset the mock implementation
+ (vscode.window.tabGroups.close as jest.Mock).mockClear().mockResolvedValue(true);
+ // Reset tab groups mock
+ (vscode.window.tabGroups.all as any) = [];
+ });
+
+ describe('registerDiffEditor', () => {
+ it('should register a diff editor with given URIs', () => {
+ registerDiffEditor(mockUri1, mockUri2);
+ expect(isTrackedDiffEditor(mockUri1, mockUri2)).toBe(true);
+ });
+
+ it('should not register unrelated URIs', () => {
+ registerDiffEditor(mockUri1, mockUri2);
+ expect(isTrackedDiffEditor(mockUri1, mockUri3)).toBe(false);
+ expect(isTrackedDiffEditor(mockUri2, mockUri3)).toBe(false);
+ });
+ });
+
+ describe('isTrackedDiffEditor', () => {
+ it('should return true for registered diff editors', () => {
+ registerDiffEditor(mockUri1, mockUri2);
+ expect(isTrackedDiffEditor(mockUri1, mockUri2)).toBe(true);
+ });
+
+ it('should return false for unregistered diff editors', () => {
+ expect(isTrackedDiffEditor(mockUri1, mockUri2)).toBe(false);
+ });
+
+ it('should be case sensitive for URIs', () => {
+ const mockUriLower = { toString: () => 'file:///test1.txt' } as vscode.Uri;
+ const mockUriUpper = { toString: () => 'FILE:///TEST1.TXT' } as vscode.Uri;
+ registerDiffEditor(mockUriLower, mockUri2);
+ expect(isTrackedDiffEditor(mockUriUpper, mockUri2)).toBe(false);
+ });
+ });
+
+ describe('closeAllTrackedDiffEditors', () => {
+ it('should close all tracked diff editors', async () => {
+ // Setup mock tabs
+ const mockTab1 = {
+ input: { original: mockUri1, modified: mockUri2 },
+ };
+ const mockTab2 = {
+ input: { original: mockUri3, modified: mockUri2 },
+ };
+ const mockTab3 = {
+ input: { somethingElse: true },
+ };
+
+ // Mock the tabGroups.all
+ (vscode.window.tabGroups.all as any) = [
+ { tabs: [mockTab1, mockTab2] },
+ { tabs: [mockTab3] },
+ ];
+
+ registerDiffEditor(mockUri1, mockUri2);
+
+ await closeAllTrackedDiffEditors();
+
+ expect(vscode.window.tabGroups.close).toHaveBeenCalledTimes(1);
+ expect(vscode.window.tabGroups.close).toHaveBeenCalledWith(mockTab1, true);
+ });
+
+ it('should clear all tracked diffs after closing', async () => {
+ registerDiffEditor(mockUri1, mockUri2);
+ (vscode.window.tabGroups.all as any) = [];
+
+ await closeAllTrackedDiffEditors();
+
+ expect(isTrackedDiffEditor(mockUri1, mockUri2)).toBe(false);
+ });
+
+ it('should handle empty tabs', async () => {
+ // Ensure no tabs exist
+ (vscode.window.tabGroups.all as any) = [];
+ // Don't register any editors for this test
+
+ await closeAllTrackedDiffEditors();
+
+ expect(vscode.window.tabGroups.close).not.toHaveBeenCalled();
+ expect(trackedDiffs.size).toBe(0);
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 705af40..da4d5ef 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,7 +15,7 @@
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true
},
- "include": ["./src/global.d.ts", "./src/types.d.ts", "src/**/*.ts", "test/**/*.ts", "media/script.js"],
+ "include": ["./src/global.d.ts", "src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
\ No newline at end of file
diff --git a/vsc-extension-quickstart.md b/vsc-extension-quickstart.md
deleted file mode 100644
index f518bb8..0000000
--- a/vsc-extension-quickstart.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Welcome to your VS Code Extension
-
-## What's in the folder
-
-* This folder contains all of the files necessary for your extension.
-* `package.json` - this is the manifest file in which you declare your extension and command.
- * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin.
-* `src/extension.ts` - this is the main file where you will provide the implementation of your command.
- * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`.
- * We pass the function containing the implementation of the command as the second parameter to `registerCommand`.
-
-## Setup
-
-* install the recommended extensions (amodio.tsl-problem-matcher, ms-vscode.extension-test-runner, and dbaeumer.vscode-eslint)
-
-
-## Get up and running straight away
-
-* Press `F5` to open a new window with your extension loaded.
-* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`.
-* Set breakpoints in your code inside `src/extension.ts` to debug your extension.
-* Find output from your extension in the debug console.
-
-## Make changes
-
-* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`.
-* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes.
-
-
-## Explore the API
-
-* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`.
-
-## Run tests
-
-* Install the [Extension Test Runner](https://marketplace.visualstudio.com/items?itemName=ms-vscode.extension-test-runner)
-* Run the "watch" task via the **Tasks: Run Task** command. Make sure this is running, or tests might not be discovered.
-* Open the Testing view from the activity bar and click the Run Test" button, or use the hotkey `Ctrl/Cmd + ; A`
-* See the output of the test result in the Test Results view.
-* Make changes to `src/test/extension.test.ts` or create new test files inside the `test` folder.
- * The provided test runner will only consider files matching the name pattern `**.test.ts`.
- * You can create folders inside the `test` folder to structure your tests any way you want.
-
-## Go further
-
-* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension).
-* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace.
-* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration).
diff --git a/webpack.config.js b/webpack.config.js
index ea0eca6..320a4f0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,20 +1,20 @@
const path = require('path');
-const nodeExternals = require('webpack-node-externals');
-const Dotenv = require('dotenv-webpack');
module.exports = {
target: 'node',
- entry: './src/extension.ts',
+ entry: {
+ extension: './src/extension.ts',
+ install: './src/install.ts' // Separate entry point for install script
+ },
output: {
path: path.resolve(__dirname, 'dist'),
- filename: 'extension.js',
- libraryTarget: 'commonjs2',
+ filename: '[name].js',
+ libraryTarget: 'commonjs2'
},
resolve: {
extensions: ['.ts', '.js'],
},
externals: [
- nodeExternals(),
{ vscode: 'commonjs vscode' },
],
module: {
@@ -31,9 +31,4 @@ module.exports = {
infrastructureLogging: {
level: 'log' // enables logging required for problem matchers
},
- plugins: [
- new Dotenv({
- path: './.env',
- }),
- ],
};
\ No newline at end of file