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

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

2# pylint: disable=too-many-lines

3

4 import shutil

5 from dataclasses import dataclass

6 from pathlib import Path

7 from typing import Dict, List, Optional, Tuple

8

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

10 from astroid import nodes

11

12 from pylint_sort_functions import utils

13 from pylint_sort_functions.utils.categorization import CategoryConfig, categorize_method

14

15

16@dataclass

17 class 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

28 class 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.

64 class 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

1114 def 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

1127 def 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

1152 def _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)