Coverage for src/pylint_sort_functions/utils/sorting.py: 100%

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

2

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

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

5"""

6

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

8

9 from .ast_analysis import is_private_function

10 from .categorization import CategoryConfig, categorize_method

11 from .decorators import function_has_excluded_decorator

12

13

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

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

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

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

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

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

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

240 def _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

282 def _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

340 def _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

356 def _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

379 def _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