PythonScript/find_cs_empty_methods.py
2026-06-10 18:02:56 +08:00

319 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""
Scan all .cs files to find empty methods (methods with no actual implementation).
Usage:
python find_empty_methods.py [target_directory]
Empty method definitions:
- Method body is completely empty: void Foo() { }
- Body contains only whitespace/newlines
- Body contains only comments (no actual code)
- Body contains only return default; / return null; / return 0; etc.
- Body contains only throw new NotImplementedException();
- Body contains only throw new NotSupportedException();
Output format:
[file_path:line_number] return_type method_name(params) -> reason
"""
import os
import re
import sys
from pathlib import Path
from typing import List, Tuple, Optional
# Common encodings to try (in priority order)
COMMON_ENCODINGS = [
'utf-8',
'utf-8-sig',
'utf-16-le',
'utf-16-be',
'gbk',
'gb2312',
'gb18030',
'utf-16',
'shift_jis',
'big5',
'euc-kr',
'ascii',
'latin-1',
]
def detect_encoding(file_path: Path) -> Optional[str]:
"""
Detect file encoding.
Priority: BOM -> chardet library -> brute-force try common encodings.
"""
try:
raw_data = file_path.read_bytes()
except Exception:
return None
if not raw_data:
return None
# 1. Check BOM header
if raw_data[:3] == b'\xef\xbb\xbf':
return 'utf-8-sig'
if raw_data[:2] == b'\xff\xfe':
return 'utf-16-le'
if raw_data[:2] == b'\xfe\xff':
return 'utf-16-be'
# 2. Try using chardet (if installed)
try:
import chardet
result = chardet.detect(raw_data)
encoding = result.get('encoding')
confidence = result.get('confidence', 0)
if encoding and confidence > 0.7:
encoding = encoding.lower().replace('-', '')
mapping = {
'utf8': 'utf-8',
'utf8sig': 'utf-8-sig',
'utf16': 'utf-16',
'utf16le': 'utf-16-le',
'utf16be': 'utf-16-be',
'gb2312': 'gbk',
'ascii': 'utf-8',
}
return mapping.get(encoding, encoding)
except ImportError:
pass
# 3. Fallback: try each common encoding
for encoding in COMMON_ENCODINGS:
try:
raw_data.decode(encoding)
return encoding
except (UnicodeDecodeError, LookupError):
continue
return None
def read_file_with_encoding(file_path: Path) -> Tuple[Optional[str], Optional[str]]:
"""
Detect encoding and read file content.
Returns (content, encoding_used) or (None, None) on failure.
"""
encoding = detect_encoding(file_path)
if encoding is None:
encoding = sys.getdefaultencoding()
try:
content = file_path.read_text(encoding=encoding)
return content, encoding
except Exception:
for enc in COMMON_ENCODINGS:
try:
content = file_path.read_text(encoding=enc)
return content, enc
except Exception:
continue
return None, None
# Compiled regex for matching C# method definitions
# Captures: modifiers+return_type, method_name, parameter_list, method_body
METHOD_PATTERN = re.compile(
r'(?:(?:\w+\s+)*)' # modifiers (public, static, async, override, virtual...)
r'(\w+(?:[\[\]\s<>,\.]+)?)' # return type (including generics, arrays)
r'\s+' # whitespace separator
r'(\w+)\s*' # method name
r'\(([^)]*)\)\s*' # parameter list
r'(?::\s*\w+(?:<[^>]+>)?)?' # optional return type annotation (C# 7+)
r'\s*' # whitespace
r'\{([^}]*)' # method body opening { to first }
r'\}', # method body closing }
re.MULTILINE
)
# Match single-line cases: void Foo() { }
SINGLE_LINE_METHOD_PATTERN = re.compile(
r'((?:\w+\s+)*)' # modifier group
r'(\w+(?:[\[\]\s<>,\."]+)?)' # return type
r'\s+'
r'(\w+)\s*'
r'\(([^)]*)\)\s*'
r'(?::\s*\w+(?:<[^>]+>)?)?\s*'
r'\{([^}]*)\}', # method body (may be on same line)
re.MULTILINE
)
# Keywords that should not be treated as method names
NON_METHOD_KEYWORDS = frozenset({
'class', 'struct', 'interface', 'enum', 'if', 'else',
'for', 'foreach', 'while', 'do', 'switch', 'using',
'namespace', 'try', 'catch', 'finally', 'lock',
'where', 'select', 'from', 'async', 'await', 'get', 'set',
'init', 'add', 'remove', 'value', 'this', 'new',
'equals', 'orderby', 'groupby',
})
def strip_comments(code: str) -> str:
"""Remove C# single-line comments // and multi-line comments /* */"""
# Remove multi-line comments first
code = re.sub(r'/\*.*?\*/', '', code, flags=re.DOTALL)
# Then remove single-line comments
code = re.sub(r'//.*?$', '', code, flags=re.MULTILINE)
return code
def is_method_empty(method_body: str) -> Tuple[bool, Optional[str]]:
"""
Check if method body is effectively empty.
Returns (is_empty, reason_description).
"""
body_stripped = method_body.strip()
if not body_stripped:
return True, "body is completely empty"
# Contains only comments
body_no_comments = strip_comments(body_stripped).strip()
if not body_no_comments:
return True, "body contains only comments"
# Common "empty implementation" patterns
empty_patterns = [
(r'^\s*return\s+default\s*;\s*$', "return default"),
(r'^\s*return\s+null\s*;\s*$', "return null"),
(r'^\s*return\s+0\s*;\s*$', "return 0"),
(r'^\s*return\s+false\s*;\s*$', "return false"),
(r'^\s*return\s+true\s*;\s*$', "return true"),
(r'^\s*return\s+""\s*;\s*$', 'return ""'),
(r'^\s*return\s+string\.Empty\s*;\s*$', "return string.Empty"),
(r'^\s*throw\s+new\s+NotImplementedException\s*\(\s*\)\s*;\s*$', "throw NotImplementedException"),
(r'^\s*throw\s+new\s+NotSupportedException\s*\(\s*\)\s*;\s*$', "throw NotSupportedException"),
(r'^\s*;\s*$', "empty statement ;"),
]
for pattern, reason in empty_patterns:
if re.match(pattern, body_no_comments, re.DOTALL):
return True, reason
return False, None
def find_methods_in_file(file_path: Path) -> List[Tuple[int, str, str, str, str]]:
"""
Parse a single .cs file and return all methods found.
Returns list: [(line_number, return_type, method_name, params, body)]
"""
content, encoding = read_file_with_encoding(file_path)
if content is None:
print(f" [WARNING] Could not read file: {file_path} (encoding detection failed)")
return []
methods = []
# Try multi-line matching first
for match in METHOD_PATTERN.finditer(content):
ret_type = match.group(1).strip()
method_name = match.group(2).strip()
params = match.group(3).strip()
body = match.group(4).strip() if match.group(4) else ''
if not ret_type or not method_name:
continue
if method_name in NON_METHOD_KEYWORDS:
continue
# Exclude lambda arrow syntax false positives
if '=>' in content[match.start():match.start()+len(match.group(0))]:
continue
line_num = content[:match.start()].count('\n') + 1
methods.append((line_num, ret_type, method_name, params, body))
# Supplement with single-line matching (for cases not caught above)
for match in SINGLE_LINE_METHOD_PATTERN.finditer(content):
ret_type = match.group(2).strip()
method_name = match.group(3).strip()
params = match.group(4).strip()
body = match.group(5).strip() if match.group(5) else ''
if not ret_type or not method_name:
continue
if method_name in NON_METHOD_KEYWORDS:
continue
if '=>' in content[match.start():match.start()+len(match.group(0))]:
continue
# Deduplicate against multi-line matches
line_num = content[:match.start()].count('\n') + 1
existing = any(m[2] == method_name and m[0] == line_num for m in methods)
if existing:
continue
methods.append((line_num, ret_type, method_name, params, body))
return methods
def scan_directory(target_dir: Path) -> None:
"""Recursively scan all .cs files in the directory for empty methods."""
if not target_dir.exists():
print(f"Error: directory not found - {target_dir}")
sys.exit(1)
cs_files = list(target_dir.rglob('*.cs'))
# Exclude common auto-generated directories
exclude_dirs = {'bin', 'obj', 'node_modules', 'packages', '.git', '.vs'}
cs_files = [f for f in cs_files
if not any(part in exclude_dirs for part in f.parts)]
if not cs_files:
print(f"No .cs files found under {target_dir}.")
return
empty_methods = []
total_methods = 0
print(f"Scanning {len(cs_files)} .cs files...\n")
for cs_file in cs_files:
methods = find_methods_in_file(cs_file)
total_methods += len(methods)
for line_num, ret_type, method_name, params, body in methods:
is_empty, reason = is_method_empty(body)
if is_empty:
empty_methods.append((cs_file, line_num, ret_type, method_name, params, reason))
# Output results
if empty_methods:
print(f"Scanned {total_methods} methods, found {len(empty_methods)} empty method(s):\n")
print(f"{'File':<60} {'Line':<6} {'Method Signature':<50} {'Reason'}")
print("-" * 140)
# Sort by file path then line number
empty_methods.sort(key=lambda x: (str(x[0]), x[1]))
for file_path, line_num, ret_type, method_name, params, reason in empty_methods:
rel_path = file_path.relative_to(target_dir) if file_path.is_relative_to(target_dir) else file_path
signature = f"{ret_type} {method_name}({params})"
if len(signature) > 47:
signature = signature[:44] + "..."
print(f"{str(rel_path):<60} {line_num:<6} {signature:<50} {reason}")
else:
print(f"Scanned {total_methods} methods, no empty methods found.")
def main():
if len(sys.argv) > 1:
target_dir = Path(sys.argv[1]).resolve()
else:
target_dir = Path.cwd().resolve()
print(f"Target directory: {target_dir}\n")
scan_directory(target_dir)
if __name__ == '__main__':
main()