Skip to content

Instantly share code, notes, and snippets.

@tomastrajan
Last active March 5, 2025 17:39
Show Gist options
  • Save tomastrajan/678a027a926a2c6aad1a5783e1d8d2b2 to your computer and use it in GitHub Desktop.
Save tomastrajan/678a027a926a2c6aad1a5783e1d8d2b2 to your computer and use it in GitHub Desktop.
no-unused-component-methods.js
const fs = require('fs');
const path = require('path');
const EXCLUDED_METHODS = [
'constructor',
'writeValue',
'registerOnChange',
'registerOnTouched',
'setDisabledState',
];
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Detect unused Angular component methods that are not public, not used in the template, not called inside the class, or not referenced in the host:{} property.',
category: 'Best Practices',
recommended: false,
},
schema: [], // No options needed
messages: {
unusedMethod:
"Method '{{ methodName }}' is not used in the template, other methods, host bindings, or explicitly marked as public.",
},
},
create(context) {
return {
ClassDeclaration(node) {
const filename = context.getFilename();
if (!filename.endsWith('.component.ts')) return;
let methods = new Map(); // Stores method name -> method node
let calledMethods = new Set();
let templateContent = '';
let hostBindings = '';
// Step 1: Extract methods from the component class
node.body.body.forEach((member) => {
if (
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
!EXCLUDED_METHODS.includes(member.key.name)
) {
const methodName = member.key.name;
const isPublic = member.accessibility === 'public';
methods.set(methodName, {
node: member,
isPublic,
});
}
});
// Step 2: Find method calls inside class methods using controlled DFS traversal
node.body.body.forEach((member) => {
if (
member.type === 'MethodDefinition' &&
(member.value.type === 'FunctionExpression' ||
member.value.type === 'ArrowFunctionExpression')
) {
findMethodCallsInClass(member.value, calledMethods, methods, 5);
}
});
// Step 3: Find the @Component decorator
const decorators = node.decorators || [];
const componentDecorator = decorators.find((decorator) => {
return (
decorator.expression &&
decorator.expression.callee &&
decorator.expression.callee.name === 'Component'
);
});
if (!componentDecorator) return;
// Step 4: Extract template file, inline template, and host property
const componentArg = componentDecorator.expression.arguments[0];
if (!componentArg || componentArg.type !== 'ObjectExpression') return;
componentArg.properties.forEach((property) => {
if (property.key.name === 'templateUrl') {
let templatePathRaw = '';
if (property.value.type === 'Literal') {
templatePathRaw = property.value.value; // Simple case
} else if (property.value.type === 'TemplateLiteral') {
// Extract template parts from quasis
templatePathRaw = property.value.quasis
.map((quasi) => quasi.value.cooked || quasi.value.raw)
.join(''); // Join all static parts
}
// Read external template
const templatePath = path.resolve(
path.dirname(filename),
templatePathRaw,
);
if (fs.existsSync(templatePath)) {
templateContent = fs.readFileSync(templatePath, 'utf-8');
}
} else if (property.key.name === 'template') {
// Handle both Literal and TemplateLiteral
if (property.value.type === 'Literal') {
templateContent = property.value.value; // Simple case
} else if (property.value.type === 'TemplateLiteral') {
// Extract template parts from quasis
templateContent = property.value.quasis
.map((quasi) => quasi.value.cooked || quasi.value.raw)
.join(''); // Join all static parts
}
}
if (
property.key.name === 'host' &&
property.value.type === 'ObjectExpression'
) {
// Extract host bindings
property.value.properties.forEach((hostProp) => {
if (hostProp.value.type === 'Literal') {
hostBindings += hostProp.value.value + ' ';
}
});
}
});
// Step 5: Check which methods are used in the template
methods.forEach((methodData, methodName) => {
const methodRegex = new RegExp(`\\b${methodName}\\b`, 'g');
if (methodRegex.test(templateContent)) {
calledMethods.add(methodName);
}
});
// Step 6: Check which methods are used in host bindings
methods.forEach((methodData, methodName) => {
const methodRegex = new RegExp(`\\b${methodName}\\b`, 'g');
if (methodRegex.test(hostBindings)) {
calledMethods.add(methodName);
}
});
// Step 7: Report unused methods that are neither public nor used
methods.forEach((methodData, methodName) => {
if (!methodData.isPublic && !calledMethods.has(methodName)) {
context.report({
node: methodData.node,
messageId: 'unusedMethod',
data: { methodName },
});
}
});
},
};
},
};
/**
* Traverses an AST method node (DFS) to find method calls inside a class.
* Uses an explicit stack to prevent deep recursion.
*/
function findMethodCallsInClass(
methodNode,
calledMethods,
methods,
maxDepth = 5,
) {
if (!methodNode || !methodNode.body || !methodNode.body.body) return;
const stack = methodNode.body.body.map((node) => ({ node, depth: 0 })); // Initialize stack with top-level nodes
while (stack.length > 0) {
const { node, depth } = stack.pop();
if (depth > maxDepth) continue; // Stop deep traversal
if (
node.type === 'ExpressionStatement' &&
node.expression.type === 'CallExpression'
) {
const callExpression = node.expression;
// Check if this is a `this.someMethod()` call
if (
callExpression.callee.type === 'MemberExpression' &&
callExpression.callee.object.type === 'ThisExpression' &&
callExpression.callee.property.type === 'Identifier'
) {
const methodName = callExpression.callee.property.name;
if (methods.has(methodName)) {
calledMethods.add(methodName);
}
}
// Process all arguments of the call expression
callExpression.arguments.forEach((arg) => {
if (
arg.type === 'CallExpression' && // If an argument is another function call
arg.callee.type === 'MemberExpression' &&
arg.callee.object.type === 'ThisExpression' &&
arg.callee.property.type === 'Identifier'
) {
const methodName = arg.callee.property.name;
if (methods.has(methodName)) {
calledMethods.add(methodName);
}
}
});
}
// Push child nodes onto stack with increased depth
for (const key in node) {
if (node[key] && typeof node[key] === 'object') {
if (Array.isArray(node[key])) {
node[key].forEach((childNode) =>
stack.push({ node: childNode, depth: depth + 1 }),
);
} else {
stack.push({ node: node[key], depth: depth + 1 });
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment