Coverage for src/pylint_sort_functions/auto_fix.py: 100%

393 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-08-12 16:06 +0200

1"""Auto-fix functionality for sorting functions and methods.""" 

2# pylint: disable=too-many-lines 

3 

4import shutil 

5from dataclasses import dataclass 

6from pathlib import Path 

7from typing import Dict, List, Optional, Tuple 

8 

9import astroid # type: ignore[import-untyped] 

10from astroid import nodes 

11 

12from pylint_sort_functions import utils 

13from pylint_sort_functions.utils.categorization import CategoryConfig, categorize_method 

14 

15 

16@dataclass 

17class FunctionSpan: 

18 """Represents a function with its complete text span in the source file.""" 

19 

20 node: nodes.FunctionDef 

21 start_line: int 

22 end_line: int 

23 text: str # Complete source text from start_line to end_line (inclusive) 

24 name: str 

25 

26 

27@dataclass 

28class AutoFixConfig: # pylint: disable=too-many-instance-attributes 

29 """Configuration for the automatic function sorting tool. 

30 

31 Controls how the auto-fix feature behaves when reordering functions 

32 and methods in Python source files. 

33 

34 Note: Comment preservation is always enabled as it's essential for 

35 maintaining code intent and documentation during reorganization. 

36 """ 

37 

38 dry_run: bool = False # Show what would be changed without modifying files 

39 backup: bool = True # Create .bak files before making changes 

40 ignore_decorators: Optional[List[str]] = ( 

41 None # Decorator patterns to exclude from sorting 

42 ) 

43 

44 # Section header configuration 

45 add_section_headers: bool = False # Add section headers during sorting 

46 public_header: str = "# Public functions" # Header text for public functions 

47 private_header: str = "# Private functions" # Header text for private functions 

48 public_method_header: str = "# Public methods" # Header text for public methods 

49 private_method_header: str = "# Private methods" # Header text for private methods 

50 

51 # Section header detection configuration 

52 additional_section_patterns: Optional[List[str]] = ( 

53 None # Extra patterns to detect as headers 

54 ) 

55 section_header_case_sensitive: bool = False # Case sensitivity for header detection 

56 

57 # Multi-category system integration 

58 category_config: Optional[CategoryConfig] = None # Use new categorization system 

59 enable_multi_category_headers: bool = False # Enable multi-category section headers 

60 

61 

62# Note: This class intentionally has only one public method as it encapsulates 

63# the configuration state and provides a clean interface for file processing. 

64class FunctionSorter: # pylint: disable=too-many-public-methods,too-few-public-methods 

65 """Main class for auto-fixing function sorting. 

66 

67 This class provides the core functionality for automatically reordering 

68 functions and methods in Python source files to comply with sorting rules. 

69 

70 Supports both traditional binary public/private sorting and the new 

71 multi-category system with flexible section headers. 

72 

73 Basic Usage: 

74 Used by the CLI tool (cli.py) and can be used programmatically: 

75 

76 config = AutoFixConfig(dry_run=True, backup=True) 

77 sorter = FunctionSorter(config) 

78 was_modified = sorter.sort_file(Path("my_file.py")) 

79 

80 Multi-Category Usage: 

81 Enhanced functionality with custom categories and section headers: 

82 

83 from pylint_sort_functions.utils import CategoryConfig, MethodCategory 

84 

85 # Define custom categories 

86 category_config = CategoryConfig( 

87 enable_categories=True, 

88 categories=[ 

89 MethodCategory(name="test_methods", patterns=["test_*"], 

90 section_header="# Test methods"), 

91 MethodCategory(name="properties", decorators=["@property"], 

92 section_header="# Properties"), 

93 MethodCategory(name="public_methods", patterns=["*"], 

94 section_header="# Public methods"), 

95 MethodCategory(name="private_methods", patterns=["_*"], 

96 section_header="# Private methods"), 

97 ] 

98 ) 

99 

100 # Configure auto-fix with multi-category support 

101 config = AutoFixConfig( 

102 add_section_headers=True, 

103 enable_multi_category_headers=True, 

104 category_config=category_config 

105 ) 

106 

107 sorter = FunctionSorter(config) 

108 was_modified = sorter.sort_file(Path("my_file.py")) 

109 """ 

110 

111 # Public methods 

112 

113 def __init__(self, config: AutoFixConfig): 

114 """Initialize the function sorter. 

115 

116 :param config: Configuration for auto-fix behavior 

117 :type config: AutoFixConfig 

118 """ 

119 self.config = config 

120 if self.config.ignore_decorators is None: 

121 self.config.ignore_decorators = [] 

122 

123 def sort_file(self, file_path: Path) -> bool: 

124 """Auto-sort functions in a Python file. 

125 

126 :param file_path: Path to the Python file to sort 

127 :type file_path: Path 

128 :returns: True if file was modified, False otherwise 

129 :rtype: bool 

130 """ 

131 try: 

132 # Read the original file 

133 original_content = file_path.read_text(encoding="utf-8") 

134 

135 # Check if file needs sorting 

136 if not self._file_needs_sorting(original_content): 

137 return False 

138 

139 # Extract and sort functions 

140 new_content = self._sort_functions_in_content(original_content) 

141 

142 if new_content == original_content: # pragma: no cover 

143 return False 

144 

145 # CRITICAL FIX: Validate syntax after transformation 

146 validated_content = self._validate_syntax_and_rollback( 

147 file_path, original_content, new_content 

148 ) 

149 

150 # If validation rolled back to original, no changes were made 

151 if validated_content == original_content: 

152 return False 

153 

154 if self.config.dry_run: 

155 print(f"Would modify: {file_path}") 

156 return True 

157 

158 # Create backup if requested 

159 if self.config.backup: 

160 backup_path = file_path.with_suffix(f"{file_path.suffix}.bak") 

161 shutil.copy2(file_path, backup_path) 

162 

163 # Write the validated sorted content 

164 file_path.write_text(validated_content, encoding="utf-8") 

165 return True 

166 

167 # Broad exception catch ensures tool never crashes when modifying user files 

168 except ( 

169 Exception 

170 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught 

171 print(f"Error processing {file_path}: {e}") 

172 return False 

173 

174 # Private methods 

175 

176 def _add_multi_category_section_headers_to_functions( 

177 self, sorted_spans: List[FunctionSpan], is_methods: bool = False 

178 ) -> List[str]: 

179 """Add multi-category section headers to sorted function spans. 

180 

181 This enhanced version supports the new categorization system with multiple 

182 categories beyond just public/private. Each category gets its own section 

183 header based on the CategoryConfig. 

184 

185 :param sorted_spans: Function spans in sorted order by category 

186 :type sorted_spans: List[FunctionSpan] 

187 :param is_methods: True if these are class methods, False for module functions 

188 :type is_methods: bool 

189 :returns: List of text lines with category headers and functions 

190 :rtype: List[str] 

191 """ 

192 if ( 

193 not self.config.enable_multi_category_headers 

194 or not self.config.category_config 

195 ): 

196 # Fall back to original binary public/private headers 

197 return self._add_section_headers_to_functions(sorted_spans, is_methods) 

198 

199 if not self.config.add_section_headers: 

200 # If section headers are disabled, just return function texts 

201 result = [] 

202 for i, span in enumerate(sorted_spans): 

203 result.append(span.text) 

204 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"): 

205 if not span.text.endswith("\n"): 

206 result.append("\n") 

207 result.append("\n") 

208 return result 

209 

210 result_lines: list[str] = [] 

211 current_category = None 

212 

213 for span in sorted_spans: 

214 # Determine the category for this function/method 

215 category = categorize_method(span.node, self.config.category_config) 

216 

217 # Add section header if we're entering a new category 

218 if current_category != category: 

219 # Add blank line before section header (except at the very beginning) 

220 if result_lines: 

221 result_lines.append("\n") 

222 

223 # Find the category definition to get section header text 

224 category_def = None 

225 for cat in self.config.category_config.categories: 

226 if cat.name == category: 

227 category_def = cat 

228 break 

229 

230 # Add section header if category has one defined 

231 if category_def and category_def.section_header: 

232 result_lines.append(f"{category_def.section_header}\n\n") 

233 else: 

234 # Fallback to generic header based on category name 

235 header_text = category.replace("_", " ").title() 

236 result_lines.append(f"# {header_text}\n\n") 

237 

238 current_category = category 

239 

240 # Add the function text 

241 result_lines.append(span.text) 

242 

243 return result_lines 

244 

245 def _add_section_headers_to_functions( # pylint: disable=too-many-branches 

246 self, sorted_spans: List[FunctionSpan], is_methods: bool = False 

247 ) -> List[str]: 

248 """Add section headers to sorted function spans. 

249 

250 Creates a list of lines that includes both section headers and function text, 

251 organized with public functions first, then private functions. 

252 

253 :param sorted_spans: Function spans in sorted order (public first, then private) 

254 :type sorted_spans: List[FunctionSpan] 

255 :param is_methods: True if these are class methods, False for module functions 

256 :type is_methods: bool 

257 :returns: List of text lines with headers and functions 

258 :rtype: List[str] 

259 """ 

260 if not self.config.add_section_headers: 

261 # If section headers are disabled, just return function texts with spacing 

262 result = [] 

263 for i, span in enumerate(sorted_spans): 

264 result.append(span.text) 

265 # Ensure proper spacing between functions if not already included 

266 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"): 

267 if not span.text.endswith("\n"): 

268 result.append("\n") 

269 result.append("\n") 

270 return result 

271 

272 if not self._has_mixed_visibility_functions(sorted_spans): 

273 # Only add headers when both public and private functions exist 

274 # Still ensure proper spacing between functions 

275 result = [] 

276 for i, span in enumerate(sorted_spans): 

277 result.append(span.text) 

278 # Ensure proper spacing between functions if not already included 

279 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"): 

280 if not span.text.endswith("\n"): 

281 result.append("\n") # pragma: no cover 

282 result.append("\n") 

283 return result 

284 

285 result_lines: list[str] = [] 

286 current_visibility = None # Track whether we're in public or private section 

287 

288 # Get appropriate header texts based on function type 

289 if is_methods: 

290 public_header = self.config.public_method_header 

291 private_header = self.config.private_method_header 

292 else: 

293 public_header = self.config.public_header 

294 private_header = self.config.private_header 

295 

296 for i, span in enumerate(sorted_spans): 

297 is_private = utils.is_private_function(span.node) 

298 section_visibility = "private" if is_private else "public" 

299 

300 # Add section header if we're entering a new section 

301 if current_visibility != section_visibility: 

302 # Add blank line before section header (except at the very beginning) 

303 if result_lines: 

304 result_lines.append("\n") 

305 

306 # Add appropriate section header 

307 if section_visibility == "public": 

308 result_lines.append(f"{public_header}\n\n") 

309 else: 

310 result_lines.append(f"{private_header}\n\n") 

311 

312 current_visibility = section_visibility 

313 

314 # Add the function text 

315 result_lines.append(span.text) 

316 

317 return result_lines 

318 

319 def _extract_function_spans( 

320 self, functions: List[nodes.FunctionDef], lines: List[str], module: nodes.Module 

321 ) -> List[FunctionSpan]: 

322 """Extract function text spans from the source. 

323 

324 :param functions: List of function nodes 

325 :type functions: List[nodes.FunctionDef] 

326 :param lines: Source file lines 

327 :type lines: List[str] 

328 :param module: Module containing the functions 

329 :type module: nodes.Module 

330 :returns: List of function spans with text 

331 :rtype: List[FunctionSpan] 

332 """ 

333 spans = [] 

334 

335 # First pass: determine where each function (including comments) starts 

336 function_boundaries = [] 

337 for func in functions: 

338 start_line = func.lineno - 1 # Convert to 0-based indexing 

339 

340 # Include decorators in the span 

341 actual_start = start_line 

342 if hasattr(func, "decorators") and func.decorators: 

343 actual_start = func.decorators.lineno - 1 

344 

345 # Include comments above the function/decorators 

346 comment_start = self._find_comments_above_function(lines, actual_start) 

347 function_boundaries.append((func, comment_start)) 

348 

349 # Second pass: create spans using the boundaries 

350 for i, (func, comment_start) in enumerate(function_boundaries): 

351 # Find the end line (start of next function or end of file) 

352 if i + 1 < len(function_boundaries): 

353 # End where the next function's comments start 

354 end_line = function_boundaries[i + 1][1] 

355 else: 

356 # Last function, find the actual end using AST boundary detection 

357 end_line = self._find_function_end(lines, func, module) 

358 

359 # Extract the text including comments 

360 text = "".join(lines[comment_start:end_line]) 

361 

362 spans.append( 

363 FunctionSpan( 

364 node=func, 

365 start_line=comment_start, 

366 end_line=end_line, 

367 text=text, 

368 name=func.name, 

369 ) 

370 ) 

371 

372 return spans 

373 

374 def _extract_method_spans( 

375 self, 

376 methods: List[nodes.FunctionDef], 

377 lines: List[str], 

378 class_node: nodes.ClassDef, 

379 ) -> List[FunctionSpan]: 

380 """Extract method text spans from a class. 

381 

382 :param methods: List of method nodes from the class 

383 :type methods: List[nodes.FunctionDef] 

384 :param lines: Source file lines 

385 :type lines: List[str] 

386 :param class_node: The class containing these methods 

387 :type class_node: nodes.ClassDef 

388 :returns: List of method spans with text 

389 :rtype: List[FunctionSpan] 

390 """ 

391 spans = [] 

392 

393 # First pass: determine where each method (including comments) starts 

394 method_boundaries = [] 

395 for method in methods: 

396 start_line = method.lineno - 1 # Convert to 0-based indexing 

397 

398 # Include decorators in the span 

399 actual_start = start_line 

400 if hasattr(method, "decorators") and method.decorators: 

401 actual_start = method.decorators.lineno - 1 

402 

403 # Include comments above the method/decorators 

404 comment_start = self._find_comments_above_function(lines, actual_start) 

405 method_boundaries.append((method, comment_start)) 

406 

407 # Second pass: create spans using the boundaries 

408 for i, (method, comment_start) in enumerate(method_boundaries): 

409 # Find the end line (start of next method or end of class) 

410 if i + 1 < len(method_boundaries): 

411 # End where the next method's comments start 

412 end_line = method_boundaries[i + 1][1] 

413 else: 

414 # Last method in class, find end of class 

415 end_line = ( 

416 class_node.end_lineno 

417 if hasattr(class_node, "end_lineno") 

418 else len(lines) 

419 ) 

420 

421 # Extract the text including comments 

422 text = "".join(lines[comment_start:end_line]) 

423 

424 spans.append( 

425 FunctionSpan( 

426 node=method, 

427 start_line=comment_start, 

428 end_line=end_line, 

429 text=text, 

430 name=method.name, 

431 ) 

432 ) 

433 

434 return spans 

435 

436 def _file_needs_sorting(self, content: str) -> bool: 

437 """Check if a file needs function sorting. 

438 

439 :param content: File content as string 

440 :type content: str 

441 :returns: True if file needs sorting 

442 :rtype: bool 

443 """ 

444 try: # pylint: disable=too-many-nested-blocks 

445 # Parse with astroid for consistency with the checker 

446 module = astroid.parse(content) 

447 

448 # Check module-level functions 

449 functions = utils.get_functions_from_node(module) 

450 if functions: 

451 sorted_result = utils.are_functions_sorted_with_exclusions( 

452 functions, self.config.ignore_decorators 

453 ) 

454 if not sorted_result: 

455 return True 

456 # Even if sorted, check if we need to add section headers 

457 if self.config.add_section_headers: 

458 function_spans = self._extract_function_spans( 

459 functions, content.splitlines(), module 

460 ) 

461 if self._has_mixed_visibility_functions(function_spans): 

462 return True 

463 

464 # Check class methods 

465 for node in module.body: 

466 if isinstance(node, nodes.ClassDef): 

467 methods = utils.get_methods_from_class(node) 

468 if methods: 

469 if not utils.are_methods_sorted_with_exclusions( 

470 methods, self.config.ignore_decorators 

471 ): 

472 return True 

473 # Even if sorted, check if we need to add section headers 

474 if self.config.add_section_headers: 

475 method_spans = self._extract_method_spans( 

476 methods, content.splitlines(), node 

477 ) 

478 if self._has_mixed_visibility_functions(method_spans): 

479 return True 

480 

481 return False 

482 

483 except Exception: # pylint: disable=broad-exception-caught 

484 return False 

485 

486 def _find_comments_above_function( 

487 self, lines: List[str], function_start_line: int 

488 ) -> int: 

489 """Find comments that belong to a function and return the start line. 

490 

491 Scans backwards from the function definition to find associated comments. 

492 Excludes section header comments that should remain at section boundaries. 

493 

494 :param lines: Source file lines 

495 :type lines: List[str] 

496 :param function_start_line: The line where the function starts (0-based) 

497 :type function_start_line: int 

498 :returns: The line number where comments start, or function_start_line 

499 :rtype: int 

500 """ 

501 comment_start_line = function_start_line 

502 

503 # Scan backwards from the function start to find comments 

504 current_line = function_start_line - 1 

505 found_function_comments = [] 

506 

507 while current_line >= 0: 

508 line = lines[current_line].strip() 

509 

510 # If we find a comment line, this could be part of the function's comments 

511 if line.startswith("#"): 

512 # Check if this is a section header comment 

513 if self._is_section_header_comment(line): 

514 # Section headers should not move with functions 

515 # Stop including comments here 

516 break 

517 

518 # This is a function-specific comment 

519 found_function_comments.append(current_line) 

520 comment_start_line = current_line 

521 current_line -= 1 

522 continue 

523 

524 # If we find an empty line, continue scanning (comments might be separated) 

525 if line == "": 

526 current_line -= 1 

527 continue 

528 

529 # If we find any other content, stop scanning 

530 break 

531 

532 return comment_start_line 

533 

534 def _find_existing_section_headers(self, lines: List[str]) -> Dict[str, int]: 

535 """Find existing section headers in the source lines. 

536 

537 Returns a mapping of header types to their line numbers (0-based). 

538 This helps avoid duplicating headers during automatic insertion. 

539 

540 :param lines: Source file lines 

541 :type lines: List[str] 

542 :returns: Dictionary mapping header type to line number 

543 :rtype: dict[str, int] 

544 """ 

545 headers = {} 

546 

547 for i, line in enumerate(lines): 

548 stripped = line.strip() 

549 if not stripped.startswith("#"): 

550 continue 

551 

552 # Check if this matches any of our configured header patterns 

553 lower_line = stripped.lower() 

554 

555 # Check for public function headers 

556 if any( 

557 keyword in lower_line 

558 for keyword in ["public functions", "public function"] 

559 ): 

560 headers["public_functions"] = i 

561 # Check for private function headers 

562 elif any( 

563 keyword in lower_line 

564 for keyword in ["private functions", "private function"] 

565 ): 

566 headers["private_functions"] = i 

567 # Check for public method headers 

568 elif any( 

569 keyword in lower_line for keyword in ["public methods", "public method"] 

570 ): 

571 headers["public_methods"] = i 

572 # Check for private method headers 

573 elif any( 

574 keyword in lower_line 

575 for keyword in ["private methods", "private method"] 

576 ): 

577 headers["private_methods"] = i 

578 

579 return headers 

580 

581 def _find_function_end( 

582 self, lines: List[str], func: nodes.FunctionDef, module: nodes.Module 

583 ) -> int: 

584 """Find the actual end line of a function using AST-based boundary detection. 

585 

586 This method uses the module's AST to properly detect where module-level 

587 constructs (assignments, if statements, other functions/classes) begin, 

588 providing accurate boundaries without hardcoded pattern matching. 

589 

590 :param lines: Source file lines 

591 :type lines: List[str] 

592 :param func: Function node 

593 :type func: nodes.FunctionDef 

594 :param module: Module containing the function 

595 :type module: nodes.Module 

596 :returns: Line number where function ends (exclusive) 

597 :rtype: int 

598 """ 

599 func_end = func.end_lineno 

600 

601 # Find the next module-level construct after this function 

602 next_construct_line = None 

603 for node in module.body: 

604 if node.lineno > func_end: 

605 # This is the first construct after our function 

606 next_construct_line = node.lineno 

607 break 

608 

609 # If no module-level constructs follow, scan forward for trailing content 

610 if next_construct_line is None: 

611 # Look forward to include trailing comments/blank lines 

612 i = func_end 

613 while i < len(lines): 

614 line = lines[i].strip() 

615 # Include blank lines and comments that follow the function 

616 if line == "" or line.startswith("#"): 

617 i += 1 

618 continue 

619 # Stop at any non-empty, non-comment content 

620 break # pragma: no cover 

621 return int(i) 

622 

623 # We have a next construct - scan up to it, including preceding comments 

624 i = func_end 

625 while i < next_construct_line and i < len(lines): 

626 line = lines[i].strip() 

627 

628 # Include blank lines and comments 

629 if line == "" or line.startswith("#"): 

630 i += 1 

631 continue 

632 

633 # Stop if we hit content that belongs to the next construct 

634 # (e.g., assignment statements, other code) 

635 break 

636 

637 return int(i) 

638 

639 def _has_mixed_visibility_functions(self, spans: List[FunctionSpan]) -> bool: 

640 """Check if spans contain both public and private functions. 

641 

642 Only add section headers when there are both public and private functions, 

643 as per the requirement in issue #9. 

644 

645 :param spans: List of function spans to analyze 

646 :type spans: List[FunctionSpan] 

647 :returns: True if both public and private functions exist 

648 :rtype: bool 

649 """ 

650 has_public = False 

651 has_private = False 

652 

653 for span in spans: 

654 if utils.is_private_function(span.node): 

655 has_private = True 

656 else: 

657 has_public = True 

658 

659 # Early exit if we've found both types 

660 if has_public and has_private: 

661 return True 

662 

663 return False 

664 

665 def _is_section_header_comment(self, comment_line: str) -> bool: 

666 """Check if a comment line is a section header. 

667 

668 Section headers are comments that organize groups of functions/methods. 

669 This method uses configurable patterns and automatically includes 

670 the configured header texts from the AutoFixConfig. 

671 

672 :param comment_line: The comment line to check (already stripped) 

673 :type comment_line: str 

674 :returns: True if this is likely a section header comment 

675 :rtype: bool 

676 """ 

677 # Determine case sensitivity 

678 comparison_comment = ( 

679 comment_line 

680 if self.config.section_header_case_sensitive 

681 else comment_line.lower() 

682 ) 

683 

684 # Build list of all patterns to check 

685 patterns_to_check = [] 

686 

687 # 1. Always include configured header texts (what we insert, we detect) 

688 configured_headers = [ 

689 self.config.public_header, 

690 self.config.private_header, 

691 self.config.public_method_header, 

692 self.config.private_method_header, 

693 ] 

694 

695 # 1a. Add multi-category headers if enabled 

696 if self.config.enable_multi_category_headers and self.config.category_config: 

697 for category in self.config.category_config.categories: 

698 if category.section_header: 

699 configured_headers.append(category.section_header) 

700 

701 for header in configured_headers: 

702 pattern = ( 

703 header if self.config.section_header_case_sensitive else header.lower() 

704 ) 

705 patterns_to_check.append(pattern) 

706 

707 # 2. Add default fallback patterns for backward compatibility 

708 default_keywords = [ 

709 "public functions", 

710 "private functions", 

711 "public methods", 

712 "private methods", 

713 "helper functions", 

714 "utility functions", 

715 "api functions", 

716 "internal functions", 

717 "exports", 

718 "imports", 

719 ] 

720 

721 default_organizational = [ 

722 "# functions", 

723 "# methods", 

724 "## functions", 

725 "## methods", 

726 "--- functions", 

727 "--- methods", 

728 "=== functions", 

729 "=== methods", 

730 ] 

731 

732 # Apply case sensitivity to defaults 

733 if not self.config.section_header_case_sensitive: 

734 default_keywords = [kw.lower() for kw in default_keywords] 

735 default_organizational = [org.lower() for org in default_organizational] 

736 

737 patterns_to_check.extend(default_keywords) 

738 patterns_to_check.extend(default_organizational) 

739 

740 # 3. Add user-configured additional patterns 

741 if self.config.additional_section_patterns: 

742 additional_patterns = self.config.additional_section_patterns[:] 

743 if not self.config.section_header_case_sensitive: 

744 additional_patterns = [ 

745 pattern.lower() for pattern in additional_patterns 

746 ] 

747 patterns_to_check.extend(additional_patterns) 

748 

749 # Check if the comment matches any pattern 

750 for pattern in patterns_to_check: 

751 if pattern in comparison_comment: 

752 return True 

753 

754 return False 

755 

756 def _reconstruct_class_with_sorted_methods( 

757 self, 

758 content: str, 

759 original_spans: List[FunctionSpan], 

760 sorted_spans: List[FunctionSpan], 

761 ) -> str: 

762 """Reconstruct class content with sorted methods. 

763 

764 :param content: Original file content 

765 :type content: str 

766 :param original_spans: Original method spans in order of appearance 

767 :type original_spans: List[FunctionSpan] 

768 :param sorted_spans: Method spans in sorted order 

769 :type sorted_spans: List[FunctionSpan] 

770 :returns: Reconstructed content with sorted methods 

771 :rtype: str 

772 """ 

773 if not original_spans: # pragma: no cover 

774 return content 

775 

776 # Find the region that contains all methods within the class 

777 first_method_start = min(span.start_line for span in original_spans) 

778 last_method_end = max(span.end_line for span in original_spans) 

779 

780 # Split content into lines for manipulation 

781 content_lines = content.splitlines(keepends=True) 

782 

783 # Build new content 

784 new_lines = [] 

785 

786 # Add everything before the first method 

787 new_lines.extend(content_lines[:first_method_start]) 

788 

789 # Add sorted methods with optional section headers 

790 method_lines = self._add_multi_category_section_headers_to_functions( 

791 sorted_spans, is_methods=True 

792 ) 

793 new_lines.extend(method_lines) 

794 

795 # Add everything after the last method 

796 if last_method_end < len(content_lines): 

797 new_lines.extend(content_lines[last_method_end:]) 

798 

799 return "".join(new_lines) 

800 

801 def _reconstruct_content_with_sorted_functions( 

802 self, 

803 original_content: str, 

804 original_spans: List[FunctionSpan], 

805 sorted_spans: List[FunctionSpan], 

806 ) -> str: 

807 """Reconstruct file content with sorted functions. 

808 

809 Strategy: 

810 1. Preserve everything before the first function (imports, module docstrings) 

811 2. Replace the function block with sorted functions 

812 3. Preserve everything after the last function 

813 4. Add blank lines between functions if not already present 

814 

815 This approach ensures non-function content (imports, constants, etc.) 

816 remains in its original position while only reordering functions. 

817 

818 :param original_content: Original file content 

819 :type original_content: str 

820 :param original_spans: Original function spans in order of appearance 

821 :type original_spans: List[FunctionSpan] 

822 :param sorted_spans: Function spans in sorted order 

823 :type sorted_spans: List[FunctionSpan] 

824 :returns: Reconstructed content with sorted functions 

825 :rtype: str 

826 """ 

827 if not original_spans: # pragma: no cover 

828 return original_content 

829 

830 lines = original_content.splitlines(keepends=True) 

831 

832 # Find the region that contains all functions 

833 first_func_start = min(span.start_line for span in original_spans) 

834 last_func_end = max(span.end_line for span in original_spans) 

835 

836 # Build new content 

837 new_lines = [] 

838 

839 # Add everything before the first function 

840 new_lines.extend(lines[:first_func_start]) 

841 

842 # Add sorted functions with optional section headers 

843 function_lines = self._add_multi_category_section_headers_to_functions( 

844 sorted_spans, is_methods=False 

845 ) 

846 new_lines.extend(function_lines) 

847 

848 # Add everything after the last function 

849 if last_func_end < len(lines): 

850 new_lines.extend(lines[last_func_end:]) 

851 

852 return "".join(new_lines) 

853 

854 def _sort_class_methods( 

855 self, content: str, module: nodes.Module, lines: List[str] 

856 ) -> str: 

857 """Sort methods within classes using multi-class safe processing. 

858 

859 CRITICAL FIX for GitHub issue #25: This method now processes all classes 

860 in a single pass to prevent line number corruption when multiple classes 

861 are present. The original implementation processed classes sequentially, 

862 which caused subsequent classes to extract methods from wrong positions 

863 after the content string was modified by earlier classes. 

864 

865 :param content: File content 

866 :type content: str 

867 :param module: Parsed module 

868 :type module: nodes.Module 

869 :param lines: Content split into lines 

870 :type lines: List[str] 

871 :returns: Content with sorted class methods 

872 :rtype: str 

873 """ 

874 # CRITICAL FIX: Extract ALL class information upfront before ANY 

875 # modifications. This prevents line number corruption that causes 

876 # class definitions to be lost 

877 class_info = [] 

878 

879 for node in module.body: 

880 if isinstance(node, nodes.ClassDef): 

881 methods = utils.get_methods_from_class(node) 

882 if methods: 

883 methods_already_sorted = utils.are_methods_sorted_with_exclusions( 

884 methods, self.config.ignore_decorators 

885 ) 

886 # Process class if methods need sorting or adding section headers 

887 if not methods_already_sorted or self.config.add_section_headers: 

888 # Extract method spans NOW while lines array is still accurate 

889 method_spans = self._extract_method_spans(methods, lines, node) 

890 sorted_spans = self._sort_function_spans(method_spans) 

891 class_info.append((node, method_spans, sorted_spans)) 

892 

893 if not class_info: 

894 return content 

895 

896 # CRITICAL FIX: Process classes in REVERSE ORDER to preserve line numbers 

897 # When we modify a class at the end of the file first, the line numbers 

898 # for classes earlier in the file remain valid 

899 for _, original_spans, sorted_spans in reversed(class_info): 

900 # Reconstruct the class content with sorted methods 

901 content = self._reconstruct_class_with_sorted_methods( 

902 content, original_spans, sorted_spans 

903 ) 

904 

905 return content 

906 

907 def _sort_function_spans(self, spans: List[FunctionSpan]) -> List[FunctionSpan]: 

908 """Sort function spans according to the plugin rules. 

909 

910 :param spans: List of function spans to sort 

911 :type spans: List[FunctionSpan] 

912 :returns: Sorted list of function spans 

913 :rtype: List[FunctionSpan] 

914 """ 

915 # Use multi-category sorting if enabled 

916 if self.config.enable_multi_category_headers and self.config.category_config: 

917 return self._sort_function_spans_by_categories(spans) 

918 

919 # Fall back to original binary public/private sorting 

920 return self._sort_function_spans_binary(spans) 

921 

922 def _sort_function_spans_binary( 

923 self, spans: List[FunctionSpan] 

924 ) -> List[FunctionSpan]: 

925 """Sort function spans using the original binary public/private system. 

926 

927 :param spans: List of function spans to sort 

928 :type spans: List[FunctionSpan] 

929 :returns: Sorted list of function spans 

930 :rtype: List[FunctionSpan] 

931 """ 

932 # Separate functions based on exclusions and visibility 

933 excluded = [] 

934 sortable_public = [] 

935 sortable_private = [] 

936 

937 for span in spans: 

938 if utils.function_has_excluded_decorator( 

939 span.node, self.config.ignore_decorators or [] 

940 ): 

941 excluded.append(span) 

942 elif utils.is_private_function(span.node): 

943 sortable_private.append(span) 

944 else: 

945 sortable_public.append(span) 

946 

947 # Sort the sortable functions alphabetically 

948 sortable_public.sort(key=lambda s: s.name) 

949 sortable_private.sort(key=lambda s: s.name) 

950 

951 # Reconstruct the order: sortable public + sortable private + excluded 

952 return sortable_public + sortable_private + excluded 

953 

954 def _sort_function_spans_by_categories( 

955 self, spans: List[FunctionSpan] 

956 ) -> List[FunctionSpan]: 

957 """Sort function spans using the multi-category system. 

958 

959 :param spans: List of function spans to sort 

960 :type spans: List[FunctionSpan] 

961 :returns: Sorted list of function spans by categories 

962 :rtype: List[FunctionSpan] 

963 """ 

964 if not self.config.category_config: 

965 return spans 

966 

967 # Separate excluded functions 

968 excluded = [] 

969 sortable = [] 

970 

971 for span in spans: 

972 if utils.function_has_excluded_decorator( 

973 span.node, self.config.ignore_decorators or [] 

974 ): 

975 excluded.append(span) 

976 else: 

977 sortable.append(span) 

978 

979 # Group functions by category 

980 categorized_functions: Dict[str, List[FunctionSpan]] = {} 

981 

982 for span in sortable: 

983 category = categorize_method(span.node, self.config.category_config) 

984 if category not in categorized_functions: 

985 categorized_functions[category] = [] 

986 categorized_functions[category].append(span) 

987 

988 # Sort within each category if category_sorting is alphabetical 

989 if self.config.category_config.category_sorting == "alphabetical": 

990 for category_functions in categorized_functions.values(): 

991 category_functions.sort(key=lambda s: s.name) 

992 

993 # Reconstruct in category order 

994 result = [] 

995 for category_def in self.config.category_config.categories: 

996 if category_def.name in categorized_functions: 

997 result.extend(categorized_functions[category_def.name]) 

998 

999 # Add any excluded functions at the end 

1000 result.extend(excluded) 

1001 

1002 return result 

1003 

1004 def _sort_functions_in_content(self, content: str) -> str: 

1005 """Sort functions in file content and return new content. 

1006 

1007 :param content: Original file content 

1008 :type content: str 

1009 :returns: Content with sorted functions 

1010 :rtype: str 

1011 """ 

1012 try: 

1013 module = astroid.parse(content) 

1014 lines = content.splitlines(keepends=True) 

1015 

1016 # Process module-level functions 

1017 content = self._sort_module_functions(content, module, lines) 

1018 

1019 # Process class methods 

1020 content = self._sort_class_methods(content, module, lines) 

1021 

1022 return content 

1023 

1024 except ( 

1025 Exception 

1026 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught 

1027 print(f"Error sorting content: {e}") 

1028 return content 

1029 

1030 def _sort_module_functions( 

1031 self, content: str, module: nodes.Module, lines: List[str] 

1032 ) -> str: 

1033 """Sort module-level functions. 

1034 

1035 :param content: File content 

1036 :type content: str 

1037 :param module: Parsed module 

1038 :type module: nodes.Module 

1039 :param lines: Content split into lines 

1040 :type lines: List[str] 

1041 :returns: Content with sorted module functions 

1042 :rtype: str 

1043 """ 

1044 functions = utils.get_functions_from_node(module) 

1045 if not functions: # pragma: no cover 

1046 return content 

1047 

1048 # Check if sorting is needed 

1049 functions_already_sorted = utils.are_functions_sorted_with_exclusions( 

1050 functions, self.config.ignore_decorators 

1051 ) 

1052 

1053 # Even if functions are sorted, we might need to add section headers 

1054 if functions_already_sorted and not self.config.add_section_headers: 

1055 return content 

1056 

1057 # Extract function spans 

1058 function_spans = self._extract_function_spans(functions, lines, module) 

1059 

1060 # Sort the functions 

1061 sorted_spans = self._sort_function_spans(function_spans) 

1062 

1063 # Reconstruct content 

1064 return self._reconstruct_content_with_sorted_functions( 

1065 content, function_spans, sorted_spans 

1066 ) 

1067 

1068 def _validate_syntax_and_rollback( 

1069 self, file_path: Path, original_content: str, new_content: str 

1070 ) -> str: 

1071 """Validate syntax after transformation, rollback if invalid. 

1072 

1073 This is a critical safety measure to prevent the corruption described in 

1074 GitHub issue #25. If auto-sort creates syntax errors, we automatically 

1075 rollback to the original content to prevent data loss. 

1076 

1077 :param file_path: Path to the file being processed (for error reporting) 

1078 :type file_path: Path 

1079 :param original_content: Original file content before transformation 

1080 :type original_content: str 

1081 :param new_content: New content after auto-sort transformation 

1082 :type new_content: str 

1083 :returns: Validated content (new_content if valid, original_content if invalid) 

1084 :rtype: str 

1085 """ 

1086 try: 

1087 # Attempt to compile the new content to check for syntax errors 

1088 compile(new_content, str(file_path), "exec") 

1089 return new_content 

1090 except SyntaxError as e: 

1091 # Log the syntax error and rollback to prevent corruption 

1092 print(f"WARNING: Auto-sort would create syntax error in {file_path}:") 

1093 print(f" Error: {e}") 

1094 if hasattr(e, "lineno") and e.lineno: 

1095 if e.text: 

1096 print(f" Line {e.lineno}: {e.text}") 

1097 else: 

1098 print(f" Line {e.lineno}") # pragma: no cover 

1099 print(" Reverting to original content to prevent file corruption.") 

1100 print(" This prevents the critical bug described in GitHub issue #25.") 

1101 return original_content 

1102 except Exception as e: # pragma: no cover 

1103 # pylint: disable=broad-exception-caught 

1104 # For any other compilation errors, be conservative and rollback 

1105 print(f"WARNING: Could not validate syntax for {file_path}: {e}") 

1106 print(" Reverting to original content as safety precaution.") 

1107 return original_content 

1108 

1109 

1110# Public functions 

1111 

1112 

1113# Public API function for sorting a single file 

1114def sort_python_file(file_path: Path, config: AutoFixConfig) -> bool: # pylint: disable=function-should-be-private 

1115 """Sort functions in a Python file. 

1116 

1117 :param file_path: Path to the Python file 

1118 :type file_path: Path 

1119 :param config: Auto-fix configuration 

1120 :type config: AutoFixConfig 

1121 :returns: True if file was modified 

1122 :rtype: bool 

1123 """ 

1124 return _sort_python_file(file_path, config) 

1125 

1126 

1127def sort_python_files(file_paths: List[Path], config: AutoFixConfig) -> Tuple[int, int]: 

1128 """Sort functions in multiple Python files. 

1129 

1130 :param file_paths: List of Python file paths 

1131 :type file_paths: List[Path] 

1132 :param config: Auto-fix configuration 

1133 :type config: AutoFixConfig 

1134 :returns: Tuple of (files_processed, files_modified) 

1135 :rtype: Tuple[int, int] 

1136 """ 

1137 files_processed = 0 

1138 files_modified = 0 

1139 

1140 for file_path in file_paths: 

1141 if file_path.suffix == ".py": 

1142 files_processed += 1 

1143 if _sort_python_file(file_path, config): 

1144 files_modified += 1 

1145 

1146 return files_processed, files_modified 

1147 

1148 

1149# Private functions 

1150 

1151 

1152def _sort_python_file(file_path: Path, config: AutoFixConfig) -> bool: 

1153 """Sort functions in a Python file (private implementation). 

1154 

1155 :param file_path: Path to the Python file 

1156 :type file_path: Path 

1157 :param config: Auto-fix configuration 

1158 :type config: AutoFixConfig 

1159 :returns: True if file was modified 

1160 :rtype: bool 

1161 """ 

1162 sorter = FunctionSorter(config) 

1163 return sorter.sort_file(file_path)