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.
- lambda_watcher/__init__.py +4 -0
- lambda_watcher/__main__.py +4 -0
- lambda_watcher/analysis/__init__.py +115 -0
- lambda_watcher/analysis/deps.py +291 -0
- lambda_watcher/analysis/envvars.py +80 -0
- lambda_watcher/analysis/handler.py +111 -0
- lambda_watcher/analysis/inventory.py +118 -0
- lambda_watcher/analysis/runtime.py +117 -0
- lambda_watcher/analysis/secrets.py +178 -0
- lambda_watcher/analysis/services.py +76 -0
- lambda_watcher/cli.py +1406 -0
- lambda_watcher/config.py +324 -0
- lambda_watcher/db.py +466 -0
- lambda_watcher/diffing/__init__.py +14 -0
- lambda_watcher/diffing/build.py +51 -0
- lambda_watcher/diffing/compare.py +525 -0
- lambda_watcher/diffing/highlight.py +312 -0
- lambda_watcher/diffing/icons.py +132 -0
- lambda_watcher/diffing/intraline.py +162 -0
- lambda_watcher/diffing/render_html.py +697 -0
- lambda_watcher/diffing/render_text.py +198 -0
- lambda_watcher/extract.py +227 -0
- lambda_watcher/gitmirror.py +151 -0
- lambda_watcher/identify.py +201 -0
- lambda_watcher/ingest.py +480 -0
- lambda_watcher/notify.py +59 -0
- lambda_watcher/reindex.py +158 -0
- lambda_watcher/service.py +553 -0
- lambda_watcher/store.py +209 -0
- lambda_watcher/templates.py +124 -0
- lambda_watcher/utils.py +314 -0
- lambda_watcher/watcher.py +241 -0
- lambda_watcher-0.1.0.dist-info/METADATA +409 -0
- lambda_watcher-0.1.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
- lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
- lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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 `&` 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)
|