|
13 | 13 | BRACKET_CLOSE, |
14 | 14 | BRACKET_OPEN, |
15 | 15 | LIST_SEPARATOR, |
| 16 | + NATIVE_PATH_SEPARATOR, |
16 | 17 | PARENTHESIS_CLOSE, |
17 | 18 | PARENTHESIS_OPEN, |
| 19 | + REASON_SEPARATOR, |
18 | 20 | REMOVED_CODE, |
19 | 21 | RUST_ATTRIBUTE_PATTERN, |
20 | 22 | RUST_BARE_WORD_PATTERN, |
|
23 | 25 | RUST_IMPL_HEADER_PATTERN, |
24 | 26 | RUST_LINE_CONTINUATION, |
25 | 27 | RUST_LITERAL_PATTERN, |
| 28 | + RUST_LOOKBEHIND, |
26 | 29 | RUST_MACRO_INVOCATION_PATTERN, |
| 30 | + RUST_MATCHES_MACRO_PATTERN, |
27 | 31 | RUST_PATH_PAIR_PATTERN, |
| 32 | + RUST_PATTERN_FOLLOWER_PATTERN, |
| 33 | + RUST_PATTERN_LEADER_PATTERN, |
| 34 | + RUST_QUALIFIED_VARIANT_PATTERN, |
| 35 | + RUST_SELF, |
28 | 36 | RUST_SELF_PATH_PATTERN, |
29 | 37 | RUST_SIMPLE_ESCAPES, |
| 38 | + RUST_TABLE_ITEM_PATTERN, |
30 | 39 | RUST_TEST_FILE_NAME, |
31 | 40 | RUST_TEST_MODULE_PATTERN, |
32 | 41 | RUST_TEST_PATH_SEGMENTS, |
|
69 | 78 | _SELF_PATH: re.Pattern[str] = re.compile(RUST_SELF_PATH_PATTERN) |
70 | 79 | _VARIANT_IMPORT: re.Pattern[str] = re.compile(RUST_VARIANT_IMPORT_PATTERN) |
71 | 80 | _BARE_WORD: re.Pattern[str] = re.compile(RUST_BARE_WORD_PATTERN) |
| 81 | +_QUALIFIED: re.Pattern[str] = re.compile(RUST_QUALIFIED_VARIANT_PATTERN) |
| 82 | +_TABLE_ITEM: re.Pattern[str] = re.compile(RUST_TABLE_ITEM_PATTERN) |
| 83 | +_MATCHES_MACRO: re.Pattern[str] = re.compile(RUST_MATCHES_MACRO_PATTERN) |
| 84 | +_PATTERN_FOLLOWER: re.Pattern[str] = re.compile(RUST_PATTERN_FOLLOWER_PATTERN) |
| 85 | +_PATTERN_LEADER: re.Pattern[str] = re.compile(RUST_PATTERN_LEADER_PATTERN) |
72 | 86 | _SQL_COMMENTS: re.Pattern[str] = re.compile(SQL_COMMENT_PATTERN, re.DOTALL) |
73 | 87 | _SQL_TOKENS: re.Pattern[str] = re.compile(SQL_TOKEN_PATTERN) |
74 | 88 | _SQL_WORD: re.Pattern[str] = re.compile(SQL_WORD_PATTERN) |
@@ -256,19 +270,108 @@ def enum_variants(*, path: str, code: str) -> list[EnumVariant]: |
256 | 270 | return variants |
257 | 271 |
|
258 | 272 |
|
259 | | -def named_variants(*, variants: list[EnumVariant], codes: dict[str, str]) -> set[EnumVariant]: |
260 | | - """Variants named by a path, by `Self` in an impl's file, or by an imported variant.""" |
| 273 | +def named_variants( |
| 274 | + *, variants: list[EnumVariant], codes: dict[str, str], strict: frozenset[str] = frozenset() |
| 275 | +) -> set[EnumVariant]: |
| 276 | + """Variants named in code; a `strict` enum's variants count only where built as a value.""" |
261 | 277 |
|
262 | 278 | bodies: dict[str, list[tuple[int, int]]] = {} |
263 | 279 | variant: EnumVariant |
264 | 280 | for variant in variants: |
265 | 281 | bodies.setdefault(variant.path, []).append(variant.body) |
266 | 282 | names: set[tuple[str, str]] = set() |
| 283 | + constructed: set[tuple[str, str]] = set() |
267 | 284 | path: str |
268 | 285 | code: str |
269 | 286 | for path, code in codes.items(): |
270 | | - names.update(_file_names(code=_without_spans(text=code, spans=bodies.get(path, [])))) |
271 | | - return {variant for variant in variants if (variant.enum, variant.variant) in names} |
| 287 | + visible: str = _without_spans(text=code, spans=bodies.get(path, [])) |
| 288 | + names.update(_file_names(code=visible)) |
| 289 | + if strict: |
| 290 | + constructed.update(_constructed_names(code=visible, strict=strict)) |
| 291 | + return { |
| 292 | + variant |
| 293 | + for variant in variants |
| 294 | + if (variant.enum, variant.variant) in (constructed if variant.enum in strict else names) |
| 295 | + } |
| 296 | + |
| 297 | + |
| 298 | +def reasoned_entries(*, entries: tuple[str, ...]) -> tuple[dict[str, str], list[str]]: |
| 299 | + """`name=reason` option entries by name, and the entries that lack a name or a reason.""" |
| 300 | + |
| 301 | + reasons: dict[str, str] = {} |
| 302 | + malformed: list[str] = [] |
| 303 | + entry: str |
| 304 | + for entry in entries: |
| 305 | + name, _, reason = entry.partition(REASON_SEPARATOR) |
| 306 | + if name.strip() and reason.strip(): |
| 307 | + reasons[name.strip()] = reason.strip() |
| 308 | + else: |
| 309 | + malformed.append(entry) |
| 310 | + return reasons, malformed |
| 311 | + |
| 312 | + |
| 313 | +def _constructed_names(*, code: str, strict: frozenset[str]) -> set[tuple[str, str]]: |
| 314 | + value_code: str = _without_tables(code=code) |
| 315 | + implemented: set[str] = set() |
| 316 | + header: re.Match[str] |
| 317 | + for header in _IMPL_HEADER.finditer(value_code): |
| 318 | + implemented.update(set(_SQL_WORD.findall(header.group(0))) & strict) |
| 319 | + names: set[tuple[str, str]] = set() |
| 320 | + occurrence: re.Match[str] |
| 321 | + for occurrence in _QUALIFIED.finditer(value_code): |
| 322 | + owner: str = occurrence.group(1) |
| 323 | + enums: set[str] = implemented if owner == RUST_SELF else {owner} & strict |
| 324 | + if enums and not _pattern_position( |
| 325 | + code=value_code, start=occurrence.start(), end=occurrence.end() |
| 326 | + ): |
| 327 | + names.update(itertools.product(enums, (occurrence.group(2),))) |
| 328 | + return names |
| 329 | + |
| 330 | + |
| 331 | +def _without_tables(*, code: str) -> str: |
| 332 | + spans: list[tuple[int, int]] = [ |
| 333 | + (item.start(), _statement_end(text=code, start=item.end())) |
| 334 | + for item in _TABLE_ITEM.finditer(code) |
| 335 | + ] |
| 336 | + macro: re.Match[str] |
| 337 | + for macro in _MATCHES_MACRO.finditer(code): |
| 338 | + closing: int = _closing( |
| 339 | + text=code, opening=macro.end() - 1, pair=(PARENTHESIS_OPEN, PARENTHESIS_CLOSE) |
| 340 | + ) |
| 341 | + pieces: list[tuple[int, str]] = _top_level_pieces(text=code, start=macro.end(), end=closing) |
| 342 | + if len(pieces) > 1: |
| 343 | + spans.append((pieces[1][0], closing)) |
| 344 | + return _without_spans(text=code, spans=spans) |
| 345 | + |
| 346 | + |
| 347 | +def _statement_end(*, text: str, start: int) -> int: |
| 348 | + depth: int = 0 |
| 349 | + offset: int |
| 350 | + for offset in range(start, len(text)): |
| 351 | + character: str = text[offset] |
| 352 | + depth += (character in (PARENTHESIS_OPEN, BRACKET_OPEN, BRACE_OPEN)) - ( |
| 353 | + character in (PARENTHESIS_CLOSE, BRACKET_CLOSE, BRACE_CLOSE) |
| 354 | + ) |
| 355 | + if character == SQL_STATEMENT_SEPARATOR and depth == 0: |
| 356 | + return offset |
| 357 | + return len(text) |
| 358 | + |
| 359 | + |
| 360 | +def _pattern_position(*, code: str, start: int, end: int) -> bool: |
| 361 | + after: int = end |
| 362 | + while after < len(code) and code[after].isspace(): |
| 363 | + after += 1 |
| 364 | + if after < len(code) and code[after] in (PARENTHESIS_OPEN, BRACE_OPEN): |
| 365 | + pair: tuple[str, str] = ( |
| 366 | + (PARENTHESIS_OPEN, PARENTHESIS_CLOSE) |
| 367 | + if code[after] == PARENTHESIS_OPEN |
| 368 | + else (BRACE_OPEN, BRACE_CLOSE) |
| 369 | + ) |
| 370 | + after = _closing(text=code, opening=after, pair=pair) + 1 |
| 371 | + return ( |
| 372 | + _PATTERN_FOLLOWER.match(code, after) is not None |
| 373 | + or _PATTERN_LEADER.search(code[max(0, start - RUST_LOOKBEHIND) : start]) is not None |
| 374 | + ) |
272 | 375 |
|
273 | 376 |
|
274 | 377 | def _file_names(*, code: str) -> set[tuple[str, str]]: |
@@ -432,3 +535,57 @@ def _column_name(*, piece: str) -> str | None: |
432 | 535 | if first in SQL_COLUMN_CONSTRAINT_WORDS or _SQL_WORD.fullmatch(first) is None: |
433 | 536 | return None |
434 | 537 | return first |
| 538 | + |
| 539 | + |
| 540 | +def allowed_state( |
| 541 | + *, definitions: list[StateDefinition], unread: list[StateDefinition], entries: tuple[str, ...] |
| 542 | +) -> tuple[list[StateDefinition], list[str]]: |
| 543 | + """Unread state not allowlisted, and malformed or stale entries for tables defined here.""" |
| 544 | + |
| 545 | + reasons: dict[str, str] |
| 546 | + malformed: list[str] |
| 547 | + reasons, malformed = reasoned_entries(entries=entries) |
| 548 | + defined_tables: set[str] = {definition.table for definition in definitions} |
| 549 | + unread_keys: set[str] = {_state_key(definition=definition) for definition in unread} |
| 550 | + stale: list[str] = [ |
| 551 | + f"allowlisted {key} is read or no longer defined; remove the entry" |
| 552 | + for key in reasons |
| 553 | + if key.partition(SQL_QUALIFIER)[0] in defined_tables and key not in unread_keys |
| 554 | + ] |
| 555 | + problems: list[str] = [f"allowlist entry {entry!r} needs `name=reason`" for entry in malformed] |
| 556 | + kept: list[StateDefinition] = [ |
| 557 | + definition for definition in unread if _state_key(definition=definition) not in reasons |
| 558 | + ] |
| 559 | + return kept, problems + stale |
| 560 | + |
| 561 | + |
| 562 | +def excepted_variants( |
| 563 | + *, variants: list[EnumVariant], unnamed: list[EnumVariant], entries: tuple[str, ...] |
| 564 | +) -> tuple[list[EnumVariant], list[str]]: |
| 565 | + """Unnamed variants not excepted, and problems: malformed or no-longer-needed entries.""" |
| 566 | + |
| 567 | + reasons: dict[str, str] |
| 568 | + malformed: list[str] |
| 569 | + reasons, malformed = reasoned_entries(entries=entries) |
| 570 | + declared: set[str] = {variant.enum for variant in variants} |
| 571 | + covered: set[str] = set() |
| 572 | + kept: list[EnumVariant] = [] |
| 573 | + variant: EnumVariant |
| 574 | + for variant in unnamed: |
| 575 | + keys: set[str] = {variant.enum, f"{variant.enum}{NATIVE_PATH_SEPARATOR}{variant.variant}"} |
| 576 | + covered.update(keys & set(reasons)) |
| 577 | + if not keys & set(reasons): |
| 578 | + kept.append(variant) |
| 579 | + stale: list[str] = [ |
| 580 | + f"exception {key} no longer matches an unnamed variant; remove the entry" |
| 581 | + for key in reasons |
| 582 | + if key.partition(NATIVE_PATH_SEPARATOR)[0] in declared and key not in covered |
| 583 | + ] |
| 584 | + problems: list[str] = [f"exception entry {entry!r} needs `name=reason`" for entry in malformed] |
| 585 | + return kept, problems + stale |
| 586 | + |
| 587 | + |
| 588 | +def _state_key(*, definition: StateDefinition) -> str: |
| 589 | + if definition.column is None: |
| 590 | + return definition.table |
| 591 | + return f"{definition.table}{SQL_QUALIFIER}{definition.column}" |
0 commit comments