Initial commit
This commit is contained in:
35
src/analyzer/AngularComponentAnalyzer.ts
Normal file
35
src/analyzer/AngularComponentAnalyzer.ts
Normal 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
136
src/analyzer/CSSAnalyzer.ts
Normal 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(':');
|
||||
}
|
||||
}
|
||||
43
src/analyzer/HTMLAnalyzer.ts
Normal file
43
src/analyzer/HTMLAnalyzer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/analyzer/TypeScriptAnalyzer.ts
Normal file
41
src/analyzer/TypeScriptAnalyzer.ts
Normal 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
51
src/extension.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
68
src/providers/UnusedStylesProvider.ts
Normal file
68
src/providers/UnusedStylesProvider.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
21
src/types/ComponentInterfaces.ts
Normal file
21
src/types/ComponentInterfaces.ts
Normal 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
50
src/utils/FileResolver.ts
Normal 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))$/, '');
|
||||
}
|
||||
}
|
||||
46
src/utils/SelectorMatcher.ts
Normal file
46
src/utils/SelectorMatcher.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/utils/UnusedStyleFinder.ts
Normal file
35
src/utils/UnusedStyleFinder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user