Skip to content

Instantly share code, notes, and snippets.

@basperheim
Last active July 17, 2025 13:05
Show Gist options
  • Select an option

  • Save basperheim/87d75448b52d82ca06f681450ab05b3e to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/87d75448b52d82ca06f681450ab05b3e to your computer and use it in GitHub Desktop.
Python script that prints Angular custom component methods declared in a component TS file, and also prints how they are used.
import os
import re
import argparse
from collections import defaultdict
# ---------------------------------------------------------
# Step 0: Utility Functions
# ---------------------------------------------------------
def find_parent_method(lines, call_line_num):
"""
Walk upward from the given line number to find the method that encloses a method call.
Returns a tuple of (line_num, method_signature) or None.
"""
method_pattern = re.compile(
r'(public|private|protected)?\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)\s*(:\s*[a-zA-Z0-9_<>\[\]| ]+)?\s*\{'
)
for i in range(call_line_num - 2, -1, -1):
line = lines[i].strip()
match = method_pattern.match(line)
if match:
method_name = match.group(2)
method_args = match.group(3).strip()
method_sig = f"{method_name}({method_args})"
return (i + 1, method_sig)
return None
def find_enclosing_condition(lines, call_line_num, parent_method_line):
"""
Walk upward from the call line up to the method start to find enclosing condition/loop/switch.
Returns the first matching block pattern as (line_num, line) or None.
"""
block_pattern = re.compile(
r'^(if|else if|else|for|while|switch)\b[^\{]*\{?'
)
brace_depth = 0
for i in range(call_line_num - 2, parent_method_line - 2, -1):
line = lines[i].strip()
brace_depth += line.count('}')
brace_depth -= line.count('{')
if brace_depth < 0:
brace_depth = 0
if brace_depth == 0 and block_pattern.match(line):
return (i + 1, line)
return None
def find_enclosing_blocks(lines, call_line_num):
"""
Scan upward from a line to find enclosing block condition (if/for/etc) and method signature.
Returns: (enclosing_condition, enclosing_method) as tuples with line numbers.
"""
brace_depth = 0
enclosing_condition = None
enclosing_method = None
for i in range(call_line_num - 2, -1, -1):
line = lines[i].strip()
brace_depth += line.count('}')
brace_depth -= line.count('{')
if brace_depth < 0:
brace_depth = 0
if brace_depth == 0:
if re.match(r'(if|else if|else|for|while|switch)\b[^\{]*\{?$', line) and not enclosing_condition:
enclosing_condition = (i + 1, line)
m2 = re.match(r'(public|private|protected)?\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)', line)
if m2 and not enclosing_method:
method_signature = f"{m2.group(2)}({m2.group(3).strip()})"
enclosing_method = (i + 1, method_signature)
if enclosing_condition and enclosing_method:
break
return enclosing_condition, enclosing_method
# ---------------------------------------------------------
# Step 1: Component File Discovery
# ---------------------------------------------------------
def find_component_files(directory, match_string):
"""
Recursively walk through the given directory and return .ts files that include @Component
and a selector that includes the match_string.
"""
matching_files = []
for root, _, files in os.walk(directory):
for file in files:
if file.endswith('.ts'):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
if '@Component' in content:
selector_match = re.search(r"selector:\s*['\"]([^'\"]+)['\"]", content)
if selector_match and match_string in selector_match.group(1):
matching_files.append(file_path)
except Exception as e:
print(f"Error reading {file_path}: {e}")
return matching_files
# ---------------------------------------------------------
# Step 2: Extract JSDoc for a Given Method Line
# ---------------------------------------------------------
def extract_jsdoc(lines, method_line_num):
"""
Given the line number of a method declaration, find the preceding JSDoc block (/** ... */)
and return it as a cleaned-up string. Returns None if not found.
"""
i = method_line_num - 2
jsdoc_lines = []
while i >= 0:
line = lines[i].rstrip()
if line.strip() == '':
i -= 1
continue
if line.strip().endswith('*/'):
while i >= 0:
line = lines[i].rstrip()
jsdoc_lines.append(line)
if line.strip().startswith('/**'):
break
i -= 1
jsdoc_clean = []
for l in reversed(jsdoc_lines):
l = l.strip()
l = re.sub(r'^/\*\*|\*/$', '', l)
l = re.sub(r'^\*\s?', '', l)
if l:
jsdoc_clean.append(" " + l)
return '\n'.join(jsdoc_clean)
elif not line.strip().startswith('*') and not line.strip().startswith('/**'):
break
i -= 1
return None
# ---------------------------------------------------------
# Step 3: Extract Method Declarations (Blocks)
# ---------------------------------------------------------
def extract_method_blocks(lines):
"""
Return a list of method blocks found in the file.
Each block includes name, args, start line, signature, scope, kind (getter/setter/async), and end line.
"""
method_blocks = []
# Now captures: [optional scope] [optional get/set/async] methodName(args): type {
method_pattern = re.compile(
r'^\s*(public|private|protected)?\s*(static\s*)?(get|set|async\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\).*{'
)
inside = False
brace_stack = []
current = None
for i, line in enumerate(lines):
if not inside:
m = method_pattern.match(line)
if m:
scope = m.group(1) or 'public'
kind = (m.group(3) or '').strip()
method_name = m.group(4)
method_args = m.group(5).strip()
sig = f"{scope} {kind} {method_name}({method_args})".replace(' ', ' ').strip()
if (
'.then' in sig or
'this.' in sig or
'if (' in sig or
'error:' in sig or
'=> {' in sig
):
continue
current = {
'name': method_name,
'args': method_args,
'start': i + 1,
'signature': sig,
'scope': scope,
'kind': kind if kind else None # getter/setter/async
}
if '{' in line:
inside = True
brace_stack = [1]
else:
opens = line.count('{')
closes = line.count('}')
brace_stack[-1] += opens
brace_stack[-1] -= closes
if brace_stack[-1] <= 0:
inside = False
current['end'] = i + 1
method_blocks.append(current)
current = None
brace_stack = []
return method_blocks
# ---------------------------------------------------------
# Step 4: Extract Method Calls (TS & HTML), Attach JSDoc, and Context
# ---------------------------------------------------------
def extract_methods_and_calls(file_path):
"""
Extract all declared methods and actual call sites, including those in .html templates.
Also captures jsdoc, enclosing if/switch, and parent method nesting for TS calls.
"""
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
method_blocks = extract_method_blocks(lines)
methods = {}
html_path = file_path[:-3] + '.html'
for mb in method_blocks:
jsdoc = extract_jsdoc(lines, mb['start'])
methods[mb['name']] = {
'args': mb['args'],
'decl_line': mb['start'],
'ts_calls': [],
'html_calls': [],
'jsdoc': jsdoc,
'scope': mb.get('scope', 'public'),
'kind': mb.get('kind', None)
}
# --- TS calls ---
for i, line in enumerate(lines):
for method_name in methods:
if re.search(rf'\b{re.escape(method_name)}\s*\(', line) and (i + 1) != methods[method_name]['decl_line']:
parent = find_parent_method_block(method_blocks, i + 1)
cond = find_enclosing_condition(lines, i + 1, parent[0]) if parent else None
methods[method_name]['ts_calls'].append({
'line_num': i + 1,
'line': line.strip(),
'condition': cond,
'parent_method': parent
})
# --- HTML calls ---
methods = extract_html_method_calls(html_path, methods)
return methods
# ---------------------------------------------------------
# Step 5: Extract HTML Method Calls
# ---------------------------------------------------------
def extract_html_method_calls(html_path, methods):
"""
For each method, scan the HTML file for usage like (event)="methodName(...)" or just methodName(...).
Adds found usages to each method's 'html_calls' list.
"""
if not os.path.isfile(html_path):
return methods
try:
with open(html_path, 'r', encoding='utf-8') as f:
html_lines = f.readlines()
for method_name in methods:
methods[method_name]['html_calls'] = []
for i, line in enumerate(html_lines):
if len(line) < 6 or '(' not in line:
continue
if method_name + "(" in line:
methods[method_name]['html_calls'].append({
'line_num': i + 1,
'line': line.strip(),
})
except Exception as err:
print(err)
return methods
# ---------------------------------------------------------
# Step 6: Determine Method Enclosing Block
# ---------------------------------------------------------
def find_parent_method_block(method_blocks, call_line):
"""
From all known method blocks, find the method that contains the given call line.
Returns (start_line, signature) or None.
"""
candidates = [mb for mb in method_blocks if mb['start'] <= call_line <= mb.get('end', 10**6)]
if candidates:
chosen = max(candidates, key=lambda mb: mb['start'])
return (chosen['start'], chosen['signature'])
return None
# ---------------------------------------------------------
# Step 7: Print Results
# ---------------------------------------------------------
def print_results(file_path, methods):
"""
Nicely print parsed method metadata, JSDoc, and usage calls with context.
"""
print(f"\n--- Analyzing {file_path} ---\n")
if not methods:
print("No methods found.")
return
html_path = os.path.basename(file_path)[:-3] + '.html'
for method_name, data in methods.items():
decl_line = data['decl_line']
args = data['args']
scope_str = data.get('scope', 'public')
kind_str = data.get('kind')
scope_label = f" [{scope_str}{' '+kind_str if kind_str else ''}]"
html_calls = data['html_calls']
ts_calls = data['ts_calls']
print(f"\033[94mMethod:\033[0m {method_name}({args}){scope_label} \033[90m[L{decl_line}]\033[0m")
if data.get('jsdoc'):
print(f" \033[93mJS Doc:\033[0m\n{data['jsdoc']}")
if len(ts_calls) > 0:
print(f" \033[92mTS Calls:\033[0m")
for call in ts_calls:
print(f" \033[90mL{call['line_num']}:\033[0m {call['line']}")
if call['condition']:
print(f" └── Inside: {call['condition'][1]} \033[90m[L{call['condition'][0]}]\033[0m")
if call['parent_method']:
print(f" └── Parent Method: {call['parent_method'][1]} \033[90m[L{call['parent_method'][0]}]\033[0m")
if len(html_calls) > 0:
print(f" \033[95mHTML Calls ({html_path}):\033[0m")
for call in html_calls:
print(f" \033[90mL{call['line_num']}:\033[0m{call['line']}")
if len(ts_calls) < 1 and len(html_calls) < 1:
print(f" \033[91mNo calls found.\033[0m")
print('\n--- Finished --')
print('Component TS:', file_path)
print('Component HTML:', file_path[:-3] + '.html')
# ---------------------------------------------------------
# Step 8: CLI Entrypoint
# ---------------------------------------------------------
def main():
"""
Main CLI entrypoint: Parse arguments, search for Angular component files, extract and print method usage info.
"""
parser = argparse.ArgumentParser(description='Search Angular components and extract methods and their call sites.')
parser.add_argument('--match', required=True, help='Partial component selector to match')
args = parser.parse_args()
directory = 'frontend/src/app'
matching_files = find_component_files(directory, args.match)
if not matching_files:
print("No matching files found.")
return
for file in matching_files:
methods = extract_methods_and_calls(file)
print_results(file, methods)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment