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

1"""Core sorting validation logic for functions and methods. 

2 

3This module provides the main sorting validation functions that check whether 

4functions and methods are properly sorted according to the configured rules. 

5""" 

6 

7from astroid import nodes # type: ignore[import-untyped] 

8 

9from .ast_analysis import is_private_function 

10from .categorization import CategoryConfig, categorize_method 

11from .decorators import function_has_excluded_decorator 

12 

13 

14def are_functions_properly_separated(functions: list[nodes.FunctionDef]) -> bool: 

15 """Check if public and private functions are properly separated. 

16 

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. 

21 

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 

29 

30 # Track if we've seen any private functions 

31 seen_private = False 

32 

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 

39 

40 return True 

41 

42 

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. 

49 

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. 

52 

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 = [] 

64 

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 ] 

71 

72 # Use existing sorting logic on the filtered functions 

73 return _are_functions_sorted(sortable_functions, config) 

74 

75 

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. 

80 

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. 

84 

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 ) 

98 

99 for method in methods: 

100 # Convert 1-based AST line number to 0-based array index 

101 method_line = method.lineno - 1 

102 

103 if not is_method_in_correct_section(method, method_line, lines, config): 

104 return False 

105 

106 return True 

107 

108 

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. 

115 

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) 

127 

128 

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. 

133 

134 Identifies section headers that are present in the source code but 

135 have no corresponding methods in that category. 

136 

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 ) 

150 

151 # Get existing headers 

152 existing_headers = parse_section_headers(lines, config) 

153 existing_categories = set(existing_headers.keys()) 

154 

155 # Get categories that have methods 

156 method_categories = _get_function_categories(methods, config) 

157 populated_categories = set(method_categories.keys()) 

158 

159 # Find categories with headers but no methods 

160 empty_categories = existing_categories - populated_categories 

161 

162 return list(empty_categories) 

163 

164 

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. 

169 

170 Analyzes methods to determine which categories have methods but no 

171 corresponding section header in the source code. 

172 

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 ) 

186 

187 # Get existing headers 

188 existing_headers = parse_section_headers(lines, config) 

189 existing_categories = set(existing_headers.keys()) 

190 

191 # Get categories that have methods 

192 method_categories = _get_function_categories(methods, config) 

193 populated_categories = set(method_categories.keys()) 

194 

195 # Find categories with methods but no headers 

196 missing_categories = populated_categories - existing_categories 

197 

198 return list(missing_categories) 

199 

200 

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. 

205 

206 Returns a list of violations where methods are not in their expected 

207 sections, including the expected and actual section information. 

208 

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 ) 

223 

224 violations = [] 

225 boundaries = find_method_section_boundaries(lines, config) 

226 

227 for method in methods: 

228 # Convert 1-based AST line number to 0-based array index 

229 method_line = method.lineno - 1 

230 

231 expected_section = get_expected_section_for_method(method, config) 

232 actual_section = boundaries.get(method_line, "unknown") 

233 

234 if actual_section != expected_section: 

235 violations.append((method, expected_section, actual_section)) 

236 

237 return violations 

238 

239 

240def _are_categories_properly_ordered( 

241 functions: list[nodes.FunctionDef], config: CategoryConfig 

242) -> bool: 

243 """Check if functions appear in the correct category order. 

244 

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. 

248 

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 

258 

259 category_order = {cat.name: i for i, cat in enumerate(config.categories)} 

260 seen_categories = set() 

261 last_category_index = -1 

262 

263 for func in functions: 

264 category = categorize_method(func, config) 

265 category_index = category_order.get(category, len(config.categories)) 

266 

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 

278 

279 return True 

280 

281 

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. 

286 

287 Functions are expected to be sorted with categories in the order defined by 

288 the configuration, and alphabetically within each category. 

289 

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 

293 

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 

303 

304 if config is None: 

305 config = CategoryConfig() 

306 

307 # For backward compatibility, use the original binary logic 

308 if not config.enable_categories: 

309 public_functions, private_functions = _get_function_groups(functions) 

310 

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 

315 

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 

320 

321 return True 

322 

323 # New multi-category logic 

324 categorized_functions = _get_function_categories(functions, config) 

325 

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) 

335 

336 # Check that categories appear in the correct order 

337 return _are_categories_properly_ordered(functions, config) 

338 

339 

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. 

344 

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) 

354 

355 

356def _get_function_categories( 

357 functions: list[nodes.FunctionDef], config: CategoryConfig 

358) -> dict[str, list[nodes.FunctionDef]]: 

359 """Group functions by their categories. 

360 

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]] = {} 

369 

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) 

375 

376 return categories 

377 

378 

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. 

383 

384 DEPRECATED: This function is maintained for backward compatibility. 

385 New code should use _get_function_categories() with CategoryConfig. 

386 

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