lambda-watcher 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,312 @@
1
+ """Syntax highlighting for the HTML report, without a highlighting library.
2
+
3
+ A report is meant to survive being emailed, dropped in a bucket or attached to
4
+ a change ticket, so it cannot pull Prism or highlight.js off a CDN — and
5
+ vendoring one would be a lot of borrowed code to carry for a diff view. What a
6
+ diff actually needs is much smaller: enough colour to tell comments, strings,
7
+ numbers and keywords apart so the eye can skip to the line that matters.
8
+
9
+ So each language family is one combined regular expression whose named groups
10
+ carry the token class, scanned left to right; whatever falls between two
11
+ matches is plain text. The first letter of the group name *is* the class, which
12
+ is what keeps the rule tables below readable.
13
+
14
+ There are two ways in:
15
+
16
+ * ``highlight_lines(text, lang)`` tokenises a **whole file** and hands back one
17
+ string per line. This is the one the report uses, because a docstring or a
18
+ licence header only comes out right when the lexer can see where the block
19
+ opened. Multi-line rules are written to span newlines, so the same grammar
20
+ serves both entry points.
21
+ * ``highlight(text, lang)`` tokenises **one line** on its own. A diff row falls
22
+ back to this when its source file cannot be read or no longer matches, so a
23
+ report degrades to line-local colour rather than to none.
24
+
25
+ It is approximate and never authoritative either way: nothing downstream reads
26
+ these classes, and getting a token wrong costs a colour, not a fact.
27
+
28
+ The one invariant that has to hold is the escaping one: every character of the
29
+ input comes back HTML-escaped, whether it landed inside a span or not.
30
+ ``tests/test_highlight.py`` asserts exactly that.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import html
36
+ import re
37
+ from pathlib import PurePosixPath
38
+
39
+ # Token classes. The letter is the CSS class suffix (`tk-k`) and the first
40
+ # character of every named group that produces it:
41
+ #
42
+ # c comment k keyword
43
+ # s string t literal constant, built-in type
44
+ # n number f name — call, decorator, variable, attribute
45
+ # y key, tag or heading
46
+
47
+ # A line long enough to be minified output is not worth tokenising: the regex
48
+ # would do real work and the spans would outweigh the code they wrap. The file
49
+ # bound catches the same thing from the other side — a bundle that *is* one
50
+ # enormous line, or a generated file where the win is not worth the scan.
51
+ MAX_LINE = 4000
52
+ MAX_FILE = 1 << 20
53
+
54
+
55
+ def _grammar(*rules: tuple[str, str]) -> re.Pattern[str]:
56
+ """Combine ``(class, pattern)`` rules into one alternation.
57
+
58
+ Order matters — the leftmost match wins, and ties at the same position go to
59
+ the earlier rule, so comments and strings come before anything that could
60
+ also match inside one.
61
+
62
+ Every group a rule opens must be non-capturing: ``lastgroup`` reports the
63
+ last *capturing* group that matched, so one stray ``(…)`` would hand back a
64
+ group with no class letter. The count check makes that a startup failure
65
+ rather than a rendering one.
66
+ """
67
+ joined = "|".join(f"(?P<{cls}{i}>{rule})" for i, (cls, rule) in enumerate(rules))
68
+ # MULTILINE so that a rule anchored with ^ (a YAML key, a heading, an INI
69
+ # section) keeps meaning "start of line" when the grammar is handed a whole
70
+ # file. On a single line it changes nothing.
71
+ pattern = re.compile(joined, re.MULTILINE)
72
+ if pattern.groups != len(rules):
73
+ raise ValueError("a highlight rule opened a capturing group; use (?:…)")
74
+ return pattern
75
+
76
+
77
+ def _words(cls: str, words: str) -> tuple[str, str]:
78
+ """A word-boundary alternation over a whitespace-separated vocabulary."""
79
+ return cls, r"\b(?:" + "|".join(words.split()) + r")\b"
80
+
81
+
82
+ # --------------------------------------------------------------- shared rules
83
+ # Quoted strings accept an unterminated tail so that the opening line of a
84
+ # multi-line string still reads as one, rather than dissolving into keywords.
85
+ # Rules that must stay on one line say so with an explicit ``\n`` exclusion:
86
+ # scanning a whole file, an unterminated quote would otherwise swallow the rest
87
+ # of it. Rules that legitimately span lines end at ``\Z``, the end of the text —
88
+ # not ``$``, which under re.MULTILINE would stop at the first newline.
89
+ _HASH = ("c", r"#[^\n]*")
90
+ _SEMI = ("c", r";[^\n]*")
91
+ _SLASHES = ("c", r"//[^\n]*")
92
+ _BLOCK = ("c", r"/\*[\s\S]*?(?:\*/|\Z)")
93
+ _SQL_DASH = ("c", r"--[^\n]*")
94
+ _SGML = ("c", r"<!--[\s\S]*?(?:-->|\Z)")
95
+ _DQUOTE = ("s", r'"(?:\\.|[^"\\\n])*"?')
96
+ _SQUOTE = ("s", r"'(?:\\.|[^'\\\n])*'?")
97
+ _BACKTICK = ("s", r"`(?:\\.|[^`\\])*`?")
98
+ _TRIPLE = ("s", r'[rbfuRBFU]{0,2}(?:"""[\s\S]*?(?:"""|\Z)|\'\'\'[\s\S]*?(?:\'\'\'|\Z))')
99
+ _PY_QUOTE = ("s", r'[rbfuRBFU]{0,2}(?:"(?:\\.|[^"\\\n])*"?|\'(?:\\.|[^\'\\\n])*\'?)')
100
+ _NUMBER = ("n", r"\b(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\w*")
101
+ _CALL = ("f", r"[A-Za-z_]\w*(?=\s*\()")
102
+
103
+
104
+ # ------------------------------------------------------------------ languages
105
+ _PY_KEYWORDS = """
106
+ and as assert async await break case class continue def del elif else except finally for from
107
+ global if import in is lambda match nonlocal not or pass raise return try while with yield
108
+ """
109
+ _PY_CONSTANTS = "True False None self cls NotImplemented Ellipsis"
110
+
111
+ _C_KEYWORDS = """
112
+ abstract as async await break case catch class const constexpr continue debugger default defer
113
+ delegate delete do else enum event export extends fallthrough final finally fn for func function
114
+ go goto if impl implements import in instanceof interface internal let lock loop mod module move
115
+ mut namespace new operator out override package private protected pub public range readonly
116
+ record ref return sealed select static struct super switch synchronized template throw throws
117
+ trait transient try type typealias typedef typeof union unsafe use using val var virtual void
118
+ volatile when where while with yield
119
+ """
120
+ _C_CONSTANTS = """
121
+ any bool boolean byte char chan complex64 complex128 double error false float float32 float64
122
+ Infinity int int8 int16 int32 int64 isize long map NaN never nil null number object rune sbyte
123
+ self short str string symbol this true u8 u16 u32 u64 uint uint8 uint16 uint32 uint64 uintptr
124
+ undefined unknown usize
125
+ """
126
+
127
+ _RB_KEYWORDS = """
128
+ alias and begin break case class def do else elsif end ensure for if in module next not or redo
129
+ require require_relative rescue retry return super then undef unless until when while yield
130
+ """
131
+ _RB_CONSTANTS = "true false nil self __FILE__ __dir__"
132
+
133
+ _SH_KEYWORDS = """
134
+ alias break case continue declare do done elif else esac eval exec exit export fi for function
135
+ if in local readonly return select set shift source then trap unset until while
136
+ ADD ARG CMD COPY ENTRYPOINT ENV EXPOSE FROM HEALTHCHECK LABEL ONBUILD RUN SHELL STOPSIGNAL USER
137
+ VOLUME WORKDIR
138
+ """
139
+
140
+ _SQL_KEYWORDS = """
141
+ add all alter and as asc begin between by case column commit constraint create cross default
142
+ delete desc distinct drop else end exists foreign from full group having if in index inner
143
+ insert into is join key left like limit not null offset on or order outer primary references
144
+ returning right rollback select set table then union unique update values view when where with
145
+ """
146
+
147
+ GRAMMARS: dict[str, re.Pattern[str]] = {
148
+ "python": _grammar(
149
+ _HASH, _TRIPLE, _PY_QUOTE, _NUMBER,
150
+ _words("k", _PY_KEYWORDS), _words("t", _PY_CONSTANTS),
151
+ ("f", r"@[\w.]+"), _CALL,
152
+ ),
153
+ "clike": _grammar(
154
+ _SLASHES, _BLOCK, _DQUOTE, _SQUOTE, _BACKTICK, _NUMBER,
155
+ _words("k", _C_KEYWORDS), _words("t", _C_CONSTANTS),
156
+ ("f", r"@[\w.]+"), _CALL,
157
+ ),
158
+ "ruby": _grammar(
159
+ ("c", r"^=begin[\s\S]*?(?:^=end[^\n]*|\Z)"),
160
+ _HASH, _DQUOTE, _SQUOTE, _NUMBER,
161
+ _words("k", _RB_KEYWORDS), _words("t", _RB_CONSTANTS),
162
+ ("t", r":[A-Za-z_]\w*[?!]?"), ("f", r"[@$]{1,2}[A-Za-z_]\w*"), _CALL,
163
+ ),
164
+ "shell": _grammar(
165
+ _HASH, _DQUOTE, _SQUOTE, _BACKTICK, _NUMBER,
166
+ _words("k", _SH_KEYWORDS),
167
+ ("f", r"\$\{[^}\n]*\}?|\$[A-Za-z_]\w*|\$[@*#?!$0-9-]"),
168
+ ),
169
+ "json": _grammar(
170
+ ("y", r'"(?:\\.|[^"\\])*"(?=\s*:)'), _DQUOTE, _NUMBER,
171
+ _words("t", "true false null"),
172
+ ),
173
+ "yaml": _grammar(
174
+ _HASH,
175
+ ("y", r"^[ \t]*(?:-[ \t]+)?[A-Za-z_0-9.$/-]+(?=[ \t]*:(?:[ \t]|$))"),
176
+ _DQUOTE, _SQUOTE, _NUMBER,
177
+ _words("t", "true false null yes no on off"),
178
+ ("f", r"[&*][A-Za-z_]\w*|<<"),
179
+ ),
180
+ "ini": _grammar(
181
+ _HASH, _SEMI,
182
+ ("y", r"^[ \t]*\[[^\]\n]*\]?"),
183
+ ("y", r"^[ \t]*[A-Za-z_0-9.$-]+(?=[ \t]*=)"),
184
+ _DQUOTE, _SQUOTE, _NUMBER,
185
+ _words("t", "true false null"),
186
+ ),
187
+ "dotenv": _grammar(
188
+ _HASH,
189
+ ("y", r"^[ \t]*(?:export[ \t]+)?[A-Za-z_][\w.]*(?==)"),
190
+ _DQUOTE, _SQUOTE, _NUMBER,
191
+ ),
192
+ "markup": _grammar(
193
+ _SGML,
194
+ ("c", r"<[?!][^>\n]*>?"),
195
+ ("y", r"</?[A-Za-z][\w:.-]*|/?>"),
196
+ ("f", r"[A-Za-z_:][\w:.-]*(?=\s*=)"),
197
+ _DQUOTE, _SQUOTE,
198
+ ("t", r"&\#?\w+;"),
199
+ ),
200
+ "css": _grammar(
201
+ _BLOCK, _DQUOTE, _SQUOTE,
202
+ ("k", r"@[-\w]+"),
203
+ ("f", r"[-a-zA-Z][-\w]*(?=[ \t]*:)"),
204
+ ("n", r"\#[0-9a-fA-F]{3,8}\b"), _NUMBER,
205
+ ("y", r"\.[-\w]+|:{1,2}[-\w]+"),
206
+ ),
207
+ "sql": _grammar(
208
+ _SQL_DASH, _BLOCK, _SQUOTE, _DQUOTE, _NUMBER,
209
+ ("k", r"(?i:" + _words("k", _SQL_KEYWORDS)[1] + r")"),
210
+ ),
211
+ "markdown": _grammar(
212
+ ("y", r"^\#{1,6}[ \t].*"),
213
+ ("k", r"^[ \t]*(?:```|~~~).*|^[ \t]*(?:[-*+]|\d+\.)(?=[ \t])|^[ \t]*>"),
214
+ ("s", r"`[^`\n]*`?"),
215
+ ("f", r"\[[^\]\n]*\]\([^)\n]*\)?"),
216
+ ),
217
+ }
218
+
219
+ FAMILY_BY_LANG: dict[str, str] = {
220
+ "python": "python", "javascript": "clike", "typescript": "clike", "java": "clike",
221
+ "kotlin": "clike", "go": "clike", "rust": "clike", "csharp": "clike", "php": "clike",
222
+ "scala": "clike", "swift": "clike", "c": "clike", "cpp": "clike",
223
+ "ruby": "ruby",
224
+ "shell": "shell", "powershell": "shell", "dockerfile": "shell", "makefile": "shell",
225
+ "json": "json", "yaml": "yaml", "toml": "ini", "ini": "ini", "dotenv": "dotenv",
226
+ "html": "markup", "xml": "markup", "css": "css", "sql": "sql", "markdown": "markdown",
227
+ }
228
+
229
+
230
+ # `utils.language_for` reads an extension, and a dotfile has none: `.env.production`
231
+ # and `.npmrc` are both indexed as plain text. Correcting that here rather than in
232
+ # the analysis layer keeps it a presentation choice — the label already written into
233
+ # every manifest on disk stays what it was.
234
+ _BY_NAME: tuple[tuple[str, str], ...] = ((".env", "dotenv"), (".npmrc", "dotenv"))
235
+
236
+
237
+ def language_of(path: str, lang: str) -> str:
238
+ """The language to render a file as: what the index recorded, corrected by name."""
239
+ name = PurePosixPath(path).name.lower()
240
+ for prefix, corrected in _BY_NAME:
241
+ if name.startswith(prefix):
242
+ return corrected
243
+ return lang
244
+
245
+
246
+ def highlight_lines(text: str, lang: str) -> list[str]:
247
+ """A whole file as escaped HTML, one entry per line.
248
+
249
+ This is where the cross-line constructs are won: the grammar sees the whole
250
+ text, so a docstring, a licence header or an HTML comment is one match no
251
+ matter how many lines it covers, and each line it crosses gets its own span.
252
+
253
+ The result always has exactly ``len(text.split("\n"))`` entries, so a caller
254
+ can index it by line number and check its own work against the same split.
255
+
256
+ The cost is scanning a whole file to colour the handful of lines a hunk
257
+ quotes from it — about 90ms for a 220 KB file, and `DiffConfig` already
258
+ refuses to diff anything past 512 KB. Skipping the scan for files with no
259
+ spanning construct in them would save that, at the price of a second path
260
+ through here that a later rule could silently fall out of sync with.
261
+ """
262
+ raw = text.split("\n")
263
+ grammar = GRAMMARS.get(FAMILY_BY_LANG.get(lang, ""))
264
+ if grammar is None or len(text) > MAX_FILE or any(len(line) > MAX_LINE for line in raw):
265
+ return [html.escape(line, quote=True) for line in raw]
266
+
267
+ lines: list[list[str]] = [[]]
268
+
269
+ def emit(chunk: str, cls: str) -> None:
270
+ for i, piece in enumerate(chunk.split("\n")):
271
+ if i:
272
+ lines.append([])
273
+ if piece:
274
+ escaped = html.escape(piece, quote=True)
275
+ lines[-1].append(f'<span class="tk-{cls}">{escaped}</span>' if cls else escaped)
276
+
277
+ pos = 0
278
+ for match in grammar.finditer(text):
279
+ start, end = match.span()
280
+ if start == end:
281
+ continue
282
+ if start > pos:
283
+ emit(text[pos:start], "")
284
+ emit(match.group(), match.lastgroup[0])
285
+ pos = end
286
+ emit(text[pos:], "")
287
+ return ["".join(parts) for parts in lines]
288
+
289
+
290
+ def highlight(text: str, lang: str) -> str:
291
+ """One line of source as escaped HTML, with token spans where we know the language.
292
+
293
+ Falls back to plain escaped text for anything unrecognised, so the caller
294
+ never needs to ask whether a language is supported.
295
+ """
296
+ grammar = GRAMMARS.get(FAMILY_BY_LANG.get(lang, ""))
297
+ if grammar is None or len(text) > MAX_LINE:
298
+ return html.escape(text, quote=True)
299
+
300
+ parts: list[str] = []
301
+ pos = 0
302
+ for match in grammar.finditer(text):
303
+ start, end = match.span()
304
+ if start == end: # a zero-width rule would colour nothing and stall nothing
305
+ continue
306
+ if start > pos:
307
+ parts.append(html.escape(text[pos:start], quote=True))
308
+ token = html.escape(match.group(), quote=True)
309
+ parts.append(f'<span class="tk-{match.lastgroup[0]}">{token}</span>')
310
+ pos = end
311
+ parts.append(html.escape(text[pos:], quote=True))
312
+ return "".join(parts)
@@ -0,0 +1,132 @@
1
+ """File-type icons for the report's file list.
2
+
3
+ Every row of a diff already spells out its path, so an icon that only repeated
4
+ the extension would be decoration. What it can add is *shape*: a scroll through
5
+ sixty changed files should show at a glance where the code is, where the
6
+ configuration is, and which one of them is a ``.env``.
7
+
8
+ So the icons are eight glyph families rather than sixty logos — nothing is
9
+ borrowed artwork, nothing needs a licence, and a language nobody thought of
10
+ still gets a sensible mark. The language it came from survives as colour.
11
+
12
+ The glyphs are drawn once into a hidden ``<symbol>`` sprite that the document
13
+ carries at the top of its body; each row then costs one ``<use>``. The colours
14
+ are emitted as one small stylesheet from the same table, so a row costs no
15
+ inline style either — which matters when a vendored diff runs to thousands of
16
+ files.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import PurePosixPath
22
+
23
+ # 16×16, stroked rather than filled so a single colour drives the whole glyph.
24
+ GLYPHS: dict[str, str] = {
25
+ "code": '<path d="M6.2 4.4 2.6 8l3.6 3.6"/><path d="M9.8 4.4 13.4 8l-3.6 3.6"/>',
26
+ "markup": '<path d="M5.4 3.6 2 8l3.4 4.4"/><path d="M10.6 3.6 14 8l-3.4 4.4"/>'
27
+ '<path d="M9.4 2.6 6.6 13.4"/>',
28
+ "data": '<path d="M6.6 2.8c-1.4 0-2 .7-2 1.9v1.4c0 1-.6 1.6-1.6 1.9 1 .3 1.6.9 1.6 1.9v1.4'
29
+ 'c0 1.2.6 1.9 2 1.9"/>'
30
+ '<path d="M9.4 2.8c1.4 0 2 .7 2 1.9v1.4c0 1 .6 1.6 1.6 1.9-1 .3-1.6.9-1.6 1.9v1.4'
31
+ 'c0 1.2-.6 1.9-2 1.9"/>',
32
+ "doc": '<path d="M3.2 4.2h9.6"/><path d="M3.2 8h9.6"/><path d="M3.2 11.8h5.8"/>',
33
+ "terminal": '<rect x="2.2" y="3.2" width="11.6" height="9.6" rx="2"/>'
34
+ '<path d="M4.9 6.9 7 9l-2.1 2.1"/><path d="M8.7 11.1h2.8"/>',
35
+ "style": '<path d="M8 2.4c2.6 2.7 4 4.7 4 6.3a4 4 0 0 1-8 0c0-1.6 1.4-3.6 4-6.3z"/>',
36
+ "lock": '<rect x="3.2" y="7" width="9.6" height="6.2" rx="1.6"/>'
37
+ '<path d="M5.6 7V5.4a2.4 2.4 0 0 1 4.8 0V7"/>',
38
+ "image": '<rect x="2.4" y="3.4" width="11.2" height="9.2" rx="1.8"/>'
39
+ '<circle cx="6" cy="6.9" r="1.1"/><path d="m3 11.8 3.2-3 2.4 2.3 2.2-1.8 2.8 2.5"/>',
40
+ "binary": '<path d="M8 2.4 13.6 5.4v5.2L8 13.6 2.4 10.6V5.4z"/>'
41
+ '<path d="M2.4 5.4 8 8.4l5.6-3"/><path d="M8 8.4v5.2"/>',
42
+ }
43
+
44
+ # Key -> (glyph, colour). The keys are the language labels `utils.language_for`
45
+ # produces plus the two below that no label can express, and one mid-tone colour
46
+ # each, because the report is read in both themes and the icon sits on the same
47
+ # panel in either one.
48
+ ICONS: dict[str, tuple[str, str]] = {
49
+ "python": ("code", "#4b8bbe"),
50
+ "javascript": ("code", "#c9a227"),
51
+ "typescript": ("code", "#3178c6"),
52
+ "java": ("code", "#c26a4a"),
53
+ "kotlin": ("code", "#9a6ef0"),
54
+ "go": ("code", "#23a3c4"),
55
+ "ruby": ("code", "#cc4b4b"),
56
+ "rust": ("code", "#c8763c"),
57
+ "csharp": ("code", "#8a63c4"),
58
+ "php": ("code", "#6e7fbc"),
59
+ "scala": ("code", "#c4574a"),
60
+ "swift": ("code", "#e0713d"),
61
+ "c": ("code", "#6d8bbd"),
62
+ "cpp": ("code", "#8a7fc4"),
63
+ "sql": ("code", "#4a9aa8"),
64
+ "shell": ("terminal", "#6ea84f"),
65
+ "powershell": ("terminal", "#3f7fd0"),
66
+ "dockerfile": ("terminal", "#2f8fd0"),
67
+ "makefile": ("terminal", "#8a8f98"),
68
+ "json": ("data", "#b58900"),
69
+ "yaml": ("data", "#9068c0"),
70
+ "toml": ("data", "#a5713d"),
71
+ "ini": ("data", "#8a8f98"),
72
+ "html": ("markup", "#e06c50"),
73
+ "xml": ("markup", "#d4713f"),
74
+ "css": ("style", "#3c9ad9"),
75
+ "markdown": ("doc", "#7a8290"),
76
+ "csv": ("doc", "#5f9e6e"),
77
+ "text": ("doc", "#8b8f97"),
78
+ "dotenv": ("lock", "#b58900"),
79
+ "binary": ("binary", "#8b8f97"),
80
+ # Two keys no language label produces. An image is indexed as `binary`,
81
+ # the same label a shared object gets; and a file that carries credentials
82
+ # is worth a lock whatever syntax happens to be inside it.
83
+ "image": ("image", "#a06fc0"),
84
+ "_other": ("doc", "#8b8f97"),
85
+ }
86
+
87
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"}
88
+ SECRET_NAMES = ("credentials", "id_rsa", ".netrc", ".pem", ".key", ".p12", ".pfx")
89
+
90
+
91
+ def icon_key(path: str, lang: str) -> str:
92
+ """Which entry of ``ICONS`` a file gets — what the path says first, then its language.
93
+
94
+ ``.env`` files need no rule here: they reach this point already labelled
95
+ ``dotenv`` by `highlight.language_of`, which is where reading a name for a
96
+ language belongs.
97
+ """
98
+ name = PurePosixPath(path).name.lower()
99
+ if PurePosixPath(name).suffix in IMAGE_SUFFIXES:
100
+ return "image"
101
+ if name.startswith(SECRET_NAMES) or name.endswith(SECRET_NAMES):
102
+ return "dotenv"
103
+ return lang if lang in ICONS else "_other"
104
+
105
+
106
+ def file_icon(path: str, lang: str) -> str:
107
+ """A decorative icon for one file: the path beside it already names it."""
108
+ key = icon_key(path, lang)
109
+ glyph = ICONS[key][0]
110
+ return (
111
+ f'<svg class="fic fic-{key}" viewBox="0 0 16 16" aria-hidden="true">'
112
+ f'<use href="#g-{glyph}"/></svg>'
113
+ )
114
+
115
+
116
+ def sprite() -> str:
117
+ """The hidden glyph sheet every ``file_icon`` points at, emitted once."""
118
+ symbols = "".join(
119
+ f'<symbol id="g-{name}" viewBox="0 0 16 16">{body}</symbol>' for name, body in GLYPHS.items()
120
+ )
121
+ return f'<svg class="sprite" aria-hidden="true">{symbols}</svg>'
122
+
123
+
124
+ def css() -> str:
125
+ """One colour rule per key, generated from the table above."""
126
+ base = [
127
+ "svg.sprite { display: none; }",
128
+ ".fic { width: 16px; height: 16px; flex: 0 0 auto; fill: none; stroke: currentColor;",
129
+ " stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }",
130
+ ]
131
+ rules = [f".fic-{key} {{ color: {colour}; }}" for key, (_, colour) in ICONS.items()]
132
+ return "\n".join(base + rules) + "\n"
@@ -0,0 +1,162 @@
1
+ """Which *words* of a changed line actually changed.
2
+
3
+ A diff that paints a whole line green says "this line is new". Most of the time
4
+ it is not new — three characters of it are, and the eye has to re-read forty to
5
+ find them. This module finds those characters, and marks them inside a line the
6
+ highlighter has already coloured.
7
+
8
+ Two problems, kept apart:
9
+
10
+ `pair_rows` decides *which* removed line a given added line is a rewrite of.
11
+ Getting that wrong is worse than not marking at all — a confident mark on an
12
+ unrelated pair sends the reader hunting for a change that is not there — so the
13
+ pairing is deliberately shy: it takes the strongest match for each line, and
14
+ only when the two are more alike than not.
15
+
16
+ `mark` then wraps the differing ranges. The line reaching it is already HTML,
17
+ so the wrapper is split at every tag boundary rather than straddling one: two
18
+ adjacent marks paint one continuous background, and the nesting stays
19
+ well-formed whatever `highlight` decided to emit.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from difflib import SequenceMatcher
26
+
27
+ #: Words, runs of whitespace, and every other character on its own. Splitting
28
+ #: this way means a renamed identifier marks as one unit instead of dissolving
29
+ #: into the letters it happens to share with the old name.
30
+ _TOKEN = re.compile(r"\w+|\s+|.")
31
+
32
+ #: A tag, an entity, or a run of neither — the three things a highlighted line
33
+ #: is made of. Entities matter because `&amp;` is five characters of markup and
34
+ #: one character of the file.
35
+ _PIECE = re.compile(r"<[^>]*>|&[#A-Za-z0-9]+;|[^<&]+|.")
36
+
37
+ #: Below this, "rewrite" is a fiction: the two lines have little in common and
38
+ #: marking their scattered shared characters would be confetti, not information.
39
+ MIN_SIMILARITY = 0.5
40
+
41
+ #: Above this, almost the whole line is marked and the marks stop distinguishing
42
+ #: anything — the plain add/remove wash already said it.
43
+ MAX_MARKED = 0.75
44
+
45
+ #: Pairing is quadratic in the size of a replaced block. Blocks this large are
46
+ #: wholesale rewrites, where per-word marks would not help anyone anyway.
47
+ MAX_BLOCK = 40
48
+
49
+
50
+ def _tokens(text: str) -> list[str]:
51
+ return _TOKEN.findall(text)
52
+
53
+
54
+ def _ranges(tokens: list[str], opcodes: list[tuple], side: int) -> list[tuple[int, int]]:
55
+ """Character ranges of the tokens this side does not share with the other."""
56
+ starts, offset = [], 0
57
+ for token in tokens:
58
+ starts.append(offset)
59
+ offset += len(token)
60
+ starts.append(offset)
61
+
62
+ spans: list[tuple[int, int]] = []
63
+ for tag, i1, i2, j1, j2 in opcodes:
64
+ if tag == "equal":
65
+ continue
66
+ lo, hi = (i1, i2) if side == 0 else (j1, j2)
67
+ if lo == hi:
68
+ continue
69
+ if spans and spans[-1][1] == starts[lo]:
70
+ spans[-1] = (spans[-1][0], starts[hi])
71
+ else:
72
+ spans.append((starts[lo], starts[hi]))
73
+ return spans
74
+
75
+
76
+ def word_diff(before: str, after: str) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
77
+ """The differing character ranges of two lines, as ``(before, after)``.
78
+
79
+ Returns empty lists when marking would not help: when the lines are too
80
+ unlike to be a rewrite of each other, and when so much of them differs that
81
+ the marks would cover the line they are supposed to pick things out of.
82
+ """
83
+ if before == after:
84
+ return [], []
85
+ old, new = _tokens(before), _tokens(after)
86
+ matcher = SequenceMatcher(None, old, new, autojunk=False)
87
+ if matcher.ratio() < MIN_SIMILARITY:
88
+ return [], []
89
+
90
+ opcodes = matcher.get_opcodes()
91
+ old_spans = _ranges(old, opcodes, 0)
92
+ new_spans = _ranges(new, opcodes, 1)
93
+ marked = sum(hi - lo for lo, hi in old_spans) + sum(hi - lo for lo, hi in new_spans)
94
+ if marked > MAX_MARKED * (len(before) + len(after)):
95
+ return [], []
96
+ return old_spans, new_spans
97
+
98
+
99
+ def pair_rows(removed: list[str], added: list[str]) -> dict[tuple[int, int], None]:
100
+ """Which removed line each added line rewrites, as ``{(old_i, new_i)}``.
101
+
102
+ Strongest pair first, each line spoken for once. A line whose best partner
103
+ is still mostly different stays unpaired, and is painted the plain way.
104
+ """
105
+ if not removed or not added or len(removed) > MAX_BLOCK or len(added) > MAX_BLOCK:
106
+ return {}
107
+
108
+ scored = []
109
+ for i, old in enumerate(removed):
110
+ for j, new in enumerate(added):
111
+ ratio = SequenceMatcher(None, old, new, autojunk=False).ratio()
112
+ if ratio >= MIN_SIMILARITY:
113
+ scored.append((ratio, i, j))
114
+
115
+ pairs: dict[tuple[int, int], None] = {}
116
+ used_old: set[int] = set()
117
+ used_new: set[int] = set()
118
+ for _, i, j in sorted(scored, key=lambda s: (-s[0], s[1], s[2])):
119
+ if i in used_old or j in used_new:
120
+ continue
121
+ used_old.add(i)
122
+ used_new.add(j)
123
+ pairs[(i, j)] = None
124
+ return pairs
125
+
126
+
127
+ def mark(rendered: str, spans: list[tuple[int, int]], css: str = "wd") -> str:
128
+ """Wrap the given character ranges of an already-highlighted line.
129
+
130
+ ``spans`` index the *plain text* of the line — the same offsets `word_diff`
131
+ returned — while ``rendered`` is what `highlight` made of it. Walking the
132
+ two together is the whole job: markup is stepped over, an entity counts as
133
+ the one character it stands for, and a range that reaches across a syntax
134
+ span is emitted as one wrapper per piece it covers.
135
+ """
136
+ if not spans:
137
+ return rendered
138
+
139
+ def covered(start: int, stop: int) -> list[tuple[int, int]]:
140
+ return [(max(lo, start), min(hi, stop)) for lo, hi in spans if lo < stop and hi > start]
141
+
142
+ out: list[str] = []
143
+ at = 0
144
+ for piece in _PIECE.findall(rendered):
145
+ if piece.startswith("<"):
146
+ out.append(piece)
147
+ continue
148
+ width = 1 if piece.startswith("&") else len(piece)
149
+ hits = covered(at, at + width)
150
+ if not hits:
151
+ out.append(piece)
152
+ elif piece.startswith("&"):
153
+ out.append(f'<span class="{css}">{piece}</span>')
154
+ else:
155
+ cursor = at
156
+ for lo, hi in hits:
157
+ out.append(piece[cursor - at : lo - at])
158
+ out.append(f'<span class="{css}">{piece[lo - at : hi - at]}</span>')
159
+ cursor = hi
160
+ out.append(piece[cursor - at :])
161
+ at += width
162
+ return "".join(out)