omakit 0.4.2 → 0.4.3

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.
@@ -0,0 +1,496 @@
1
+ // The text primitives every extractor shares: line numbers, brace-delimited
2
+ // blocks, a property's raw value inside a block, string and array literals,
3
+ // and the crude shell word split. All of it is regular expressions and
4
+ // bracket counting over raw text, with no parser behind it, which is why
5
+ // every row the extractors produce is labelled observed and never claimed
6
+ // as complete.
7
+
8
+ /** 1-based line number of a character offset. */
9
+ export function lineOf(text, index) {
10
+ let line = 1
11
+ for (let i = 0; i < index && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1
12
+ return line
13
+ }
14
+
15
+ /**
16
+ * QML and JavaScript comments replaced by spaces, so a `Process {` in a
17
+ * comment is not a process and every offset still maps to its line.
18
+ */
19
+ export function blankComments(text) {
20
+ let out = ""
21
+ let i = 0
22
+ let quote = null
23
+ while (i < text.length) {
24
+ const ch = text[i]
25
+ const next = text[i + 1]
26
+ if (quote) {
27
+ out += ch
28
+ if (ch === "\\" && i + 1 < text.length) {
29
+ out += next
30
+ i += 2
31
+ continue
32
+ }
33
+ if (ch === quote) quote = null
34
+ i += 1
35
+ continue
36
+ }
37
+ if (ch === '"' || ch === "'" || ch === "`") {
38
+ quote = ch
39
+ out += ch
40
+ i += 1
41
+ continue
42
+ }
43
+ if (ch === "/" && next === "/") {
44
+ while (i < text.length && text[i] !== "\n") {
45
+ out += " "
46
+ i += 1
47
+ }
48
+ continue
49
+ }
50
+ if (ch === "/" && next === "*") {
51
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
52
+ out += text[i] === "\n" ? "\n" : " "
53
+ i += 1
54
+ }
55
+ out += " "
56
+ i += 2
57
+ continue
58
+ }
59
+ out += ch
60
+ i += 1
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** The offset of the bracket that closes the one at `open`, strings skipped; -1 when unbalanced. */
66
+ export function closingBracket(text, open) {
67
+ const pairs = { "{": "}", "[": "]", "(": ")" }
68
+ const close = pairs[text[open]]
69
+ let depth = 0
70
+ let quote = null
71
+ for (let i = open; i < text.length; i += 1) {
72
+ const ch = text[i]
73
+ if (quote) {
74
+ if (ch === "\\") i += 1
75
+ else if (ch === quote) quote = null
76
+ continue
77
+ }
78
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
79
+ else if (ch === text[open]) depth += 1
80
+ else if (ch === close) {
81
+ depth -= 1
82
+ if (depth === 0) return i
83
+ }
84
+ }
85
+ return -1
86
+ }
87
+
88
+ /**
89
+ * Every `Name {` block in a text: where it starts, its body, its line and its
90
+ * `id:`. `Name` is matched as a whole word, so `Process {` is found and
91
+ * `SubProcess {` is not. The text is expected to have its comments blanked.
92
+ */
93
+ export function blocks(text, name) {
94
+ const found = []
95
+ const pattern = new RegExp(`(?<![\\w.])${name}\\s*\\{`, "g")
96
+ for (const match of text.matchAll(pattern)) {
97
+ const open = match.index + match[0].length - 1
98
+ const end = closingBracket(text, open)
99
+ if (end < 0) continue
100
+ const body = text.slice(open + 1, end)
101
+ found.push({ name, start: match.index, open, end, line: lineOf(text, match.index), body, id: idOf(body) })
102
+ }
103
+ return found
104
+ }
105
+
106
+ /** The `id:` of a block body, at any depth zero position. */
107
+ export function idOf(body) {
108
+ const value = propertyValue(body, "id")
109
+ return value && /^[A-Za-z_]\w*$/.test(value.text) ? value.text : null
110
+ }
111
+
112
+ /**
113
+ * The raw value of `name:` at depth zero of a block body: from the colon to
114
+ * the end of the line, or to the bracket that closes a value opening with
115
+ * `[`, `{` or `(`, so a multi-line array is one value. `null` when the
116
+ * property is not declared at that depth.
117
+ * @returns {{ text: string, offset: number }|null} offset is into `body`
118
+ */
119
+ export function propertyValue(body, name) {
120
+ const pattern = new RegExp(`(?<![\\w.])${name}\\s*:(?!=)`, "g")
121
+ for (const match of body.matchAll(pattern)) {
122
+ if (depthAt(body, match.index) !== 0) continue
123
+ // `property int name: value` declares; `name: value` binds. Both are
124
+ // the value of the property, so both are read.
125
+ let start = match.index + match[0].length
126
+ while (start < body.length && (body[start] === " " || body[start] === "\t")) start += 1
127
+ const first = body[start]
128
+ if (first === "[" || first === "{" || first === "(") {
129
+ const end = closingBracket(body, start)
130
+ if (end > 0) return { text: body.slice(start, end + 1), offset: start }
131
+ }
132
+ // The value runs to the end of the line, or to a `;` outside quotes
133
+ // and brackets, since QML allows `id: a; interval: 1000` on one line.
134
+ let end = start
135
+ let quote = null
136
+ let depth = 0
137
+ while (end < body.length && body[end] !== "\n") {
138
+ const ch = body[end]
139
+ if (quote) {
140
+ if (ch === "\\") end += 1
141
+ else if (ch === quote) quote = null
142
+ } else if (ch === '"' || ch === "'" || ch === "`") quote = ch
143
+ else if (ch === "(" || ch === "[" || ch === "{") depth += 1
144
+ else if (ch === ")" || ch === "]" || ch === "}") depth -= 1
145
+ else if (ch === ";" && depth === 0) break
146
+ end += 1
147
+ }
148
+ return { text: body.slice(start, end).trim(), offset: start }
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * The inner text of every `$(...)` and backtick substitution in a shell
155
+ * line, so `t=$(mktemp)` is read as running `mktemp`. Nested substitutions
156
+ * are returned from the outside in.
157
+ */
158
+ export function shellSubstitutions(line) {
159
+ const out = []
160
+ for (let i = 0; i < line.length; i += 1) {
161
+ if (line[i] === "$" && line[i + 1] === "(") {
162
+ const end = closingBracket(line, i + 1)
163
+ if (end > 0) {
164
+ out.push(line.slice(i + 2, end))
165
+ continue
166
+ }
167
+ }
168
+ if (line[i] === "`") {
169
+ const end = line.indexOf("`", i + 1)
170
+ if (end > 0) {
171
+ out.push(line.slice(i + 1, end))
172
+ i = end
173
+ }
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** Bracket depth at an offset, strings skipped. */
180
+ export function depthAt(text, at) {
181
+ let depth = 0
182
+ let quote = null
183
+ for (let i = 0; i < at; i += 1) {
184
+ const ch = text[i]
185
+ if (quote) {
186
+ if (ch === "\\") i += 1
187
+ else if (ch === quote) quote = null
188
+ continue
189
+ }
190
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
191
+ else if (ch === "{" || ch === "[" || ch === "(") depth += 1
192
+ else if (ch === "}" || ch === "]" || ch === ")") depth -= 1
193
+ }
194
+ return depth
195
+ }
196
+
197
+ /**
198
+ * A string literal's value, or null when the text is not one literal: a
199
+ * quoted string, or a template literal with no `${`. Escapes are unescaped
200
+ * the JavaScript way for the common cases.
201
+ */
202
+ export function stringLiteral(text) {
203
+ const t = String(text).trim()
204
+ const match = t.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1$/s)
205
+ if (!match) return null
206
+ if (match[1] === "`" && match[2].includes("${")) return null
207
+ return match[2].replace(/\\(.)/g, (_, ch) => ({ n: "\n", t: "\t", r: "\r" }[ch] ?? ch))
208
+ }
209
+
210
+ /**
211
+ * The elements of an array literal `[a, b, c]`, each with its source text
212
+ * and its literal value when it is one. Commas inside strings and nested
213
+ * brackets are not separators. `null` when the text is not an array literal.
214
+ */
215
+ export function arrayLiteral(text) {
216
+ const t = String(text).trim()
217
+ if (!t.startsWith("[") || closingBracket(t, 0) !== t.length - 1) return null
218
+ const inner = t.slice(1, -1)
219
+ const elements = []
220
+ let depth = 0
221
+ let quote = null
222
+ let current = ""
223
+ const flush = () => {
224
+ const source = current.trim()
225
+ if (source) elements.push({ text: source, literal: stringLiteral(source) })
226
+ current = ""
227
+ }
228
+ for (let i = 0; i < inner.length; i += 1) {
229
+ const ch = inner[i]
230
+ if (quote) {
231
+ current += ch
232
+ if (ch === "\\" && i + 1 < inner.length) {
233
+ current += inner[i + 1]
234
+ i += 1
235
+ } else if (ch === quote) quote = null
236
+ continue
237
+ }
238
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
239
+ else if (ch === "[" || ch === "{" || ch === "(") depth += 1
240
+ else if (ch === "]" || ch === "}" || ch === ")") depth -= 1
241
+ else if (ch === "," && depth === 0) {
242
+ flush()
243
+ continue
244
+ }
245
+ current += ch
246
+ }
247
+ flush()
248
+ return elements
249
+ }
250
+
251
+ /**
252
+ * A shell line split into words the way a reader would: quotes keep a word
253
+ * whole and are dropped, a backslash escapes the next character, and the
254
+ * split is on whitespace. No expansion, no globbing: `$HOME/x` stays the
255
+ * text `$HOME/x`.
256
+ */
257
+ export function shellWords(text) {
258
+ const words = []
259
+ let current = ""
260
+ let quote = null
261
+ let started = false
262
+ let depth = 0
263
+ for (let i = 0; i < text.length; i += 1) {
264
+ const ch = text[i]
265
+ if (quote) {
266
+ if (ch === quote) quote = null
267
+ else if (ch === "\\" && quote === '"' && i + 1 < text.length) {
268
+ current += text[i + 1]
269
+ i += 1
270
+ } else current += ch
271
+ continue
272
+ }
273
+ if (ch === '"' || ch === "'") {
274
+ quote = ch
275
+ started = true
276
+ continue
277
+ }
278
+ if (ch === "\\" && i + 1 < text.length) {
279
+ current += text[i + 1]
280
+ started = true
281
+ i += 1
282
+ continue
283
+ }
284
+ // A `$(...)` or `(...)` is one word however many spaces it holds: the
285
+ // assignment `x=$(curl -fsS "$url")` is one assignment, and its command
286
+ // is read from the substitution on its own.
287
+ if (ch === "(") depth += 1
288
+ else if (ch === ")" && depth > 0) depth -= 1
289
+ if (depth === 0 && /\s/.test(ch)) {
290
+ if (started) words.push(current)
291
+ current = ""
292
+ started = false
293
+ continue
294
+ }
295
+ current += ch
296
+ started = true
297
+ }
298
+ if (started) words.push(current)
299
+ return words
300
+ }
301
+
302
+ /**
303
+ * A shell line cut at its unquoted `|`, `;`, `&&` and `||`, each piece with
304
+ * the operator that led into it. `2>&1` and `>&2` are not operators.
305
+ * @returns {Array<{ text: string, operator: string|null }>}
306
+ */
307
+ export function shellSegments(line) {
308
+ const segments = []
309
+ let current = ""
310
+ let quote = null
311
+ let operator = null
312
+ let depth = 0
313
+ const push = (next) => {
314
+ if (current.trim()) segments.push({ text: current.trim(), operator })
315
+ current = ""
316
+ operator = next
317
+ }
318
+ for (let i = 0; i < line.length; i += 1) {
319
+ const ch = line[i]
320
+ const next = line[i + 1]
321
+ if (quote) {
322
+ current += ch
323
+ if (ch === "\\" && i + 1 < line.length) {
324
+ current += next
325
+ i += 1
326
+ } else if (ch === quote) quote = null
327
+ continue
328
+ }
329
+ if (ch === '"' || ch === "'") {
330
+ quote = ch
331
+ current += ch
332
+ continue
333
+ }
334
+ if (ch === "\\" && i + 1 < line.length) {
335
+ current += ch + next
336
+ i += 1
337
+ continue
338
+ }
339
+ // Inside `$(...)` or a subshell the operators belong to the inner
340
+ // command line, which is read on its own through shellPieces.
341
+ if (ch === "(") depth += 1
342
+ else if (ch === ")" && depth > 0) depth -= 1
343
+ if (depth > 0) {
344
+ current += ch
345
+ continue
346
+ }
347
+ if (ch === "&" && next === "&") {
348
+ push("&&")
349
+ i += 1
350
+ continue
351
+ }
352
+ if (ch === "|" && next === "|") {
353
+ push("||")
354
+ i += 1
355
+ continue
356
+ }
357
+ if (ch === "|" && next !== "&") {
358
+ push("|")
359
+ continue
360
+ }
361
+ if (ch === "|" && next === "&") {
362
+ push("|&")
363
+ i += 1
364
+ continue
365
+ }
366
+ if (ch === ";" && next === ";") {
367
+ push(";")
368
+ i += 1
369
+ continue
370
+ }
371
+ if (ch === ";" || ch === "\n") {
372
+ push(";")
373
+ continue
374
+ }
375
+ current += ch
376
+ }
377
+ push(null)
378
+ return segments
379
+ }
380
+
381
+ /** The words with every redirection and its target left out: `> path`, `2>&1`, `<file`, `&>log`. */
382
+ export function withoutRedirections(words) {
383
+ const out = []
384
+ for (let index = 0; index < words.length; index += 1) {
385
+ const word = words[index]
386
+ if (/^(?:\d*>>?|&>>?|<{1,3}|\d*<)$/.test(word)) {
387
+ index += 1
388
+ continue
389
+ }
390
+ if (/^(?:\d*>>?|&>>?|<{1,3}|\d*<)\S/.test(word)) continue
391
+ out.push(word)
392
+ }
393
+ return out
394
+ }
395
+
396
+ /**
397
+ * A shell script as logical lines, each keeping the number it started on:
398
+ * a backslash continuation joins the next line, a quote left open joins
399
+ * lines until it closes (a multi-line jq or awk program is one word), and
400
+ * a here-document's body is dropped, since it is data the command reads
401
+ * and not commands the shell runs.
402
+ */
403
+ export function shellLogicalLines(text) {
404
+ const out = []
405
+ const lines = text.split("\n")
406
+ // Open when a quote, or a `$(` or `(` outside quotes, has not closed by
407
+ // the end of the line: the next line continues the same command.
408
+ const unbalanced = (line) => {
409
+ let quote = null
410
+ let depth = 0
411
+ for (let i = 0; i < line.length; i += 1) {
412
+ const ch = line[i]
413
+ if (quote) {
414
+ if (ch === "\\" && quote === '"') i += 1
415
+ else if (ch === quote) quote = null
416
+ } else if (ch === "\\") i += 1
417
+ else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) break
418
+ else if (ch === '"' || ch === "'") quote = ch
419
+ else if (ch === "(") depth += 1
420
+ else if (ch === ")") depth = Math.max(0, depth - 1)
421
+ }
422
+ return quote !== null || depth > 0
423
+ }
424
+ for (let index = 0; index < lines.length; index += 1) {
425
+ let line = lines[index]
426
+ const start = index + 1
427
+ while ((/\\$/.test(line) || unbalanced(line)) && index + 1 < lines.length) {
428
+ index += 1
429
+ line = /\\$/.test(line) ? `${line.slice(0, -1)} ${lines[index]}` : `${line}\n${lines[index]}`
430
+ }
431
+ out.push({ line: start, text: line })
432
+ const heredoc = line.match(/<<-?\s*(["']?)([A-Za-z_][\w-]*)\1/)
433
+ if (heredoc) {
434
+ const stop = heredoc[2]
435
+ while (index + 1 < lines.length && lines[index + 1].replace(/^\t+/, "") !== stop) index += 1
436
+ index += 1
437
+ }
438
+ }
439
+ return out
440
+ }
441
+
442
+ /**
443
+ * Arithmetic and test contexts blanked to spaces, length kept: what sits
444
+ * inside `$(( ))`, `(( ))` and `[[ ]]` is an expression, so a `>` there is
445
+ * a comparison and a word there is not a tool.
446
+ */
447
+ export function blankShellExpressions(line) {
448
+ let out = line
449
+ let from = 0
450
+ for (;;) {
451
+ const at = out.indexOf("((", from)
452
+ if (at < 0) break
453
+ const end = closingBracket(out, at)
454
+ if (end < 0) break
455
+ out = `${out.slice(0, at + 2)}${" ".repeat(Math.max(0, end - at - 3))}${out.slice(end - 1)}`
456
+ from = end + 1
457
+ }
458
+ from = 0
459
+ for (;;) {
460
+ const at = out.indexOf("[[", from)
461
+ if (at < 0) break
462
+ const end = out.indexOf("]]", at + 2)
463
+ if (end < 0) break
464
+ out = `${out.slice(0, at + 2)}${" ".repeat(end - at - 2)}${out.slice(end)}`
465
+ from = end + 2
466
+ }
467
+ return out
468
+ }
469
+
470
+ /**
471
+ * Every command line inside a shell line, flattened: the line's own
472
+ * segments, then, for each, the command lines inside its `$(...)` and
473
+ * backtick substitutions and its `(...)` subshells, recursively, each
474
+ * split into segments in turn. A segment that is only a subshell is
475
+ * replaced by what runs inside it.
476
+ * @returns {Array<{ text: string, operator: string|null }>}
477
+ */
478
+ export function shellPieces(text, depth = 0) {
479
+ if (depth > 8) return []
480
+ const out = []
481
+ for (const segment of shellSegments(text)) {
482
+ const subshell = segment.text.startsWith("(") && closingBracket(segment.text, 0) === segment.text.length - 1
483
+ if (subshell) {
484
+ out.push(...shellPieces(segment.text.slice(1, -1), depth + 1).map((piece, index) => (index === 0 ? { ...piece, operator: segment.operator } : piece)))
485
+ continue
486
+ }
487
+ out.push(segment)
488
+ for (const inner of shellSubstitutions(segment.text)) out.push(...shellPieces(inner, depth + 1))
489
+ }
490
+ return out
491
+ }
492
+
493
+ /** The last path segment of a command word: `/usr/bin/curl` -> `curl`. */
494
+ export function basename(word) {
495
+ return String(word).split("/").pop()
496
+ }
@@ -0,0 +1,65 @@
1
+ // Timer sites: every `Timer {` block in QML with its interval, whether it
2
+ // repeats, whether it runs from the start, whether it fires on start, and
3
+ // the handler outside the block that starts it. An interval that is an
4
+ // expression is recorded as not resolvable with the expression text, never
5
+ // as a number.
6
+
7
+ import { blankComments, blocks, closingBracket, lineOf, propertyValue } from "./text.mjs"
8
+
9
+ function flag(body, name) {
10
+ const value = propertyValue(body, name)?.text
11
+ if (value === undefined) return false
12
+ if (value === "true") return true
13
+ if (value === "false") return false
14
+ return null
15
+ }
16
+
17
+ /** The handler a `<id>.start()`, `.restart()` or `.running = true` sits in, by the nearest enclosing `onSomething:` or `function name(` above it. */
18
+ function startedBy(text, id, exclude) {
19
+ if (!id) return null
20
+ const pattern = new RegExp(`(?<![\\w.])${id}\\.(?:start\\s*\\(|restart\\s*\\(|running\\s*=\\s*true)`, "g")
21
+ for (const match of text.matchAll(pattern)) {
22
+ if (match.index >= exclude.start && match.index <= exclude.end) continue
23
+ const before = text.slice(0, match.index)
24
+ const handlers = [...before.matchAll(/(?<![\w.])(on[A-Z]\w*)\s*:|(?<![\w.])function\s+(\w+)\s*\(/g)]
25
+ for (let index = handlers.length - 1; index >= 0; index -= 1) {
26
+ const handler = handlers[index]
27
+ // The handler encloses the call when its value block has not closed before the call.
28
+ const colon = handler.index + handler[0].length
29
+ const rest = text.slice(colon).replace(/^\s+/, "")
30
+ const at = colon + (text.length - colon - rest.length)
31
+ const end = text[at] === "{" ? closingBracket(text, at) : text.indexOf("\n", at)
32
+ if (end >= match.index) return handler[1] || handler[2]
33
+ }
34
+ return "script"
35
+ }
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * @param {{ path: string, kind: string, text: string }} file
41
+ * @returns {{ timers: Array, notResolvable: Array }}
42
+ */
43
+ export function extractTimers(file) {
44
+ if (file.kind !== "qml") return { timers: [], notResolvable: [] }
45
+ const text = blankComments(file.text)
46
+ const timers = []
47
+ const notResolvable = []
48
+ for (const block of blocks(text, "Timer")) {
49
+ const interval = propertyValue(block.body, "interval")?.text ?? null
50
+ const intervalMs = interval !== null && /^\d+$/.test(interval) ? Number(interval) : null
51
+ if (interval !== null && intervalMs === null) notResolvable.push({ file: file.path, line: lineOf(text, block.open + 1 + propertyValue(block.body, "interval").offset), kind: "timer-interval", text: interval })
52
+ timers.push({
53
+ file: file.path,
54
+ line: block.line,
55
+ id: block.id,
56
+ intervalMs,
57
+ intervalText: intervalMs === null ? interval : null,
58
+ repeat: flag(block.body, "repeat"),
59
+ running: flag(block.body, "running"),
60
+ triggeredOnStart: flag(block.body, "triggeredOnStart"),
61
+ startedBy: startedBy(text, block.id, block),
62
+ })
63
+ }
64
+ return { timers, notResolvable }
65
+ }
@@ -0,0 +1,102 @@
1
+ // The installable tree of the subject, filtered to what `inspect` reads for
2
+ // facts: QML, JavaScript, shell and Python by extension, and any other blob
3
+ // that opens with a shebang or carries the executable bit. Prose is never
4
+ // read for facts (the 0.1 Passport counted a README sentence naming `curl`
5
+ // as a process); `manifest.json` is read for the id and the entry points,
6
+ // and the README only to answer whether a privileged tool the argv names
7
+ // is mentioned there, which is a question about the README, not a fact
8
+ // about the tree.
9
+ //
10
+ // Read-only, from the Git object database at the subject commit through
11
+ // tree.mjs (`git ls-tree`, `git cat-file`), so a working-tree edit that is
12
+ // not committed is not inspected and nothing in the tree is opened as a
13
+ // file.
14
+
15
+ import { isBlob, readBlob, readText, readTree } from "../marketplace/tree.mjs"
16
+
17
+ /** The extensions that name a kind outright. */
18
+ const KIND_BY_EXTENSION = Object.freeze({
19
+ qml: "qml",
20
+ js: "js",
21
+ mjs: "js",
22
+ cjs: "js",
23
+ sh: "shell",
24
+ bash: "shell",
25
+ zsh: "shell",
26
+ fish: "shell",
27
+ py: "python",
28
+ })
29
+
30
+ /** The interpreter a shebang names, as a kind; anything else is `other`. */
31
+ export function kindOfShebang(line) {
32
+ const first = String(line || "").split("\n")[0]
33
+ if (!first.startsWith("#!")) return null
34
+ const words = first.slice(2).trim().split(/\s+/)
35
+ const interpreter = (words[0] === "/usr/bin/env" ? words[1] : words[0]) || ""
36
+ const name = interpreter.split("/").pop().replace(/\d+(?:\.\d+)*$/, "")
37
+ if (["sh", "bash", "zsh", "fish", "dash", "ksh"].includes(name)) return "shell"
38
+ if (name === "python") return "python"
39
+ if (["node", "deno", "bun"].includes(name)) return "js"
40
+ return "other"
41
+ }
42
+
43
+ /** Paths whose every segment is visible: `.git/`, `.github/` and any dot entry are out, as they are for the installable tree. */
44
+ function visible(path) {
45
+ return !path.split("/").some((segment) => segment.startsWith("."))
46
+ }
47
+
48
+ const README = /^readme(?:\.[^/]+)?$/i
49
+ const EXECUTABLE = "100755"
50
+ const TEXT_LIMIT = 1024 * 1024
51
+
52
+ /**
53
+ * @param {{ dir: string, commit: string, subdir?: string }} subject
54
+ * @returns {{
55
+ * files: Array<{ path: string, kind: "qml"|"js"|"shell"|"python"|"other", text: string, shebang: string|null, executable: boolean }>,
56
+ * filesRead: { qml: number, js: number, shell: number, python: number, other: number },
57
+ * manifest: object|null, manifestError: string|null, readme: string|null, entries: Array,
58
+ * }}
59
+ */
60
+ export function walkSubject(subject) {
61
+ const prefix = subject.subdir ? `${subject.subdir}/` : ""
62
+ const entries = readTree(subject.dir, subject.commit)
63
+ .filter((entry) => isBlob(entry) && entry.path.startsWith(prefix))
64
+ .map((entry) => ({ ...entry, path: entry.path.slice(prefix.length) }))
65
+ .filter((entry) => visible(entry.path))
66
+ const files = []
67
+ let manifest = null
68
+ let manifestError = null
69
+ let readme = null
70
+ for (const entry of entries) {
71
+ if (entry.path === "manifest.json") {
72
+ try {
73
+ manifest = JSON.parse(readText(subject.dir, entry.sha))
74
+ } catch (error) {
75
+ manifestError = error.message
76
+ }
77
+ continue
78
+ }
79
+ if (!entry.path.includes("/") && README.test(entry.path)) {
80
+ readme = readText(subject.dir, entry.sha, TEXT_LIMIT)
81
+ continue
82
+ }
83
+ const extension = entry.path.includes(".") ? entry.path.split(".").pop().toLowerCase() : ""
84
+ let kind = KIND_BY_EXTENSION[extension] || null
85
+ const executable = entry.mode === EXECUTABLE
86
+ let shebang = null
87
+ if (kind || executable || entry.size <= TEXT_LIMIT) {
88
+ // A file without a naming extension is opened only far enough to see
89
+ // whether it starts with a shebang; the whole text is read for the
90
+ // kinds that are inspected.
91
+ const head = readBlob(subject.dir, entry.sha).subarray(0, 256).toString("utf8")
92
+ if (head.startsWith("#!")) shebang = head.split("\n")[0]
93
+ }
94
+ if (!kind && shebang) kind = kindOfShebang(shebang)
95
+ if (!kind && executable) kind = "other"
96
+ if (!kind) continue
97
+ files.push({ path: entry.path, kind, text: readText(subject.dir, entry.sha, TEXT_LIMIT), shebang, executable })
98
+ }
99
+ const filesRead = { qml: 0, js: 0, shell: 0, python: 0, other: 0 }
100
+ for (const file of files) filesRead[file.kind] += 1
101
+ return { files, filesRead, manifest, manifestError, readme, entries }
102
+ }