|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +EditorConfig Checker - A Python-based EditorConfig checker that doesn't require external dependencies. |
| 4 | +
|
| 5 | +This script provides basic EditorConfig checking functionality without needing to download |
| 6 | +binaries from GitHub or install Node.js packages, thus avoiding GitHub API rate limiting issues. |
| 7 | +
|
| 8 | +Usage: |
| 9 | + python editorconfig_check.py [--config CONFIG_FILE] [--ignore-defaults] [--format FORMAT] [FILES...] |
| 10 | +
|
| 11 | +Examples: |
| 12 | + python editorconfig_check.py ./ |
| 13 | + python editorconfig_check.py --config .editorconfig_checker.json ./ |
| 14 | + python editorconfig_check.py --ignore-defaults --format default ./ |
| 15 | +""" |
| 16 | + |
| 17 | +import argparse |
| 18 | +import configparser |
| 19 | +import json |
| 20 | +import os |
| 21 | +import re |
| 22 | +import sys |
| 23 | +from pathlib import Path |
| 24 | +from typing import Dict, List, Optional, Set, Tuple |
| 25 | + |
| 26 | + |
| 27 | +class EditorConfigChecker: |
| 28 | + """A basic EditorConfig checker implementation.""" |
| 29 | + |
| 30 | + def __init__(self, config_file: Optional[str] = None, ignore_defaults: bool = False): |
| 31 | + self.config_file = config_file |
| 32 | + self.ignore_defaults = ignore_defaults |
| 33 | + self.errors: List[Tuple[str, int, str]] = [] |
| 34 | + self.config_data = self._load_config() |
| 35 | + |
| 36 | + def _load_config(self) -> Dict: |
| 37 | + """Load the checker configuration file.""" |
| 38 | + config = { |
| 39 | + "Exclude": [], |
| 40 | + "Disable": { |
| 41 | + "EndOfLine": False, |
| 42 | + "Indentation": False, |
| 43 | + "IndentSize": False, |
| 44 | + "TabWidth": False, |
| 45 | + "TrimTrailingWhitespace": False, |
| 46 | + "InsertFinalNewline": False, |
| 47 | + "MaxLineLength": False |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + if self.config_file and os.path.exists(self.config_file): |
| 52 | + try: |
| 53 | + with open(self.config_file, 'r') as f: |
| 54 | + file_config = json.load(f) |
| 55 | + config.update(file_config) |
| 56 | + except (json.JSONDecodeError, IOError) as e: |
| 57 | + print(f"Warning: Could not load config file {self.config_file}: {e}", file=sys.stderr) |
| 58 | + |
| 59 | + return config |
| 60 | + |
| 61 | + def _find_editorconfig(self, file_path: str) -> Optional[configparser.ConfigParser]: |
| 62 | + """Find and parse the nearest .editorconfig file.""" |
| 63 | + path = Path(file_path).parent.absolute() |
| 64 | + |
| 65 | + while True: |
| 66 | + editorconfig_path = path / '.editorconfig' |
| 67 | + if editorconfig_path.exists(): |
| 68 | + config = configparser.ConfigParser() |
| 69 | + try: |
| 70 | + config.read(editorconfig_path) |
| 71 | + return config |
| 72 | + except configparser.Error: |
| 73 | + pass |
| 74 | + |
| 75 | + if path.parent == path or (editorconfig_path.exists() and |
| 76 | + config.has_option('*', 'root') and |
| 77 | + config.getboolean('*', 'root')): |
| 78 | + break |
| 79 | + path = path.parent |
| 80 | + |
| 81 | + return None |
| 82 | + |
| 83 | + def _get_file_settings(self, file_path: str) -> Dict[str, str]: |
| 84 | + """Get EditorConfig settings for a specific file.""" |
| 85 | + config = self._find_editorconfig(file_path) |
| 86 | + if not config: |
| 87 | + return {} |
| 88 | + |
| 89 | + settings = {} |
| 90 | + file_path_posix = Path(file_path).as_posix() |
| 91 | + |
| 92 | + # Apply settings from matching sections |
| 93 | + for section_name in config.sections(): |
| 94 | + if section_name == 'DEFAULT': |
| 95 | + continue |
| 96 | + |
| 97 | + # Simple glob matching - this is a basic implementation |
| 98 | + if self._matches_glob(file_path_posix, section_name): |
| 99 | + for key, value in config[section_name].items(): |
| 100 | + settings[key] = value |
| 101 | + |
| 102 | + return settings |
| 103 | + |
| 104 | + def _matches_glob(self, file_path: str, pattern: str) -> bool: |
| 105 | + """Basic glob pattern matching for EditorConfig patterns.""" |
| 106 | + # Convert EditorConfig glob to regex |
| 107 | + # This is a simplified implementation |
| 108 | + if pattern == '*': |
| 109 | + return True |
| 110 | + |
| 111 | + # Convert common patterns |
| 112 | + regex_pattern = pattern |
| 113 | + regex_pattern = regex_pattern.replace('.', r'\.') |
| 114 | + regex_pattern = regex_pattern.replace('*', '.*') |
| 115 | + regex_pattern = regex_pattern.replace('?', '.') |
| 116 | + |
| 117 | + try: |
| 118 | + return re.match(regex_pattern + '$', os.path.basename(file_path)) is not None |
| 119 | + except re.error: |
| 120 | + return False |
| 121 | + |
| 122 | + def _is_excluded(self, file_path: str) -> bool: |
| 123 | + """Check if a file should be excluded from checking.""" |
| 124 | + excludes = self.config_data.get("Exclude", []) |
| 125 | + for exclude_pattern in excludes: |
| 126 | + if self._matches_glob(file_path, exclude_pattern): |
| 127 | + return True |
| 128 | + return False |
| 129 | + |
| 130 | + def _check_file_content(self, file_path: str, content: str, settings: Dict[str, str]): |
| 131 | + """Check file content against EditorConfig settings.""" |
| 132 | + lines = content.splitlines(keepends=True) |
| 133 | + disabled = self.config_data.get("Disable", {}) |
| 134 | + |
| 135 | + for line_num, line in enumerate(lines, 1): |
| 136 | + # Check trailing whitespace |
| 137 | + if not disabled.get("TrimTrailingWhitespace", False): |
| 138 | + if settings.get("trim_trailing_whitespace", "").lower() == "true": |
| 139 | + if line.rstrip('\r\n') != line.rstrip(): |
| 140 | + self.errors.append((file_path, line_num, "Line has trailing whitespace")) |
| 141 | + |
| 142 | + # Check line ending |
| 143 | + if not disabled.get("EndOfLine", False): |
| 144 | + end_of_line = settings.get("end_of_line", "") |
| 145 | + if end_of_line and line.endswith('\n'): |
| 146 | + if end_of_line == "crlf" and not line.endswith('\r\n'): |
| 147 | + self.errors.append((file_path, line_num, "Expected CRLF line ending")) |
| 148 | + elif end_of_line == "lf" and line.endswith('\r\n'): |
| 149 | + self.errors.append((file_path, line_num, "Expected LF line ending")) |
| 150 | + |
| 151 | + # Check indentation |
| 152 | + if not disabled.get("Indentation", False): |
| 153 | + indent_style = settings.get("indent_style", "") |
| 154 | + if indent_style: |
| 155 | + leading_whitespace = line[:len(line) - len(line.lstrip())] |
| 156 | + if indent_style == "space" and '\t' in leading_whitespace: |
| 157 | + self.errors.append((file_path, line_num, "Indentation should use spaces, not tabs")) |
| 158 | + elif indent_style == "tab" and ' ' in leading_whitespace: |
| 159 | + self.errors.append((file_path, line_num, "Indentation should use tabs, not spaces")) |
| 160 | + |
| 161 | + # Check max line length |
| 162 | + if not disabled.get("MaxLineLength", False): |
| 163 | + max_line_length = settings.get("max_line_length", "") |
| 164 | + if max_line_length and max_line_length.isdigit(): |
| 165 | + if len(line.rstrip('\r\n')) > int(max_line_length): |
| 166 | + self.errors.append((file_path, line_num, f"Line exceeds maximum length of {max_line_length}")) |
| 167 | + |
| 168 | + # Check final newline |
| 169 | + if not disabled.get("InsertFinalNewline", False): |
| 170 | + if settings.get("insert_final_newline", "").lower() == "true": |
| 171 | + if content and not content.endswith('\n'): |
| 172 | + self.errors.append((file_path, len(lines), "File should end with a newline")) |
| 173 | + |
| 174 | + def check_file(self, file_path: str) -> bool: |
| 175 | + """Check a single file against EditorConfig rules.""" |
| 176 | + if self._is_excluded(file_path): |
| 177 | + return True |
| 178 | + |
| 179 | + if not os.path.isfile(file_path): |
| 180 | + return True |
| 181 | + |
| 182 | + try: |
| 183 | + # Try to read as text file |
| 184 | + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: |
| 185 | + content = f.read() |
| 186 | + |
| 187 | + settings = self._get_file_settings(file_path) |
| 188 | + self._check_file_content(file_path, content, settings) |
| 189 | + |
| 190 | + except (IOError, UnicodeDecodeError): |
| 191 | + # Skip binary files or files that can't be read |
| 192 | + pass |
| 193 | + |
| 194 | + return len(self.errors) == 0 |
| 195 | + |
| 196 | + def check_directory(self, directory: str) -> bool: |
| 197 | + """Recursively check all files in a directory.""" |
| 198 | + success = True |
| 199 | + |
| 200 | + for root, dirs, files in os.walk(directory): |
| 201 | + # Skip hidden directories and common exclude patterns |
| 202 | + dirs[:] = [d for d in dirs if not d.startswith('.') and |
| 203 | + d not in ['node_modules', '__pycache__', '.git']] |
| 204 | + |
| 205 | + for file in files: |
| 206 | + if file.startswith('.'): |
| 207 | + continue |
| 208 | + |
| 209 | + file_path = os.path.join(root, file) |
| 210 | + if not self.check_file(file_path): |
| 211 | + success = False |
| 212 | + |
| 213 | + return success |
| 214 | + |
| 215 | + def get_errors(self) -> List[Tuple[str, int, str]]: |
| 216 | + """Get all collected errors.""" |
| 217 | + return self.errors |
| 218 | + |
| 219 | + def print_errors(self, format_type: str = "default"): |
| 220 | + """Print errors in the specified format.""" |
| 221 | + if not self.errors: |
| 222 | + return |
| 223 | + |
| 224 | + for file_path, line_num, message in self.errors: |
| 225 | + if format_type == "gcc": |
| 226 | + print(f"{file_path}:{line_num}: error: {message}") |
| 227 | + else: |
| 228 | + print(f"{file_path}:{line_num}: {message}") |
| 229 | + |
| 230 | + |
| 231 | +def main(): |
| 232 | + """Main entry point for the EditorConfig checker.""" |
| 233 | + parser = argparse.ArgumentParser(description="Check files against EditorConfig rules") |
| 234 | + parser.add_argument("paths", nargs="*", default=["."], help="Files or directories to check") |
| 235 | + parser.add_argument("--config", help="Path to the configuration file") |
| 236 | + parser.add_argument("--ignore-defaults", action="store_true", help="Ignore default settings") |
| 237 | + parser.add_argument("--format", choices=["default", "gcc"], default="default", help="Output format") |
| 238 | + |
| 239 | + args = parser.parse_args() |
| 240 | + |
| 241 | + checker = EditorConfigChecker(args.config, args.ignore_defaults) |
| 242 | + success = True |
| 243 | + |
| 244 | + for path in args.paths: |
| 245 | + if os.path.isfile(path): |
| 246 | + if not checker.check_file(path): |
| 247 | + success = False |
| 248 | + elif os.path.isdir(path): |
| 249 | + if not checker.check_directory(path): |
| 250 | + success = False |
| 251 | + else: |
| 252 | + print(f"Error: Path not found: {path}", file=sys.stderr) |
| 253 | + success = False |
| 254 | + |
| 255 | + checker.print_errors(args.format) |
| 256 | + |
| 257 | + if not success: |
| 258 | + sys.exit(1) |
| 259 | + |
| 260 | + |
| 261 | +if __name__ == "__main__": |
| 262 | + main() |
0 commit comments