source.py 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. """A scanned file a finding names: reading it, and finding the line that places the finding."""
  2. from __future__ import annotations
  3. import re
  4. from bisect import bisect_right
  5. from itertools import accumulate
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING
  8. if TYPE_CHECKING:
  9. from collections.abc import Sequence
  10. def read(scan_root: str, file: str) -> str | None:
  11. """The text of `file`, relative to `scan_root`; None when it cannot be read."""
  12. try:
  13. return Path(scan_root, file).read_bytes().decode("utf-8", "surrogateescape")
  14. except (OSError, ValueError):
  15. return None
  16. def placed_line(text: str | None, line: int, snippet: str) -> int:
  17. """The 1-based line of `text` that places a finding declaring `line` and quoting `snippet`.
  18. `text` is the text of the finding's file, None when it was not read; the
  19. declared `line` comes back unchanged then, and when no line of the file
  20. places the finding (placing_row).
  21. """
  22. if text is None:
  23. return line
  24. row = placing_row(normalized_lines(text), line, snippet)
  25. return line if row is None else row + 1
  26. def normalized_lines(source: str) -> list[str]:
  27. """A file's lines, split on the newline alone, each with its whitespace normalized."""
  28. return [" ".join(each.split()) for each in source.split("\n")]
  29. def quoted_lines(text: str, snippet: str, *, whole: bool) -> set[int]:
  30. """Every 1-based line of `text` on which the quoted `snippet` occurs, whitespace aside.
  31. With `whole`, only occurrences that are entire lines of `text` count, so a
  32. quote of part of a line matches nothing.
  33. """
  34. lines = normalized_lines(text)
  35. quoted = " ".join(snippet.split())
  36. spans = [
  37. (first, last)
  38. for first, last in occurrences(lines, quoted)
  39. if not whole or " ".join(filter(None, lines[first : last + 1])) == quoted
  40. ]
  41. return {row + 1 for first, last in spans for row in range(first, last + 1)}
  42. def placing_span(lines: Sequence[str], line: int, snippet: str) -> tuple[int, int] | None:
  43. """The (first, last) rows of the occurrence of `snippet` nearest the declared line, or None."""
  44. declared = line - 1
  45. return min(
  46. occurrences(lines, " ".join(snippet.split())),
  47. key=lambda span: abs(min(max(declared, span[0]), span[1]) - declared),
  48. default=None,
  49. )
  50. def placing_row(lines: Sequence[str], line: int, snippet: str) -> int | None:
  51. """The index into the normalized `lines` of the one placing a finding; None when none does.
  52. The finding is placed on the line nearest its declared `line` where
  53. `snippet`, the code it quotes, appears, whitespace aside, and on the
  54. declared line itself when it appears nowhere.
  55. """
  56. declared = line - 1
  57. span = placing_span(lines, line, snippet)
  58. at = declared if span is None else min(max(declared, span[0]), span[1])
  59. return at if 0 <= at < len(lines) else None
  60. def occurrences(lines: Sequence[str], quoted: str) -> list[tuple[int, int]]:
  61. """The (first, last) index into the normalized `lines` of each occurrence of `quoted`."""
  62. if not quoted:
  63. return []
  64. filled = [row for row, line in enumerate(lines) if line]
  65. starts = list(accumulate((len(lines[row]) + 1 for row in filled), initial=0))
  66. flat = " ".join(lines[row] for row in filled)
  67. def row_at(offset: int) -> int:
  68. return filled[bisect_right(starts, offset) - 1]
  69. return [
  70. (row_at(found.start()), row_at(found.end() - 1))
  71. for found in re.finditer(re.escape(quoted), flat)
  72. ]