C#空方法扫描脚本
This commit is contained in:
parent
9a290bbeda
commit
0af9dd21d7
318
find_cs_empty_methods.py
Normal file
318
find_cs_empty_methods.py
Normal file
@ -0,0 +1,318 @@
|
||||
# -*- 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()
|
||||
Loading…
Reference in New Issue
Block a user