Initial commit

This commit is contained in:
NinjaPug
2025-06-10 16:10:59 -04:00
commit edd1b1dbb1
624 changed files with 1174453 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import * as vscode from 'vscode';
import { CSSAnalyzer } from './CSSAnalyzer';
import { HTMLAnalyzer } from './HTMLAnalyzer';
import { TypeScriptAnalyzer } from './TypeScriptAnalyzer';
import { ComponentFiles, UnusedStyle } from '../types/ComponentInterfaces';
import { FileResolver } from '../utils/FileResolver';
import { UnusedStyleFinder } from '../utils/UnusedStyleFinder';
export class AngularComponentAnalyzer {
private cssAnalyzer = new CSSAnalyzer();
private htmlAnalyzer = new HTMLAnalyzer();
private tsAnalyzer = new TypeScriptAnalyzer();
private fileResolver = new FileResolver();
private unusedStyleFinder = new UnusedStyleFinder();
async analyzeComponent(componentFiles: ComponentFiles): Promise<UnusedStyle[]> {
if (!componentFiles.styles) {
return [];
}
const cssSelectors = this.cssAnalyzer.extractSelectors(componentFiles.styles);
const htmlUsedSelectors: Set<string> = componentFiles.html ?
this.htmlAnalyzer.extractUsedSelectors(componentFiles.html) : new Set<string>();
const tsUsedSelectors: Set<string> = componentFiles.typescript ?
this.tsAnalyzer.extractDynamicSelectors(componentFiles.typescript) : new Set<string>();
const allUsedSelectors = new Set<string>([...htmlUsedSelectors, ...tsUsedSelectors]);
return this.unusedStyleFinder.findUnusedStyles(cssSelectors, allUsedSelectors, componentFiles.styles);
}
async getComponentFiles(document: vscode.TextDocument): Promise<ComponentFiles> {
return this.fileResolver.getComponentFiles(document);
}
}

136
src/analyzer/CSSAnalyzer.ts Normal file
View File

@@ -0,0 +1,136 @@
import * as vscode from 'vscode';
import { SelectorPosition } from '../types/ComponentInterfaces';
export class CSSAnalyzer {
extractSelectors(document: vscode.TextDocument): Map<string, SelectorPosition> {
const selectors = new Map<string, SelectorPosition>();
const text = document.getText();
// Parse CSS/SCSS using regex-based approach
this.parseStylesheet(text, selectors);
return selectors;
}
private parseStylesheet(text: string, selectors: Map<string, SelectorPosition>) {
const lines = text.split('\n');
let inComment = false;
let inRule = false;
let braceCount = 0;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Handle multi-line comments
if (inComment) {
const commentEnd = line.indexOf('*/');
if (commentEnd !== -1) {
inComment = false;
line = line.substring(commentEnd + 2);
} else {
continue;
}
}
// Remove single-line comments
const commentStart = line.indexOf('//');
if (commentStart !== -1) {
line = line.substring(0, commentStart);
}
// Handle multi-line comment start
const multiCommentStart = line.indexOf('/*');
if (multiCommentStart !== -1) {
const multiCommentEnd = line.indexOf('*/', multiCommentStart);
if (multiCommentEnd !== -1) {
line = line.substring(0, multiCommentStart) + line.substring(multiCommentEnd + 2);
} else {
inComment = true;
line = line.substring(0, multiCommentStart);
}
}
// Count braces to track nesting
const openBraces = (line.match(/\{/g) || []).length;
const closeBraces = (line.match(/\}/g) || []).length;
// If we're not in a rule and line contains a selector pattern
if (!inRule && braceCount === 0) {
const selectorMatch = this.extractSelectorFromLine(line, i);
if (selectorMatch) {
selectors.set(selectorMatch.selector, {
line: i,
character: selectorMatch.character,
length: selectorMatch.length
});
}
}
braceCount += openBraces - closeBraces;
inRule = braceCount > 0;
}
}
private extractSelectorFromLine(line: string, lineNumber: number): {selector: string, character: number, length: number} | null {
// Remove leading/trailing whitespace
const trimmed = line.trim();
// Skip empty lines, variables, imports, mixins, etc.
if (!trimmed ||
trimmed.startsWith('$') ||
trimmed.startsWith('@') ||
trimmed.startsWith('//') ||
trimmed.startsWith('/*') ||
!trimmed.includes('{')) {
return null;
}
// Extract selector part (everything before the opening brace)
const braceIndex = trimmed.indexOf('{');
if (braceIndex === -1) {
return null;
}
const selectorPart = trimmed.substring(0, braceIndex).trim();
// Validate that this looks like a CSS selector
if (this.isValidSelector(selectorPart)) {
const character = line.indexOf(selectorPart);
return {
selector: selectorPart,
character: character >= 0 ? character : 0,
length: selectorPart.length
};
}
return null;
}
private isValidSelector(selector: string): boolean {
// Skip SCSS control structures and mixins
if (selector.startsWith('@') ||
selector.includes('@if') ||
selector.includes('@for') ||
selector.includes('@while') ||
selector.includes('@each') ||
selector.includes('@mixin') ||
selector.includes('@include')) {
return false;
}
// Basic CSS selector patterns
const selectorPatterns = [
/^[.#]?[a-zA-Z_-][a-zA-Z0-9_-]*/, // Class, ID, or element
/^::[a-zA-Z-]+/, // Pseudo-elements
/^:[a-zA-Z-]+/, // Pseudo-classes
/^\[.*\]/, // Attribute selectors
/^[*]/, // Universal selector
];
// Check if selector matches any valid pattern
return selectorPatterns.some(pattern => pattern.test(selector.trim())) ||
selector.includes('.') ||
selector.includes('#') ||
selector.includes(':');
}
}

View File

@@ -0,0 +1,43 @@
import * as vscode from 'vscode';
export class HTMLAnalyzer {
extractUsedSelectors(document: vscode.TextDocument): Set<string> {
const usedSelectors = new Set<string>();
const text = document.getText();
// Extract class attributes
const classMatches = text.match(/class\s*=\s*["']([^"']+)["']/g) || [];
classMatches.forEach(match => {
const classes = match.replace(/class\s*=\s*["']([^"']+)["']/, '$1').split(/\s+/);
classes.forEach(cls => cls.trim() && usedSelectors.add(cls.trim()));
});
// Extract id attributes
const idMatches = text.match(/id\s*=\s*["']([^"']+)["']/g) || [];
idMatches.forEach(match => {
const id = match.replace(/id\s*=\s*["']([^"']+)["']/, '$1').trim();
if (id) usedSelectors.add(id);
});
// Extract Angular class bindings
const ngClassMatches = text.match(/\[class\.([^\]]+)\]/g) || [];
ngClassMatches.forEach(match => {
const className = match.replace(/\[class\.([^\]]+)\]/, '$1');
usedSelectors.add(className);
});
// Extract ngClass bindings
const ngClassObjectMatches = text.match(/\[ngClass\]\s*=\s*["']([^"']+)["']/g) || [];
ngClassObjectMatches.forEach(match => {
const expr = match.replace(/\[ngClass\]\s*=\s*["']([^"']+)["']/, '$1');
// This would need more sophisticated parsing for object expressions
const possibleClasses = expr.match(/['"`]([^'"`]+)['"`]/g) || [];
possibleClasses.forEach(cls => {
const className = cls.replace(/['"`]/g, '');
usedSelectors.add(className);
});
});
return usedSelectors;
}
}

View File

@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
export class TypeScriptAnalyzer {
extractDynamicSelectors(document: vscode.TextDocument): Set<string> {
const dynamicSelectors = new Set<string>();
const text = document.getText();
// Look for renderer.addClass, renderer.removeClass calls
const rendererMatches = text.match(/renderer\.(addClass|removeClass)\s*\(\s*[^,]+,\s*['"`]([^'"`]+)['"`]/g) || [];
rendererMatches.forEach(match => {
const className = match.replace(/.*['"`]([^'"`]+)['"`].*/, '$1');
dynamicSelectors.add(className);
});
// Look for element.classList operations
const classListMatches = text.match(/\.classList\.(add|remove|toggle)\s*\(\s*['"`]([^'"`]+)['"`]/g) || [];
classListMatches.forEach(match => {
const className = match.replace(/.*['"`]([^'"`]+)['"`].*/, '$1');
dynamicSelectors.add(className);
});
// Look for HostBinding decorators
const hostBindingMatches = text.match(/@HostBinding\s*\(\s*['"`]class\.([^'"`]+)['"`]/g) || [];
hostBindingMatches.forEach(match => {
const className = match.replace(/@HostBinding\s*\(\s*['"`]class\.([^'"`]+)['"`].*/, '$1');
dynamicSelectors.add(className);
});
// Look for string literals that might be class names
const stringLiteralMatches = text.match(/['"`][a-zA-Z_-][a-zA-Z0-9_-]*['"`]/g) || [];
stringLiteralMatches.forEach(match => {
const str = match.replace(/['"`]/g, '');
// Only consider strings that look like CSS class names
if (str.match(/^[a-zA-Z_-][a-zA-Z0-9_-]*$/) && str.length > 2) {
dynamicSelectors.add(str);
}
});
return dynamicSelectors;
}
}

51
src/extension.ts Normal file
View File

@@ -0,0 +1,51 @@
import * as vscode from 'vscode';
import { AngularComponentAnalyzer } from './analyzer/AngularComponentAnalyzer';
import { UnusedStylesProvider } from './providers/UnusedStylesProvider';
let analyzer: AngularComponentAnalyzer;
let diagnosticCollection: vscode.DiagnosticCollection;
export function activate(context: vscode.ExtensionContext) {
analyzer = new AngularComponentAnalyzer();
diagnosticCollection = vscode.languages.createDiagnosticCollection('angular-unused-styles');
const provider = new UnusedStylesProvider(analyzer, diagnosticCollection);
// Register command
const analyzeCommand = vscode.commands.registerCommand('angular-unused-styles.analyze', () => {
provider.analyzeCurrentWorkspace();
});
// Register document change handlers
const onDidChangeTextDocument = vscode.workspace.onDidChangeTextDocument(event => {
if (isAngularFile(event.document.fileName)) {
provider.analyzeDocument(event.document);
}
});
const onDidOpenTextDocument = vscode.workspace.onDidOpenTextDocument(document => {
if (isAngularFile(document.fileName)) {
provider.analyzeDocument(document);
}
});
context.subscriptions.push(
analyzeCommand,
onDidChangeTextDocument,
onDidOpenTextDocument,
diagnosticCollection
);
}
function isAngularFile(fileName: string): boolean {
return fileName.endsWith('.component.ts') ||
fileName.endsWith('.component.html') ||
fileName.endsWith('.component.scss') ||
fileName.endsWith('.component.css');
}
export function deactivate() {
if (diagnosticCollection) {
diagnosticCollection.dispose();
}
}

View File

@@ -0,0 +1,68 @@
import * as vscode from 'vscode';
import { AngularComponentAnalyzer } from '../analyzer/AngularComponentAnalyzer';
import { UnusedStyle } from '../types/ComponentInterfaces';
export class UnusedStylesProvider {
constructor(
private analyzer: AngularComponentAnalyzer,
private diagnosticCollection: vscode.DiagnosticCollection
) {}
async analyzeDocument(document: vscode.TextDocument) {
if (!this.isAngularStyleFile(document.fileName)) {
return;
}
const componentFiles = await this.analyzer.getComponentFiles(document);
const unusedStyles = await this.analyzer.analyzeComponent(componentFiles);
this.updateDiagnostics(document, unusedStyles);
}
async analyzeCurrentWorkspace() {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders) {
return;
}
for (const folder of workspaceFolders) {
const pattern = new vscode.RelativePattern(folder, '**/*.component.{scss,css}');
const files = await vscode.workspace.findFiles(pattern);
for (const file of files) {
const document = await vscode.workspace.openTextDocument(file);
await this.analyzeDocument(document);
}
}
vscode.window.showInformationMessage('Angular unused styles analysis completed!');
}
private updateDiagnostics(document: vscode.TextDocument, unusedStyles: UnusedStyle[]) {
const diagnostics: vscode.Diagnostic[] = unusedStyles.map(unused => {
const range = new vscode.Range(
unused.line,
unused.character,
unused.line,
unused.character + unused.length
);
const diagnostic = new vscode.Diagnostic(
range,
unused.reason,
vscode.DiagnosticSeverity.Warning
);
diagnostic.source = 'Angular Unused Styles';
diagnostic.code = 'unused-style';
return diagnostic;
});
this.diagnosticCollection.set(document.uri, diagnostics);
}
private isAngularStyleFile(fileName: string): boolean {
return fileName.endsWith('.component.scss') || fileName.endsWith('.component.css');
}
}

View File

@@ -0,0 +1,21 @@
import * as vscode from 'vscode';
export interface ComponentFiles {
typescript?: vscode.TextDocument;
html?: vscode.TextDocument;
styles?: vscode.TextDocument;
}
export interface UnusedStyle {
selector: string;
line: number;
character: number;
length: number;
reason: string;
}
export interface SelectorPosition {
line: number;
character: number;
length: number;
}

50
src/utils/FileResolver.ts Normal file
View File

@@ -0,0 +1,50 @@
import * as vscode from 'vscode';
import { ComponentFiles } from '../types/ComponentInterfaces';
export class FileResolver {
async getComponentFiles(document: vscode.TextDocument): Promise<ComponentFiles> {
const basePath = this.getBasePath(document.fileName);
const componentFiles: ComponentFiles = {};
if (document.fileName.endsWith('.component.ts')) {
componentFiles.typescript = document;
} else if (document.fileName.endsWith('.component.html')) {
componentFiles.html = document;
} else if (document.fileName.endsWith('.component.scss') || document.fileName.endsWith('.component.css')) {
componentFiles.styles = document;
}
// Try to find related files
try {
if (!componentFiles.typescript) {
const tsFile = await vscode.workspace.openTextDocument(basePath + '.component.ts');
componentFiles.typescript = tsFile;
}
if (!componentFiles.html) {
const htmlFile = await vscode.workspace.openTextDocument(basePath + '.component.html');
componentFiles.html = htmlFile;
}
if (!componentFiles.styles) {
try {
const scssFile = await vscode.workspace.openTextDocument(basePath + '.component.scss');
componentFiles.styles = scssFile;
} catch {
try {
const cssFile = await vscode.workspace.openTextDocument(basePath + '.component.css');
componentFiles.styles = cssFile;
} catch {
// No styles file found
}
}
}
} catch (error) {
// Some files might not exist, which is fine
}
return componentFiles;
}
private getBasePath(fileName: string): string {
return fileName.replace(/\.(component\.(ts|html|scss|css))$/, '');
}
}

View File

@@ -0,0 +1,46 @@
export class SelectorMatcher {
isSelectorUsed(cssSelector: string, usedSelectors: Set<string>): boolean {
// Clean the CSS selector for comparison
const cleanSelector = this.cleanCSSSelector(cssSelector);
// Check for exact matches
if (usedSelectors.has(cleanSelector)) {
return true;
}
// Check for partial matches (class and ID selectors)
for (const used of usedSelectors) {
if (this.selectorsMatch(cleanSelector, used)) {
return true;
}
}
return false;
}
shouldIgnoreSelector(selector: string, ignoredSelectors: string[]): boolean {
return ignoredSelectors.some(ignored =>
selector.includes(ignored) ||
selector.match(new RegExp(ignored.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
);
}
private cleanCSSSelector(selector: string): string {
// Remove pseudo-classes, pseudo-elements, and complex selectors
return selector
.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '') // Remove pseudo-classes/elements
.replace(/\s*[>+~]\s*/g, ' ') // Simplify combinators
.replace(/\[.*?\]/g, '') // Remove attribute selectors
.trim();
}
private selectorsMatch(cssSelector: string, usedSelector: string): boolean {
// Extract class names and IDs
const cssClasses = cssSelector.match(/\.[a-zA-Z_-][a-zA-Z0-9_-]*/g) || [];
const cssIds = cssSelector.match(/#[a-zA-Z_-][a-zA-Z0-9_-]*/g) || [];
return [...cssClasses, ...cssIds].some(sel =>
usedSelector.includes(sel.substring(1)) // Remove . or # prefix
);
}
}

View File

@@ -0,0 +1,35 @@
import * as vscode from 'vscode';
import { UnusedStyle, SelectorPosition } from '../types/ComponentInterfaces';
import { SelectorMatcher } from './SelectorMatcher';
export class UnusedStyleFinder {
private selectorMatcher = new SelectorMatcher();
findUnusedStyles(
cssSelectors: Map<string, SelectorPosition>,
usedSelectors: Set<string>,
styleDocument: vscode.TextDocument
): UnusedStyle[] {
const unused: UnusedStyle[] = [];
const config = vscode.workspace.getConfiguration('angularUnusedStyles');
const ignoredSelectors: string[] = config.get('ignoredSelectors', []);
for (const [selector, position] of cssSelectors.entries()) {
if (this.selectorMatcher.shouldIgnoreSelector(selector, ignoredSelectors)) {
continue;
}
if (!this.selectorMatcher.isSelectorUsed(selector, usedSelectors)) {
unused.push({
selector,
line: position.line,
character: position.character,
length: position.length,
reason: `Style '${selector}' appears to be unused`
});
}
}
return unused;
}
}