Coverage for src/pylint_sort_functions/utils/sorting.py: 100%
109 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
1"""Core sorting validation logic for functions and methods.
3This module provides the main sorting validation functions that check whether
4functions and methods are properly sorted according to the configured rules.
5"""
7from astroid import nodes # type: ignore[import-untyped]
9from .ast_analysis import is_private_function
10from .categorization import CategoryConfig, categorize_method
11from .decorators import function_has_excluded_decorator
14def are_functions_properly_separated(functions: list[nodes.FunctionDef]) -> bool:
15 """Check if public and private functions are properly separated.
17 This function only verifies the ordering constraint: public functions must
18 appear before private functions. It does not check for section comment headers
19 like "# Public functions" or "# Private functions" - that would be a separate
20 validation if implemented.
22 :param functions: List of function definition nodes
23 :type functions: list[nodes.FunctionDef]
24 :returns: True if public functions come before private functions
25 :rtype: bool
26 """
27 if len(functions) <= 1:
28 return True
30 # Track if we've seen any private functions
31 seen_private = False
33 for func in functions:
34 if is_private_function(func):
35 seen_private = True
36 elif seen_private:
37 # Found a public function after a private function
38 return False
40 return True
43def are_functions_sorted_with_exclusions(
44 functions: list[nodes.FunctionDef],
45 ignore_decorators: list[str] | None = None,
46 config: CategoryConfig | None = None,
47) -> bool:
48 """Check if functions are sorted alphabetically, excluding decorator-dependent ones.
50 This is the enhanced version of _are_functions_sorted that supports framework-aware
51 sorting by excluding functions with specific decorators that create dependencies.
53 :param functions: List of function definition nodes
54 :type functions: list[nodes.FunctionDef]
55 :param ignore_decorators: List of decorator patterns to ignore
56 :type ignore_decorators: list[str] | None
57 :param config: Category configuration, uses default if None
58 :type config: CategoryConfig | None
59 :returns: True if functions are properly sorted (excluding ignored ones)
60 :rtype: bool
61 """
62 if ignore_decorators is None:
63 ignore_decorators = []
65 # Filter out functions with excluded decorators
66 sortable_functions = [
67 func
68 for func in functions
69 if not function_has_excluded_decorator(func, ignore_decorators)
70 ]
72 # Use existing sorting logic on the filtered functions
73 return _are_functions_sorted(sortable_functions, config)
76def are_methods_in_correct_sections( # pylint: disable=function-should-be-private
77 methods: list[nodes.FunctionDef], lines: list[str], config: CategoryConfig
78) -> bool:
79 """Check if methods are positioned in their correct sections.
81 Validates that all methods appear under the appropriate section headers
82 according to their categorization. This makes section headers functional
83 rather than just decorative.
85 :param methods: List of method nodes to validate
86 :type methods: list[nodes.FunctionDef]
87 :param lines: Source code lines containing the methods
88 :type lines: list[str]
89 :param config: Category configuration with section headers
90 :type config: CategoryConfig
91 :returns: True if all methods are in correct sections
92 :rtype: bool
93 """
94 # Import here to avoid circular dependency
95 from .categorization import ( # pylint: disable=import-outside-toplevel
96 is_method_in_correct_section,
97 )
99 for method in methods:
100 # Convert 1-based AST line number to 0-based array index
101 method_line = method.lineno - 1
103 if not is_method_in_correct_section(method, method_line, lines, config):
104 return False
106 return True
109def are_methods_sorted_with_exclusions(
110 methods: list[nodes.FunctionDef],
111 ignore_decorators: list[str] | None = None,
112 config: CategoryConfig | None = None,
113) -> bool:
114 """Check if methods are sorted alphabetically, excluding decorator-dependent ones.
116 :param methods: List of method definition nodes
117 :type methods: list[nodes.FunctionDef]
118 :param ignore_decorators: List of decorator patterns to ignore
119 :type ignore_decorators: list[str] | None
120 :param config: Category configuration, uses default if None
121 :type config: CategoryConfig | None
122 :returns: True if methods are properly sorted (excluding ignored ones)
123 :rtype: bool
124 """
125 # Methods follow the same sorting rules as functions
126 return are_functions_sorted_with_exclusions(methods, ignore_decorators, config)
129def find_empty_section_headers( # pylint: disable=function-should-be-private
130 methods: list[nodes.FunctionDef], lines: list[str], config: CategoryConfig
131) -> list[str]:
132 """Find section headers that exist but have no methods underneath.
134 Identifies section headers that are present in the source code but
135 have no corresponding methods in that category.
137 :param methods: List of method nodes to analyze
138 :type methods: list[nodes.FunctionDef]
139 :param lines: Source code lines to check for headers
140 :type lines: list[str]
141 :param config: Category configuration with section headers
142 :type config: CategoryConfig
143 :returns: List of empty section header category names
144 :rtype: list[str]
145 """
146 # Import here to avoid circular dependency
147 from .categorization import ( # pylint: disable=import-outside-toplevel
148 parse_section_headers,
149 )
151 # Get existing headers
152 existing_headers = parse_section_headers(lines, config)
153 existing_categories = set(existing_headers.keys())
155 # Get categories that have methods
156 method_categories = _get_function_categories(methods, config)
157 populated_categories = set(method_categories.keys())
159 # Find categories with headers but no methods
160 empty_categories = existing_categories - populated_categories
162 return list(empty_categories)
165def find_missing_section_headers( # pylint: disable=function-should-be-private
166 methods: list[nodes.FunctionDef], lines: list[str], config: CategoryConfig
167) -> list[str]:
168 """Find section headers that should exist but are missing.
170 Analyzes methods to determine which categories have methods but no
171 corresponding section header in the source code.
173 :param methods: List of method nodes to analyze
174 :type methods: list[nodes.FunctionDef]
175 :param lines: Source code lines to check for headers
176 :type lines: list[str]
177 :param config: Category configuration with section headers
178 :type config: CategoryConfig
179 :returns: List of missing section header category names
180 :rtype: list[str]
181 """
182 # Import here to avoid circular dependency
183 from .categorization import ( # pylint: disable=import-outside-toplevel
184 parse_section_headers,
185 )
187 # Get existing headers
188 existing_headers = parse_section_headers(lines, config)
189 existing_categories = set(existing_headers.keys())
191 # Get categories that have methods
192 method_categories = _get_function_categories(methods, config)
193 populated_categories = set(method_categories.keys())
195 # Find categories with methods but no headers
196 missing_categories = populated_categories - existing_categories
198 return list(missing_categories)
201def get_section_violations( # pylint: disable=function-should-be-private
202 methods: list[nodes.FunctionDef], lines: list[str], config: CategoryConfig
203) -> list[tuple[nodes.FunctionDef, str, str]]:
204 """Get detailed information about methods in wrong sections.
206 Returns a list of violations where methods are not in their expected
207 sections, including the expected and actual section information.
209 :param methods: List of method nodes to analyze
210 :type methods: list[nodes.FunctionDef]
211 :param lines: Source code lines containing the methods
212 :type lines: list[str]
213 :param config: Category configuration with section headers
214 :type config: CategoryConfig
215 :returns: List of (method, expected_section, actual_section) tuples
216 :rtype: list[tuple[nodes.FunctionDef, str, str]]
217 """
218 # Import here to avoid circular dependency
219 from .categorization import ( # pylint: disable=import-outside-toplevel
220 find_method_section_boundaries,
221 get_expected_section_for_method,
222 )
224 violations = []
225 boundaries = find_method_section_boundaries(lines, config)
227 for method in methods:
228 # Convert 1-based AST line number to 0-based array index
229 method_line = method.lineno - 1
231 expected_section = get_expected_section_for_method(method, config)
232 actual_section = boundaries.get(method_line, "unknown")
234 if actual_section != expected_section:
235 violations.append((method, expected_section, actual_section))
237 return violations
240def _are_categories_properly_ordered(
241 functions: list[nodes.FunctionDef], config: CategoryConfig
242) -> bool:
243 """Check if functions appear in the correct category order.
245 Verifies that functions appear in the order defined by config.categories.
246 For example, if categories are [properties, public_methods, private_methods],
247 then all properties must appear before all public_methods, etc.
249 :param functions: List of function definition nodes
250 :type functions: list[nodes.FunctionDef]
251 :param config: Category configuration defining order
252 :type config: CategoryConfig
253 :returns: True if functions are in correct category order
254 :rtype: bool
255 """
256 if not functions:
257 return True
259 category_order = {cat.name: i for i, cat in enumerate(config.categories)}
260 seen_categories = set()
261 last_category_index = -1
263 for func in functions:
264 category = categorize_method(func, config)
265 category_index = category_order.get(category, len(config.categories))
267 if category in seen_categories:
268 # We've seen this category before - check if we're still in it or
269 # moving backward
270 if category_index < last_category_index:
271 return False # Categories are mixed/out of order
272 else:
273 # First time seeing this category
274 if category_index < last_category_index:
275 return False # This category should have appeared earlier
276 seen_categories.add(category)
277 last_category_index = category_index
279 return True
282def _are_functions_sorted(
283 functions: list[nodes.FunctionDef], config: CategoryConfig | None = None
284) -> bool: # pylint: disable=function-should-be-private
285 """Check if functions are sorted alphabetically within their category scope.
287 Functions are expected to be sorted with categories in the order defined by
288 the configuration, and alphabetically within each category.
290 For backward compatibility, when config.enable_categories=False:
291 - Public functions (including dunder methods like __init__) sorted first
292 - Private functions (single underscore prefix) sorted alphabetically second
294 :param functions: List of function definition nodes
295 :type functions: list[nodes.FunctionDef]
296 :param config: Category configuration, uses default if None
297 :type config: CategoryConfig | None
298 :returns: True if functions are properly sorted
299 :rtype: bool
300 """
301 if len(functions) <= 1:
302 return True
304 if config is None:
305 config = CategoryConfig()
307 # For backward compatibility, use the original binary logic
308 if not config.enable_categories:
309 public_functions, private_functions = _get_function_groups(functions)
311 # Check if public functions are sorted
312 public_names = [f.name for f in public_functions]
313 if public_names != sorted(public_names):
314 return False
316 # Check if private functions are sorted
317 private_names = [f.name for f in private_functions]
318 if private_names != sorted(private_names):
319 return False
321 return True
323 # New multi-category logic
324 categorized_functions = _get_function_categories(functions, config)
326 # Check sorting within each category
327 for category_name in [cat.name for cat in config.categories]:
328 if category_name in categorized_functions:
329 category_functions = categorized_functions[category_name]
330 if config.category_sorting == "alphabetical":
331 names = [f.name for f in category_functions]
332 if names != sorted(names):
333 return False
334 # If category_sorting == "declaration", preserve order (always sorted)
336 # Check that categories appear in the correct order
337 return _are_categories_properly_ordered(functions, config)
340def _are_methods_sorted(
341 methods: list[nodes.FunctionDef], config: CategoryConfig | None = None
342) -> bool: # pylint: disable=function-should-be-private
343 """Check if methods are sorted alphabetically within their category scope.
345 :param methods: List of method definition nodes
346 :type methods: list[nodes.FunctionDef]
347 :param config: Category configuration, uses default if None
348 :type config: CategoryConfig | None
349 :returns: True if methods are properly sorted
350 :rtype: bool
351 """
352 # Methods follow the same sorting rules as functions
353 return _are_functions_sorted(methods, config)
356def _get_function_categories(
357 functions: list[nodes.FunctionDef], config: CategoryConfig
358) -> dict[str, list[nodes.FunctionDef]]:
359 """Group functions by their categories.
361 :param functions: List of function definition nodes
362 :type functions: list[nodes.FunctionDef]
363 :param config: Category configuration
364 :type config: CategoryConfig
365 :returns: Dictionary mapping category names to function lists
366 :rtype: dict[str, list[nodes.FunctionDef]]
367 """
368 categories: dict[str, list[nodes.FunctionDef]] = {}
370 for func in functions:
371 category = categorize_method(func, config)
372 if category not in categories:
373 categories[category] = []
374 categories[category].append(func)
376 return categories
379def _get_function_groups(
380 functions: list[nodes.FunctionDef],
381) -> tuple[list[nodes.FunctionDef], list[nodes.FunctionDef]]:
382 """Split functions into public and private groups.
384 DEPRECATED: This function is maintained for backward compatibility.
385 New code should use _get_function_categories() with CategoryConfig.
387 :param functions: List of function definitions
388 :type functions: list[nodes.FunctionDef]
389 :returns: Tuple of (public_functions, private_functions)
390 :rtype: tuple[list[nodes.FunctionDef], list[nodes.FunctionDef]]
391 """
392 public_functions = [f for f in functions if not is_private_function(f)]
393 private_functions = [f for f in functions if is_private_function(f)]
394 return public_functions, private_functions