diff --git a/find_cs_empty_methods.py b/find_cs_empty_methods.py new file mode 100644 index 0000000..c09bc62 --- /dev/null +++ b/find_cs_empty_methods.py @@ -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()