@redpanda-data/docs-extensions-and-macros 5.51.2 → 5.53.0
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.
- package/bin/doc-tools-mcp.js +4 -0
- package/bin/doc-tools.js +119 -0
- package/bin/mcp-tools/rpcn-docs.js +1 -0
- package/extensions/REFERENCE.adoc +19 -4
- package/extensions/generate-rp-connect-categories.js +76 -25
- package/extensions/generate-rp-connect-info.js +150 -40
- package/extensions/modify-connect-tag-playbook.js +183 -17
- package/extensions/util/connect-catalog.js +153 -0
- package/macros/rp-connect-components.js +23 -8
- package/package.json +1 -1
- package/tools/lint-strings/diff.js +4 -1
- package/tools/lint-strings/engine.js +5 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
- package/tools/lint-strings/fixtures/connect/repo/go.mod.txt +5 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/cli/lint.go +19 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/common.go +20 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/docs.go +8 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/input_thing.go +32 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/output_thing.go +12 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/bloblang.go +21 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/scanner.go +12 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/plugins/info.csv +3 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/retries/retries.go +13 -0
- package/tools/lint-strings/go-index.js +1143 -0
- package/tools/lint-strings/index.js +28 -2
- package/tools/lint-strings/rules/common.js +10 -2
- package/tools/lint-strings/surfaces/connect.js +1484 -137
- package/tools/lint-strings/surfaces/rpk.js +1 -0
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +23 -13
- package/tools/redpanda-connect/report-delta.js +1 -0
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -13
- package/tools/redpanda-connect/templates/connector.hbs +20 -2
- package/tools/redpanda-release-notes/generate-release-notes.js +242 -0
- package/tools/redpanda-release-notes/parse-release-body.js +241 -0
- package/tools/redpanda-release-notes/render-release-notes.js +70 -0
- package/tools/rpk-docs/helpers/index.js +32 -35
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const { maskComments, skipString, findBalancedClose, splitTopLevelArgs, unescapeGo } = require('./go-source')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A package-aware index over a Go module, for surfaces whose doc strings are
|
|
11
|
+
* assembled from more than one file.
|
|
12
|
+
*
|
|
13
|
+
* The plain helpers in go-source.js evaluate a string expression against the
|
|
14
|
+
* constants of ONE file. Connect builds most of its published text across
|
|
15
|
+
* files and packages: a description concatenates a constant from a sibling
|
|
16
|
+
* file, calls a helper that returns a string, or embeds a field name constant
|
|
17
|
+
* from another package (`kafka.FranzMaxInFlightDescription`). This index
|
|
18
|
+
* loads packages on demand, records their top-level constants, variables and
|
|
19
|
+
* functions, and evaluates string expressions against them.
|
|
20
|
+
*
|
|
21
|
+
* Same philosophy as the rest of lint-strings: a comment-masked scanner over
|
|
22
|
+
* real source text, never a compiler. When an expression cannot be resolved
|
|
23
|
+
* it is never guessed at. The evaluator returns the parts it could resolve
|
|
24
|
+
* and names each part it could not, so a caller can report the gap instead
|
|
25
|
+
* of dropping the declaration.
|
|
26
|
+
*
|
|
27
|
+
* Packages outside the module resolve through the Go module cache when it
|
|
28
|
+
* holds the version go.mod requires (GOMODCACHE, or ~/go/pkg/mod). In CI the
|
|
29
|
+
* cache is usually absent, and those parts stay unresolved.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PLACEHOLDER = (n) => `{{unresolved:${n}}}`
|
|
33
|
+
const PLACEHOLDER_RE = /\{\{unresolved:\d+\}\}/g
|
|
34
|
+
const MAX_DEPTH = 12
|
|
35
|
+
|
|
36
|
+
/** Byte offset -> 1-indexed line, by binary search over line starts. */
|
|
37
|
+
function makeLineIndex (content) {
|
|
38
|
+
const starts = [0]
|
|
39
|
+
for (let i = 0; i < content.length; i++) if (content[i] === '\n') starts.push(i + 1)
|
|
40
|
+
return (offset) => {
|
|
41
|
+
let lo = 0
|
|
42
|
+
let hi = starts.length - 1
|
|
43
|
+
while (lo < hi) {
|
|
44
|
+
const mid = (lo + hi + 1) >> 1
|
|
45
|
+
if (starts[mid] <= offset) lo = mid
|
|
46
|
+
else hi = mid - 1
|
|
47
|
+
}
|
|
48
|
+
return lo + 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bracket matches for (), [] and {} over comment-masked content, skipping
|
|
54
|
+
* string and rune literals. Returns Map(offset -> partner offset) in both
|
|
55
|
+
* directions, which is what lets a scanner walk a builder chain backwards.
|
|
56
|
+
*/
|
|
57
|
+
function bracketPairs (masked) {
|
|
58
|
+
const pairs = new Map()
|
|
59
|
+
const stack = []
|
|
60
|
+
let i = 0
|
|
61
|
+
while (i < masked.length) {
|
|
62
|
+
const ch = masked[i]
|
|
63
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
64
|
+
i = skipString(masked, i)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (ch === '(' || ch === '[' || ch === '{') stack.push(i)
|
|
68
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
69
|
+
const open = stack.pop()
|
|
70
|
+
if (open !== undefined) {
|
|
71
|
+
pairs.set(open, i)
|
|
72
|
+
pairs.set(i, open)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
i++
|
|
76
|
+
}
|
|
77
|
+
return pairs
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* End offset of the Go statement starting at `start`: the first newline
|
|
82
|
+
* outside strings and brackets that does not follow a continuation token
|
|
83
|
+
* (`+`, `.`, `,`, an opening bracket, a binary operator). Go's automatic
|
|
84
|
+
* semicolon insertion works the same way.
|
|
85
|
+
*/
|
|
86
|
+
function statementEnd (masked, start, limit = masked.length) {
|
|
87
|
+
let depth = 0
|
|
88
|
+
let i = start
|
|
89
|
+
while (i < limit) {
|
|
90
|
+
const ch = masked[i]
|
|
91
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
92
|
+
i = skipString(masked, i)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
96
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
97
|
+
if (depth === 0) return i
|
|
98
|
+
depth--
|
|
99
|
+
} else if ((ch === '\n' || ch === ';') && depth === 0) {
|
|
100
|
+
if (ch === ';') return i
|
|
101
|
+
// The last token before the newline decides, as in Go's semicolon
|
|
102
|
+
// insertion: a blank line, or a comment line (masked to spaces),
|
|
103
|
+
// inside a builder chain does not end the statement.
|
|
104
|
+
let k = i - 1
|
|
105
|
+
while (k >= start && /\s/.test(masked[k])) k--
|
|
106
|
+
if (k < start || !/[+.,([{|&=:*/-]/.test(masked[k])) return i
|
|
107
|
+
}
|
|
108
|
+
i++
|
|
109
|
+
}
|
|
110
|
+
return limit
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Split text on top-level occurrences of a single-character operator. */
|
|
114
|
+
function splitTopLevel (text, op) {
|
|
115
|
+
const parts = []
|
|
116
|
+
let depth = 0
|
|
117
|
+
let start = 0
|
|
118
|
+
let i = 0
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
const ch = text[i]
|
|
121
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
122
|
+
i = skipString(text, i)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
126
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
127
|
+
else if (ch === op && depth === 0) {
|
|
128
|
+
parts.push(text.slice(start, i))
|
|
129
|
+
start = i + 1
|
|
130
|
+
}
|
|
131
|
+
i++
|
|
132
|
+
}
|
|
133
|
+
parts.push(text.slice(start))
|
|
134
|
+
return parts
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Parse `a, b string, c *T` into [{ name, type }]. */
|
|
138
|
+
function parseParams (text) {
|
|
139
|
+
const raw = splitTopLevelArgs(text).map((p) => p.trim()).filter(Boolean)
|
|
140
|
+
const params = []
|
|
141
|
+
let pendingNames = []
|
|
142
|
+
for (const entry of raw) {
|
|
143
|
+
const m = entry.match(/^([A-Za-z_]\w*)\s+(.+)$/s)
|
|
144
|
+
if (m) {
|
|
145
|
+
for (const name of pendingNames) params.push({ name, type: m[2].trim() })
|
|
146
|
+
pendingNames = []
|
|
147
|
+
params.push({ name: m[1], type: m[2].trim() })
|
|
148
|
+
} else if (/^[A-Za-z_]\w*$/.test(entry)) {
|
|
149
|
+
pendingNames.push(entry)
|
|
150
|
+
} else {
|
|
151
|
+
params.push({ name: null, type: entry })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const name of pendingNames) params.push({ name: null, type: name })
|
|
155
|
+
return params
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Top-level declarations of one comment-masked file: functions (with body
|
|
160
|
+
* offsets), const/var entries (with their raw expression), and imports.
|
|
161
|
+
*/
|
|
162
|
+
function scanTopLevel (masked, pairs) {
|
|
163
|
+
const funcs = []
|
|
164
|
+
const values = []
|
|
165
|
+
const imports = new Map()
|
|
166
|
+
let pkg = null
|
|
167
|
+
const pkgMatch = masked.match(/^\s*package\s+([A-Za-z_]\w*)/m)
|
|
168
|
+
if (pkgMatch) pkg = pkgMatch[1]
|
|
169
|
+
|
|
170
|
+
let i = 0
|
|
171
|
+
let depth = 0
|
|
172
|
+
const n = masked.length
|
|
173
|
+
const keywordAt = (kw) => masked.startsWith(kw, i) && (i === 0 || /\s/.test(masked[i - 1])) &&
|
|
174
|
+
/[\s(]/.test(masked[i + kw.length] || '')
|
|
175
|
+
while (i < n) {
|
|
176
|
+
const ch = masked[i]
|
|
177
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
178
|
+
i = skipString(masked, i)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue }
|
|
182
|
+
if (ch === ')' || ch === ']' || ch === '}') { depth--; i++; continue }
|
|
183
|
+
if (depth !== 0) { i++; continue }
|
|
184
|
+
|
|
185
|
+
if (keywordAt('import')) {
|
|
186
|
+
let j = i + 6
|
|
187
|
+
while (/\s/.test(masked[j])) j++
|
|
188
|
+
let region
|
|
189
|
+
let end
|
|
190
|
+
if (masked[j] === '(') {
|
|
191
|
+
end = pairs.get(j)
|
|
192
|
+
region = masked.slice(j + 1, end)
|
|
193
|
+
} else {
|
|
194
|
+
end = statementEnd(masked, j)
|
|
195
|
+
region = masked.slice(j, end)
|
|
196
|
+
}
|
|
197
|
+
for (const m of region.matchAll(/(?:^|\n|;)\s*([A-Za-z_]\w*|\.|_)?\s*"([^"]+)"/g)) {
|
|
198
|
+
imports.set(m[1] || null, m[2])
|
|
199
|
+
if (!m[1]) imports.set(`\0${m[2]}`, m[2])
|
|
200
|
+
}
|
|
201
|
+
i = end + 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (keywordAt('func')) {
|
|
206
|
+
const fn = parseFunc(masked, pairs, i)
|
|
207
|
+
if (fn) {
|
|
208
|
+
funcs.push(fn)
|
|
209
|
+
i = fn.bodyEnd + 1
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
i += 4
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (keywordAt('const') || keywordAt('var')) {
|
|
217
|
+
const kind = masked.startsWith('const', i) ? 'const' : 'var'
|
|
218
|
+
let j = i + kind.length
|
|
219
|
+
while (/[ \t]/.test(masked[j])) j++
|
|
220
|
+
if (masked[j] === '(') {
|
|
221
|
+
const end = pairs.get(j)
|
|
222
|
+
if (end === undefined) { i++; continue }
|
|
223
|
+
let k = j + 1
|
|
224
|
+
while (k < end) {
|
|
225
|
+
while (k < end && /\s/.test(masked[k])) k++
|
|
226
|
+
if (k >= end) break
|
|
227
|
+
const stop = statementEnd(masked, k, end)
|
|
228
|
+
const entry = parseValueEntry(masked, k, stop, kind)
|
|
229
|
+
if (entry) values.push(entry)
|
|
230
|
+
k = stop + 1
|
|
231
|
+
}
|
|
232
|
+
i = end + 1
|
|
233
|
+
} else {
|
|
234
|
+
const stop = statementEnd(masked, j)
|
|
235
|
+
const entry = parseValueEntry(masked, j, stop, kind)
|
|
236
|
+
if (entry) values.push(entry)
|
|
237
|
+
i = stop + 1
|
|
238
|
+
}
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
i++
|
|
242
|
+
}
|
|
243
|
+
return { pkg, funcs, values, imports }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Parse one `name [type] [= expr]` const/var entry between start and stop. */
|
|
247
|
+
function parseValueEntry (masked, start, stop, kind) {
|
|
248
|
+
const text = masked.slice(start, stop)
|
|
249
|
+
const m = text.match(/^([A-Za-z_]\w*)\s*([^=]*?)\s*(?:=(?!=)\s*([\s\S]*))?$/)
|
|
250
|
+
if (!m) return null
|
|
251
|
+
const exprText = m[3] === undefined ? null : m[3]
|
|
252
|
+
const exprStart = exprText === null ? null : start + text.length - exprText.length
|
|
253
|
+
return {
|
|
254
|
+
kind,
|
|
255
|
+
name: m[1],
|
|
256
|
+
type: (m[2] || '').trim() || null,
|
|
257
|
+
expr: exprText,
|
|
258
|
+
exprStart,
|
|
259
|
+
start,
|
|
260
|
+
end: stop
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Parse a top-level func declaration starting at the `func` keyword. */
|
|
265
|
+
function parseFunc (masked, pairs, at) {
|
|
266
|
+
let j = at + 4
|
|
267
|
+
const skipWs = () => { while (/\s/.test(masked[j])) j++ }
|
|
268
|
+
skipWs()
|
|
269
|
+
let recv = null
|
|
270
|
+
if (masked[j] === '(') {
|
|
271
|
+
const close = pairs.get(j)
|
|
272
|
+
if (close === undefined) return null
|
|
273
|
+
recv = masked.slice(j + 1, close).trim()
|
|
274
|
+
j = close + 1
|
|
275
|
+
skipWs()
|
|
276
|
+
}
|
|
277
|
+
const nameMatch = masked.slice(j).match(/^([A-Za-z_]\w*)/)
|
|
278
|
+
if (!nameMatch) return null
|
|
279
|
+
const name = nameMatch[1]
|
|
280
|
+
j += name.length
|
|
281
|
+
skipWs()
|
|
282
|
+
if (masked[j] === '[') {
|
|
283
|
+
const close = pairs.get(j)
|
|
284
|
+
if (close === undefined) return null
|
|
285
|
+
j = close + 1
|
|
286
|
+
skipWs()
|
|
287
|
+
}
|
|
288
|
+
if (masked[j] !== '(') return null
|
|
289
|
+
const paramsClose = pairs.get(j)
|
|
290
|
+
if (paramsClose === undefined) return null
|
|
291
|
+
const params = parseParams(masked.slice(j + 1, paramsClose))
|
|
292
|
+
j = paramsClose + 1
|
|
293
|
+
// Results run to the body brace. A newline at depth 0 first means a
|
|
294
|
+
// declaration with no body (assembly stub).
|
|
295
|
+
const resultsStart = j
|
|
296
|
+
while (j < masked.length && masked[j] !== '{') {
|
|
297
|
+
if (masked[j] === '\n') return null
|
|
298
|
+
if (masked[j] === '(' || masked[j] === '[') {
|
|
299
|
+
const close = pairs.get(j)
|
|
300
|
+
if (close === undefined) return null
|
|
301
|
+
j = close + 1
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
j++
|
|
305
|
+
}
|
|
306
|
+
const bodyStart = j
|
|
307
|
+
const bodyEnd = pairs.get(bodyStart)
|
|
308
|
+
if (bodyEnd === undefined) return null
|
|
309
|
+
return {
|
|
310
|
+
name,
|
|
311
|
+
recv,
|
|
312
|
+
params,
|
|
313
|
+
results: masked.slice(resultsStart, bodyStart).trim(),
|
|
314
|
+
start: at,
|
|
315
|
+
bodyStart,
|
|
316
|
+
bodyEnd
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The Go module path and requirements from go.mod. */
|
|
321
|
+
function readGoMod (repo) {
|
|
322
|
+
const file = path.join(repo, 'go.mod')
|
|
323
|
+
if (!fs.existsSync(file)) return { module: null, requires: new Map(), replaces: new Map() }
|
|
324
|
+
const text = maskComments(fs.readFileSync(file, 'utf8'))
|
|
325
|
+
const moduleMatch = text.match(/^\s*module\s+(\S+)/m)
|
|
326
|
+
const requires = new Map()
|
|
327
|
+
for (const m of text.matchAll(/^\s*(?:require\s+)?([a-z0-9][\w.\-/]*\.[\w.\-/]+)\s+(v[\w.\-+]+)\s*$/gm)) {
|
|
328
|
+
requires.set(m[1], m[2])
|
|
329
|
+
}
|
|
330
|
+
const replaces = new Map()
|
|
331
|
+
for (const m of text.matchAll(/^\s*(?:replace\s+)?(\S+)\s*=>\s*(\S+)\s+(v\S+)\s*$/gm)) {
|
|
332
|
+
replaces.set(m[1], { path: m[2], version: m[3] })
|
|
333
|
+
}
|
|
334
|
+
return { module: moduleMatch ? moduleMatch[1] : null, requires, replaces }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Module cache escaping: uppercase letters become !lowercase. */
|
|
338
|
+
function escapeModulePath (p) {
|
|
339
|
+
return p.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function moduleCacheDir () {
|
|
343
|
+
if (process.env.GOMODCACHE) return process.env.GOMODCACHE
|
|
344
|
+
if (process.env.GOPATH) return path.join(process.env.GOPATH.split(path.delimiter)[0], 'pkg', 'mod')
|
|
345
|
+
return path.join(os.homedir(), 'go', 'pkg', 'mod')
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
class GoIndex {
|
|
349
|
+
/**
|
|
350
|
+
* @param {string} repo - Module root (the directory holding go.mod)
|
|
351
|
+
* @param {Object} [options]
|
|
352
|
+
* @param {boolean} [options.external=true] - Resolve packages outside the
|
|
353
|
+
* module through the Go module cache when it holds them
|
|
354
|
+
*/
|
|
355
|
+
constructor (repo, { external = true, overlay = null } = {}) {
|
|
356
|
+
this.repo = path.resolve(repo)
|
|
357
|
+
// In-memory files (abs path -> content), consulted before the disk, so
|
|
358
|
+
// a single file can be scanned without a checkout (tests, previews).
|
|
359
|
+
this.overlay = new Map()
|
|
360
|
+
if (overlay) for (const [file, content] of overlay) this.overlay.set(path.resolve(this.repo, file), content)
|
|
361
|
+
this.gomod = readGoMod(this.repo)
|
|
362
|
+
this.external = external
|
|
363
|
+
this.files = new Map() // abs path -> FileInfo
|
|
364
|
+
this.packages = new Map() // abs dir -> PackageInfo
|
|
365
|
+
this.memo = new Map()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Repo-relative path for an absolute path inside the module. */
|
|
369
|
+
rel (abs) {
|
|
370
|
+
return path.relative(this.repo, abs)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Load (and cache) one file. */
|
|
374
|
+
file (absPath) {
|
|
375
|
+
if (this.files.has(absPath)) return this.files.get(absPath)
|
|
376
|
+
let content
|
|
377
|
+
if (this.overlay.has(absPath)) {
|
|
378
|
+
content = this.overlay.get(absPath)
|
|
379
|
+
} else {
|
|
380
|
+
try {
|
|
381
|
+
content = fs.readFileSync(absPath, 'utf8')
|
|
382
|
+
} catch {
|
|
383
|
+
this.files.set(absPath, null)
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const masked = maskComments(content)
|
|
388
|
+
const pairs = bracketPairs(masked)
|
|
389
|
+
const top = scanTopLevel(masked, pairs)
|
|
390
|
+
const build = content.match(/^\/\/go:build\s+(.+)$/m)
|
|
391
|
+
const info = {
|
|
392
|
+
abs: absPath,
|
|
393
|
+
dir: path.dirname(absPath),
|
|
394
|
+
content,
|
|
395
|
+
masked,
|
|
396
|
+
pairs,
|
|
397
|
+
lineOf: makeLineIndex(content),
|
|
398
|
+
pkg: top.pkg,
|
|
399
|
+
funcs: top.funcs,
|
|
400
|
+
values: top.values,
|
|
401
|
+
imports: top.imports,
|
|
402
|
+
buildConstraint: build ? build[1].trim() : null
|
|
403
|
+
}
|
|
404
|
+
this.files.set(absPath, info)
|
|
405
|
+
return info
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Load (and cache) every non-test file of the package in `dir`. */
|
|
409
|
+
package (dir) {
|
|
410
|
+
if (this.packages.has(dir)) return this.packages.get(dir)
|
|
411
|
+
const pkg = { dir, name: null, files: [], funcs: new Map(), values: new Map(), methods: new Map() }
|
|
412
|
+
this.packages.set(dir, pkg)
|
|
413
|
+
const names = new Set()
|
|
414
|
+
try {
|
|
415
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isFile()) names.add(entry.name)
|
|
416
|
+
} catch {}
|
|
417
|
+
for (const file of this.overlay.keys()) if (path.dirname(file) === dir) names.add(path.basename(file))
|
|
418
|
+
for (const name of [...names].sort()) {
|
|
419
|
+
if (!name.endsWith('.go') || name.endsWith('_test.go')) continue
|
|
420
|
+
const info = this.file(path.join(dir, name))
|
|
421
|
+
if (!info) continue
|
|
422
|
+
pkg.files.push(info)
|
|
423
|
+
if (!pkg.name) pkg.name = info.pkg
|
|
424
|
+
for (const fn of info.funcs) {
|
|
425
|
+
const target = fn.recv ? pkg.methods : pkg.funcs
|
|
426
|
+
if (!target.has(fn.name)) target.set(fn.name, { ...fn, file: info })
|
|
427
|
+
}
|
|
428
|
+
for (const value of info.values) {
|
|
429
|
+
if (!pkg.values.has(value.name)) pkg.values.set(value.name, { ...value, file: info })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return pkg
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Directory of an import path, or null when it is not resolvable. */
|
|
436
|
+
importDir (importPath) {
|
|
437
|
+
const mod = this.gomod.module
|
|
438
|
+
if (mod && (importPath === mod || importPath.startsWith(`${mod}/`))) {
|
|
439
|
+
return path.join(this.repo, importPath.slice(mod.length))
|
|
440
|
+
}
|
|
441
|
+
if (!this.external) return null
|
|
442
|
+
let best = null
|
|
443
|
+
for (const [modPath, version] of this.gomod.requires) {
|
|
444
|
+
if ((importPath === modPath || importPath.startsWith(`${modPath}/`)) && (!best || modPath.length > best[0].length)) {
|
|
445
|
+
best = [modPath, version]
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!best) return null
|
|
449
|
+
const [modPath, version] = best
|
|
450
|
+
const replaced = this.gomod.replaces.get(modPath)
|
|
451
|
+
const source = replaced ? replaced.path : modPath
|
|
452
|
+
const ver = replaced ? replaced.version : version
|
|
453
|
+
const dir = path.join(moduleCacheDir(), `${escapeModulePath(source)}@${ver}`, importPath.slice(modPath.length))
|
|
454
|
+
return fs.existsSync(dir) ? dir : null
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The package an identifier qualifier names in `file`, or null. */
|
|
458
|
+
importedPackage (file, alias) {
|
|
459
|
+
let importPath = file.imports.get(alias)
|
|
460
|
+
if (!importPath) {
|
|
461
|
+
// An unaliased import is named by its package clause, which is
|
|
462
|
+
// usually, but not always, the last path element.
|
|
463
|
+
for (const [key, value] of file.imports) {
|
|
464
|
+
if (key !== null && key.startsWith('\0')) {
|
|
465
|
+
const last = value.split('/').filter((s) => !/^v\d+$/.test(s)).pop()
|
|
466
|
+
if (last === alias || last.replace(/-/g, '_') === alias) { importPath = value; break }
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (!importPath) {
|
|
471
|
+
for (const [key, value] of file.imports) {
|
|
472
|
+
if (key === null || !key.startsWith('\0')) continue
|
|
473
|
+
const dir = this.importDir(value)
|
|
474
|
+
if (dir && this.package(dir).name === alias) { importPath = value; break }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!importPath) return null
|
|
478
|
+
return { importPath, dir: this.importDir(importPath) }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** True when `alias` in `file` imports a path ending in `suffix`. */
|
|
482
|
+
importIs (file, alias, suffix) {
|
|
483
|
+
const imported = this.importedPackage(file, alias)
|
|
484
|
+
return Boolean(imported && imported.importPath.endsWith(suffix))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Look up a function by callee text (`name` or `pkg.Name`) as seen from
|
|
489
|
+
* `file`. Returns the function record or null.
|
|
490
|
+
*/
|
|
491
|
+
lookupFunc (file, callee) {
|
|
492
|
+
const dot = callee.indexOf('.')
|
|
493
|
+
if (dot === -1) {
|
|
494
|
+
return this.package(file.dir).funcs.get(callee) || null
|
|
495
|
+
}
|
|
496
|
+
const alias = callee.slice(0, dot)
|
|
497
|
+
const name = callee.slice(dot + 1)
|
|
498
|
+
if (name.includes('.')) return null
|
|
499
|
+
const imported = this.importedPackage(file, alias)
|
|
500
|
+
if (!imported || !imported.dir) return null
|
|
501
|
+
return this.package(imported.dir).funcs.get(name) || null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Same as lookupFunc, for a top-level const or var. */
|
|
505
|
+
lookupValue (file, ident) {
|
|
506
|
+
const dot = ident.indexOf('.')
|
|
507
|
+
if (dot === -1) return this.package(file.dir).values.get(ident) || null
|
|
508
|
+
const alias = ident.slice(0, dot)
|
|
509
|
+
const name = ident.slice(dot + 1)
|
|
510
|
+
if (name.includes('.')) return null
|
|
511
|
+
const imported = this.importedPackage(file, alias)
|
|
512
|
+
if (!imported || !imported.dir) return null
|
|
513
|
+
return this.package(imported.dir).values.get(name) || null
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Evaluate a Go string expression.
|
|
518
|
+
*
|
|
519
|
+
* @param {string} exprText - Comment-masked expression text
|
|
520
|
+
* @param {Object} ctx - { file, locals?: Map(name -> Result), depth? }
|
|
521
|
+
* @returns {{ parts: Array<{text}|{unresolved}>, sawString: boolean }}
|
|
522
|
+
* Each unresolved part names the source text that could not be
|
|
523
|
+
* evaluated. Use renderResult() to turn it into a string.
|
|
524
|
+
*/
|
|
525
|
+
evalString (exprText, ctx) {
|
|
526
|
+
const depth = ctx.depth || 0
|
|
527
|
+
const parts = []
|
|
528
|
+
let sawString = false
|
|
529
|
+
if (depth > MAX_DEPTH) return { parts: [{ unresolved: exprText.trim() }], sawString: false }
|
|
530
|
+
for (const rawTerm of splitTopLevel(exprText, '+')) {
|
|
531
|
+
const term = rawTerm.trim()
|
|
532
|
+
if (term === '') continue
|
|
533
|
+
const r = this.evalTerm(term, { ...ctx, depth })
|
|
534
|
+
if (r.sawString) sawString = true
|
|
535
|
+
parts.push(...r.parts)
|
|
536
|
+
}
|
|
537
|
+
return { parts: mergeParts(parts), sawString }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
evalTerm (term, ctx) {
|
|
541
|
+
const unresolved = { parts: [{ unresolved: term }], sawString: false }
|
|
542
|
+
// A constant slice of a string, as in `...`[1:] (drops a raw string's
|
|
543
|
+
// leading newline).
|
|
544
|
+
const slice = term.match(/\[\s*(\d*)\s*:\s*(\d*)\s*\]$/)
|
|
545
|
+
if (slice && term.length > slice[0].length) {
|
|
546
|
+
const baseText = term.slice(0, term.length - slice[0].length)
|
|
547
|
+
const open = term.length - slice[0].length
|
|
548
|
+
const base = term[open - 1] === ')' || term[open - 1] === '`' || term[open - 1] === '"' || /\w/.test(term[open - 1])
|
|
549
|
+
? this.evalTerm(baseText.trim(), ctx)
|
|
550
|
+
: null
|
|
551
|
+
if (!base || !isResolved(base)) return unresolved
|
|
552
|
+
const text = renderResult(base)
|
|
553
|
+
const from = slice[1] === '' ? 0 : Number(slice[1])
|
|
554
|
+
const to = slice[2] === '' ? text.length : Number(slice[2])
|
|
555
|
+
if (from > to || to > text.length) return unresolved
|
|
556
|
+
return { parts: [{ text: text.slice(from, to) }], sawString: true }
|
|
557
|
+
}
|
|
558
|
+
const first = term[0]
|
|
559
|
+
if (first === '"' || first === '`') {
|
|
560
|
+
const end = skipString(term, 0)
|
|
561
|
+
if (end !== term.length) return unresolved
|
|
562
|
+
const body = term.slice(1, end - 1)
|
|
563
|
+
return { parts: [{ text: first === '"' ? unescapeGo(body) : body.replace(/\r/g, '') }], sawString: true }
|
|
564
|
+
}
|
|
565
|
+
if (first === '(') {
|
|
566
|
+
const close = findBalancedClose(term, 0)
|
|
567
|
+
if (close !== term.length - 1) return unresolved
|
|
568
|
+
return this.evalString(term.slice(1, -1), { ...ctx, depth: ctx.depth + 1 })
|
|
569
|
+
}
|
|
570
|
+
const call = term.match(/^([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)?)\s*\(/)
|
|
571
|
+
if (call) {
|
|
572
|
+
const open = term.indexOf('(', call[1].length)
|
|
573
|
+
const close = findBalancedClose(term, open)
|
|
574
|
+
if (close !== term.length - 1) return unresolved
|
|
575
|
+
return this.evalCall(call[1], term.slice(open + 1, close), ctx, term)
|
|
576
|
+
}
|
|
577
|
+
if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(term)) {
|
|
578
|
+
return this.evalIdent(term, ctx)
|
|
579
|
+
}
|
|
580
|
+
return unresolved
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
evalIdent (ident, ctx) {
|
|
584
|
+
const unresolved = { parts: [{ unresolved: ident }], sawString: false }
|
|
585
|
+
if (ctx.locals && ctx.locals.has(ident)) return ctx.locals.get(ident)
|
|
586
|
+
// A function-local variable or parameter shadows the package scope.
|
|
587
|
+
// The resolver returns undefined for "not a local", null for "a local
|
|
588
|
+
// whose value cannot be known here".
|
|
589
|
+
if (ctx.resolveLocal && !ident.includes('.')) {
|
|
590
|
+
const local = ctx.resolveLocal(ident, ctx)
|
|
591
|
+
if (local === null) return unresolved
|
|
592
|
+
if (local !== undefined) return local
|
|
593
|
+
}
|
|
594
|
+
const value = this.lookupValue(ctx.file, ident)
|
|
595
|
+
if (value && value.expr === null) {
|
|
596
|
+
// `//go:embed file` on a string var: the file is the value.
|
|
597
|
+
const embedded = this.embeddedString(value)
|
|
598
|
+
if (embedded !== null) return { parts: [{ text: embedded }], sawString: true }
|
|
599
|
+
}
|
|
600
|
+
if (!value || value.expr === null) return unresolved
|
|
601
|
+
const key = `${value.file.abs}\0${value.name}`
|
|
602
|
+
if (this.memo.has(key)) return this.memo.get(key) || unresolved
|
|
603
|
+
this.memo.set(key, null) // cycle guard
|
|
604
|
+
const r = this.evalString(value.expr, { file: value.file, depth: ctx.depth + 1 })
|
|
605
|
+
const result = r.sawString ? r : unresolved
|
|
606
|
+
this.memo.set(key, result)
|
|
607
|
+
return result
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
evalCall (callee, argText, ctx, term) {
|
|
611
|
+
const unresolved = { parts: [{ unresolved: term }], sawString: false }
|
|
612
|
+
const args = splitTopLevelArgs(argText).map((a) => a.trim()).filter((a) => a !== '')
|
|
613
|
+
const sub = { ...ctx, depth: ctx.depth + 1 }
|
|
614
|
+
|
|
615
|
+
// string(x): a typed string constant converted back to string.
|
|
616
|
+
if (callee === 'string' && args.length === 1) {
|
|
617
|
+
const r = this.evalString(args[0], sub)
|
|
618
|
+
return r.sawString ? r : unresolved
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const dot = callee.indexOf('.')
|
|
622
|
+
const alias = dot === -1 ? null : callee.slice(0, dot)
|
|
623
|
+
const fnName = dot === -1 ? callee : callee.slice(dot + 1)
|
|
624
|
+
const stdlib = alias && this.stdlibName(ctx.file, alias)
|
|
625
|
+
if (stdlib === 'fmt' && fnName === 'Sprintf' && args.length >= 1) {
|
|
626
|
+
return this.evalSprintf(args, sub, term)
|
|
627
|
+
}
|
|
628
|
+
if (stdlib === 'strings' && fnName === 'Join' && args.length === 2) {
|
|
629
|
+
const list = this.evalStringList(args[0], sub)
|
|
630
|
+
const sep = this.evalString(args[1], sub)
|
|
631
|
+
if (!list || !isResolved(sep) || !list.some((r) => r.sawString)) return unresolved
|
|
632
|
+
const parts = []
|
|
633
|
+
list.forEach((r, i) => {
|
|
634
|
+
if (i > 0) parts.push({ text: renderResult(sep) })
|
|
635
|
+
parts.push(...r.parts)
|
|
636
|
+
})
|
|
637
|
+
return { parts: mergeParts(parts), sawString: true }
|
|
638
|
+
}
|
|
639
|
+
if (stdlib === 'strings' && ((fnName === 'ReplaceAll' && args.length === 3) || (fnName === 'Replace' && args.length === 4))) {
|
|
640
|
+
const r = this.evalString(args[0], sub)
|
|
641
|
+
const from = this.evalString(args[1], sub)
|
|
642
|
+
const to = this.evalString(args[2], sub)
|
|
643
|
+
const n = fnName === 'Replace' ? Number(args[3]) : -1
|
|
644
|
+
if (!isResolved(r) || !isResolved(from) || !Number.isInteger(n)) return unresolved
|
|
645
|
+
const needle = renderResult(from)
|
|
646
|
+
if (needle === '') return unresolved
|
|
647
|
+
// The replacement may itself be partial: splice its parts in.
|
|
648
|
+
const pieces = renderResult(r).split(needle)
|
|
649
|
+
const parts = []
|
|
650
|
+
pieces.forEach((piece, i) => {
|
|
651
|
+
if (i > 0) parts.push(...(n < 0 || i <= n ? to.parts : [{ text: needle }]))
|
|
652
|
+
parts.push({ text: piece })
|
|
653
|
+
})
|
|
654
|
+
return { parts: mergeParts(parts.filter((p) => p.text !== '')), sawString: true }
|
|
655
|
+
}
|
|
656
|
+
if (stdlib === 'strconv' && fnName === 'Itoa' && args.length === 1) {
|
|
657
|
+
const n = this.evalInt(args[0], sub)
|
|
658
|
+
return n === null ? unresolved : { parts: [{ text: String(n) }], sawString: true }
|
|
659
|
+
}
|
|
660
|
+
if (stdlib === 'strings' && /^(TrimSpace|ToLower|ToUpper|Title)$/.test(fnName) && args.length === 1) {
|
|
661
|
+
const r = this.evalString(args[0], sub)
|
|
662
|
+
if (!isResolved(r)) return unresolved
|
|
663
|
+
const s = renderResult(r)
|
|
664
|
+
const out = fnName === 'TrimSpace'
|
|
665
|
+
? s.trim()
|
|
666
|
+
: fnName === 'ToLower' ? s.toLowerCase() : fnName === 'ToUpper' ? s.toUpperCase() : s.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
667
|
+
return { parts: [{ text: out }], sawString: true }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// A function in this module (or a cached dependency) whose body runs
|
|
671
|
+
// straight to one return statement, with its parameters bound to the
|
|
672
|
+
// call arguments and its local string variables followed. Anything with
|
|
673
|
+
// control flow stays unresolved.
|
|
674
|
+
const fn = this.lookupFunc(ctx.file, callee)
|
|
675
|
+
if (!fn) return unresolved
|
|
676
|
+
const ret = straightLineReturn(fn)
|
|
677
|
+
if (ret === null) {
|
|
678
|
+
const r = this.interpretBranches(fn, args, sub)
|
|
679
|
+
return r && r.sawString ? r : unresolved
|
|
680
|
+
}
|
|
681
|
+
const locals = new Map()
|
|
682
|
+
// Every parameter shadows the package scope, so one that does not
|
|
683
|
+
// evaluate to a string is bound to an unresolved part, never left to
|
|
684
|
+
// fall through to a same-named package constant.
|
|
685
|
+
fn.params.forEach((param, i) => {
|
|
686
|
+
if (!param.name) return
|
|
687
|
+
const arg = i < args.length && !/^\.\.\./.test(param.type) ? args[i] : null
|
|
688
|
+
const r = arg === null ? null : this.evalString(arg, sub)
|
|
689
|
+
locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: arg === null ? param.name : arg }], sawString: false })
|
|
690
|
+
})
|
|
691
|
+
const r = this.evalString(ret.expr, {
|
|
692
|
+
file: fn.file,
|
|
693
|
+
locals,
|
|
694
|
+
depth: ctx.depth + 1,
|
|
695
|
+
resolveLocal: this.localResolver(fn.file, fn, ret.offset)
|
|
696
|
+
})
|
|
697
|
+
return r.sawString ? r : unresolved
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* A resolver for the local variables of `fn` as they stand at `offset`
|
|
702
|
+
* (an offset into fn.file.masked). Follows `x := expr`, `x = expr`,
|
|
703
|
+
* `var x = expr` and `x += expr` assignments in the function body.
|
|
704
|
+
*
|
|
705
|
+
* Returns a function (ident, ctx) -> Result | null | undefined:
|
|
706
|
+
* undefined when the identifier is not a local of fn (so the package
|
|
707
|
+
* scope applies), null when it is a local or parameter whose value cannot
|
|
708
|
+
* be known here (assigned under a condition, in a loop, from a tuple).
|
|
709
|
+
*/
|
|
710
|
+
localResolver (file, fn, offset) {
|
|
711
|
+
if (!fn) return undefined
|
|
712
|
+
const masked = file.masked
|
|
713
|
+
const body = { start: fn.bodyStart, end: Math.min(fn.bodyEnd, offset) }
|
|
714
|
+
const params = new Set(fn.params.map((p) => p.name).filter(Boolean))
|
|
715
|
+
const pairs = file.pairs
|
|
716
|
+
// Brace depth of a position relative to the body: an assignment deeper
|
|
717
|
+
// than the declaration it updates runs conditionally.
|
|
718
|
+
const depthAt = (pos) => {
|
|
719
|
+
let depth = 0
|
|
720
|
+
for (let i = fn.bodyStart + 1; i < pos; i++) {
|
|
721
|
+
const ch = masked[i]
|
|
722
|
+
if (ch === '"' || ch === '`' || ch === "'") { i = skipString(masked, i) - 1; continue }
|
|
723
|
+
if (ch === '{') {
|
|
724
|
+
const close = pairs.get(i)
|
|
725
|
+
if (close !== undefined && close < pos) { i = close; continue }
|
|
726
|
+
depth++
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return depth
|
|
730
|
+
}
|
|
731
|
+
const assignmentsOf = (ident, before) => {
|
|
732
|
+
const out = []
|
|
733
|
+
const text = masked.slice(body.start, before)
|
|
734
|
+
const forms = [
|
|
735
|
+
new RegExp(`(^|[^\\w.])${ident}\\s*(:=|\\+=|=)(?!=)`, 'g'),
|
|
736
|
+
new RegExp(`(^|[^\\w.])var\\s+${ident}\\b[^=\\n;]*(=)(?!=)`, 'g')
|
|
737
|
+
]
|
|
738
|
+
for (const re of forms) {
|
|
739
|
+
let m
|
|
740
|
+
while ((m = re.exec(text)) !== null) {
|
|
741
|
+
const at = body.start + m.index + m[1].length
|
|
742
|
+
// `a, b := f()` binds a tuple: never a plain string value.
|
|
743
|
+
let k = at - 1
|
|
744
|
+
while (k > body.start && /[ \t]/.test(masked[k])) k--
|
|
745
|
+
if (masked[k] === ',') { out.push({ tuple: true, at }); continue }
|
|
746
|
+
const exprStart = body.start + m.index + m[0].length
|
|
747
|
+
const exprEnd = statementEnd(masked, exprStart, fn.bodyEnd)
|
|
748
|
+
out.push({ op: m[2] === '=' && re === forms[1] ? ':=' : m[2], at, exprStart, exprEnd, depth: depthAt(at) })
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return out.sort((a, b) => a.at - b.at)
|
|
752
|
+
}
|
|
753
|
+
const resolveAt = (ident, before, ctx) => {
|
|
754
|
+
const list = assignmentsOf(ident, before)
|
|
755
|
+
if (list.length === 0) return params.has(ident) ? null : undefined
|
|
756
|
+
if (list.some((a) => a.tuple)) return null
|
|
757
|
+
const declIndex = list.map((a) => a.op).lastIndexOf(':=')
|
|
758
|
+
const startIndex = declIndex === -1 ? 0 : declIndex
|
|
759
|
+
const relevant = list.slice(startIndex)
|
|
760
|
+
const baseDepth = relevant[0].depth
|
|
761
|
+
if (relevant.some((a) => a.depth !== baseDepth)) return null
|
|
762
|
+
// The last plain assignment wins; += after it appends.
|
|
763
|
+
let from = relevant.length - 1
|
|
764
|
+
while (from > 0 && relevant[from].op === '+=') from--
|
|
765
|
+
// An append with no plain assignment before it extends a value this
|
|
766
|
+
// body never set (a parameter or a package variable).
|
|
767
|
+
if (relevant[from].op === '+=') return null
|
|
768
|
+
const parts = []
|
|
769
|
+
let sawString = false
|
|
770
|
+
for (const a of relevant.slice(from)) {
|
|
771
|
+
const sub = {
|
|
772
|
+
file,
|
|
773
|
+
depth: (ctx.depth || 0) + 1,
|
|
774
|
+
locals: ctx.locals,
|
|
775
|
+
resolveLocal: (id, c) => resolveAt(id, a.at, c)
|
|
776
|
+
}
|
|
777
|
+
const r = this.evalString(masked.slice(a.exprStart, a.exprEnd), sub)
|
|
778
|
+
if (r.sawString) sawString = true
|
|
779
|
+
parts.push(...r.parts)
|
|
780
|
+
}
|
|
781
|
+
return sawString ? { parts: mergeParts(parts), sawString } : null
|
|
782
|
+
}
|
|
783
|
+
return (ident, ctx) => resolveAt(ident, body.end, ctx || {})
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The contents of the file a `//go:embed <file>` directive above a
|
|
788
|
+
* string var names, or null. Patterns and multiple files are not a
|
|
789
|
+
* single string, so they stay unresolved.
|
|
790
|
+
*/
|
|
791
|
+
embeddedString (value) {
|
|
792
|
+
if (value.kind !== 'var' || !/^string$/.test(value.type || '')) return null
|
|
793
|
+
const before = value.file.content.slice(0, value.start)
|
|
794
|
+
const m = before.match(/\/\/go:embed[ \t]+(\S+)[ \t]*\r?\n\s*(?:var\s*)?$/)
|
|
795
|
+
if (!m || /[*?[]/.test(m[1])) return null
|
|
796
|
+
const abs = path.join(value.file.dir, m[1])
|
|
797
|
+
if (this.overlay.has(abs)) return this.overlay.get(abs)
|
|
798
|
+
try {
|
|
799
|
+
return fs.readFileSync(abs, 'utf8')
|
|
800
|
+
} catch {
|
|
801
|
+
return null
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The values of a call to a function that returns several strings, as in
|
|
807
|
+
* `spec.Example(exampleConfig())` where exampleConfig returns a title,
|
|
808
|
+
* a summary and a config. Returns an array of Results, or null.
|
|
809
|
+
*/
|
|
810
|
+
evalTuple (callText, ctx) {
|
|
811
|
+
const call = callText.trim().match(/^([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)?)\s*\(/)
|
|
812
|
+
if (!call) return null
|
|
813
|
+
const open = callText.trim().indexOf('(', call[1].length)
|
|
814
|
+
const close = findBalancedClose(callText.trim(), open)
|
|
815
|
+
if (close !== callText.trim().length - 1) return null
|
|
816
|
+
const fn = this.lookupFunc(ctx.file, call[1])
|
|
817
|
+
if (!fn || !/^\(/.test(fn.results)) return null
|
|
818
|
+
const ret = straightLineReturn(fn)
|
|
819
|
+
if (ret === null) return null
|
|
820
|
+
const args = splitTopLevelArgs(callText.trim().slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
|
|
821
|
+
const locals = new Map()
|
|
822
|
+
fn.params.forEach((param, i) => {
|
|
823
|
+
if (!param.name) return
|
|
824
|
+
const r = i < args.length ? this.evalString(args[i], { ...ctx, depth: (ctx.depth || 0) + 1 }) : null
|
|
825
|
+
locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
|
|
826
|
+
})
|
|
827
|
+
const sub = { file: fn.file, locals, depth: (ctx.depth || 0) + 1, resolveLocal: this.localResolver(fn.file, fn, ret.offset) }
|
|
828
|
+
return splitTopLevelArgs(ret.expr).map((e) => this.evalString(e, sub))
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* An integer expression of literals and constants joined by + and -, as
|
|
833
|
+
* in `strconv.Itoa(maxRetries-1)`. Returns a number or null.
|
|
834
|
+
*/
|
|
835
|
+
evalInt (text, ctx) {
|
|
836
|
+
const t = text.trim()
|
|
837
|
+
if ((ctx.depth || 0) > MAX_DEPTH) return null
|
|
838
|
+
const terms = t.match(/^[-+]?\s*[\w.]+(?:\s*[-+]\s*[\w.]+)*$/) ? t.split(/(?=[-+])/) : null
|
|
839
|
+
if (!terms) return null
|
|
840
|
+
let total = 0
|
|
841
|
+
for (const raw of terms) {
|
|
842
|
+
const m = raw.trim().match(/^([-+]?)\s*([\w.]+)$/)
|
|
843
|
+
if (!m) return null
|
|
844
|
+
let v
|
|
845
|
+
if (/^\d+$/.test(m[2])) v = Number(m[2])
|
|
846
|
+
else if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(m[2])) {
|
|
847
|
+
const value = this.lookupValue(ctx.file, m[2])
|
|
848
|
+
if (!value || value.kind !== 'const' || !value.expr) return null
|
|
849
|
+
v = this.evalInt(value.expr, { file: value.file, depth: (ctx.depth || 0) + 1 })
|
|
850
|
+
if (v === null) return null
|
|
851
|
+
} else return null
|
|
852
|
+
total += m[1] === '-' ? -v : v
|
|
853
|
+
}
|
|
854
|
+
return total
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** `[]string{...}` literal or an identifier bound to one. */
|
|
858
|
+
evalStringList (text, ctx) {
|
|
859
|
+
let t = text.trim()
|
|
860
|
+
// A variadic parameter bound to the call's arguments.
|
|
861
|
+
if (ctx.locals && ctx.locals.has(t) && ctx.locals.get(t).list) return [...ctx.locals.get(t).list]
|
|
862
|
+
// append(list, a, b) and append(list, rest...): a variadic spread that
|
|
863
|
+
// cannot be evaluated is one unresolved element.
|
|
864
|
+
const app = t.match(/^append\s*\(/)
|
|
865
|
+
if (app) {
|
|
866
|
+
const open = app[0].length - 1
|
|
867
|
+
const close = findBalancedClose(t, open)
|
|
868
|
+
if (close !== t.length - 1) return null
|
|
869
|
+
const parts = splitTopLevelArgs(t.slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
|
|
870
|
+
if (parts.length === 0) return null
|
|
871
|
+
const base = this.evalStringList(parts[0], ctx)
|
|
872
|
+
if (!base) return null
|
|
873
|
+
for (const item of parts.slice(1)) {
|
|
874
|
+
if (item.endsWith('...')) {
|
|
875
|
+
const spread = this.evalStringList(item.slice(0, -3), ctx)
|
|
876
|
+
base.push(...(spread || [{ parts: [{ unresolved: item }], sawString: false }]))
|
|
877
|
+
} else {
|
|
878
|
+
base.push(this.evalString(item, ctx))
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return base
|
|
882
|
+
}
|
|
883
|
+
if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(t)) {
|
|
884
|
+
const value = this.lookupValue(ctx.file, t)
|
|
885
|
+
if (!value || !value.expr) return null
|
|
886
|
+
t = value.expr.trim()
|
|
887
|
+
ctx = { ...ctx, file: value.file }
|
|
888
|
+
}
|
|
889
|
+
const m = t.match(/^\[\]string\s*\{/)
|
|
890
|
+
if (!m) return null
|
|
891
|
+
const open = m[0].length - 1
|
|
892
|
+
const close = findBalancedClose(t, open, '{', '}')
|
|
893
|
+
if (close !== t.length - 1) return null
|
|
894
|
+
return splitTopLevelArgs(t.slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
|
|
895
|
+
.map((a) => this.evalString(a, ctx))
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* fmt.Sprintf with a resolvable format. An argument that cannot be
|
|
900
|
+
* evaluated becomes an unresolved part in place of its verb, so the rest
|
|
901
|
+
* of the sentence still reaches the review.
|
|
902
|
+
*/
|
|
903
|
+
evalSprintf (args, ctx, term) {
|
|
904
|
+
const unresolved = { parts: [{ unresolved: term }], sawString: false }
|
|
905
|
+
const format = this.evalString(args[0], ctx)
|
|
906
|
+
if (!isResolved(format)) return unresolved
|
|
907
|
+
const fmtText = renderResult(format)
|
|
908
|
+
const parts = []
|
|
909
|
+
let argIndex = 1
|
|
910
|
+
let last = 0
|
|
911
|
+
// Match every verb, so one this parser can't render (%.2f, %w, %T, or an
|
|
912
|
+
// explicit argument index such as %[1]s) marks the whole string unresolved
|
|
913
|
+
// instead of staying as literal text and shifting the later arguments.
|
|
914
|
+
const verbs = /%(\[\d+\])?([-+# 0]*(?:\d+|\*)?(?:\.(?:\d+|\*)?)?)([a-zA-Z%])/g
|
|
915
|
+
let m
|
|
916
|
+
while ((m = verbs.exec(fmtText)) !== null) {
|
|
917
|
+
parts.push({ text: fmtText.slice(last, m.index) })
|
|
918
|
+
last = m.index + m[0].length
|
|
919
|
+
const [whole, explicitIndex, flags, verb] = m
|
|
920
|
+
if (verb === '%' && !explicitIndex && !flags) { parts.push({ text: '%' }); continue }
|
|
921
|
+
if (explicitIndex || !'svqdtx'.includes(verb)) return unresolved
|
|
922
|
+
const arg = args[argIndex++]
|
|
923
|
+
if (arg === undefined || flags) return unresolved
|
|
924
|
+
const a = arg.trim()
|
|
925
|
+
if (/^-?\d+$/.test(a) && (verb === 'd' || verb === 'v')) { parts.push({ text: a }); continue }
|
|
926
|
+
if (/^(true|false)$/.test(a) && (verb === 't' || verb === 'v')) { parts.push({ text: a }); continue }
|
|
927
|
+
const r = this.evalString(a, ctx)
|
|
928
|
+
if (!isResolved(r)) {
|
|
929
|
+
parts.push({ unresolved: a || whole })
|
|
930
|
+
continue
|
|
931
|
+
}
|
|
932
|
+
const text = renderResult(r)
|
|
933
|
+
if (verb === 'q') parts.push({ text: JSON.stringify(text) })
|
|
934
|
+
else if (verb === 's' || verb === 'v') parts.push({ text })
|
|
935
|
+
else parts.push({ unresolved: a })
|
|
936
|
+
}
|
|
937
|
+
parts.push({ text: fmtText.slice(last) })
|
|
938
|
+
return { parts: mergeParts(parts.filter((p) => p.text !== '')), sawString: true }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Interpret a string-returning function whose control flow branches only
|
|
943
|
+
* on boolean parameters bound to literal arguments, such as benthos'
|
|
944
|
+
* service.OutputPerformanceDocs(true, false):
|
|
945
|
+
*
|
|
946
|
+
* if <cond> { ... } (no else; cond over params, !, &&, ||, true, false)
|
|
947
|
+
* x += expr | x = expr | x := expr | var x = expr
|
|
948
|
+
* return [expr] (a bare return yields the named result)
|
|
949
|
+
*
|
|
950
|
+
* Anything else returns null and the call stays unresolved.
|
|
951
|
+
*/
|
|
952
|
+
interpretBranches (fn, args, ctx) {
|
|
953
|
+
const masked = fn.file.masked
|
|
954
|
+
const bools = new Map()
|
|
955
|
+
fn.params.forEach((param, i) => {
|
|
956
|
+
const a = (args[i] || '').trim()
|
|
957
|
+
if (param.name && /^bool$/.test(param.type) && /^(true|false)$/.test(a)) bools.set(param.name, a === 'true')
|
|
958
|
+
})
|
|
959
|
+
const named = fn.results.match(/^\(\s*([A-Za-z_]\w*)\s+string\s*\)$/)
|
|
960
|
+
const vars = new Map()
|
|
961
|
+
if (named) vars.set(named[1], { parts: [], sawString: true })
|
|
962
|
+
const evalCond = (text) => {
|
|
963
|
+
const t = text.replace(/\s+/g, ' ').trim()
|
|
964
|
+
if (!/^[\w!&|() ]+$/.test(t)) return null
|
|
965
|
+
const js = t.replace(/[A-Za-z_]\w*/g, (id) => {
|
|
966
|
+
if (id === 'true' || id === 'false') return id
|
|
967
|
+
return bools.has(id) ? String(bools.get(id)) : '@'
|
|
968
|
+
})
|
|
969
|
+
if (js.includes('@')) return null
|
|
970
|
+
if (!/^[truefals!&|() ]+$/.test(js)) return null
|
|
971
|
+
try { return Boolean(Function(`"use strict"; return (${js})`)()) } catch { return null }
|
|
972
|
+
}
|
|
973
|
+
const sub = () => ({
|
|
974
|
+
file: fn.file,
|
|
975
|
+
depth: (ctx.depth || 0) + 1,
|
|
976
|
+
locals: new Map([...vars]),
|
|
977
|
+
resolveLocal: () => undefined
|
|
978
|
+
})
|
|
979
|
+
const run = (start, end) => {
|
|
980
|
+
let i = start
|
|
981
|
+
while (i < end) {
|
|
982
|
+
while (i < end && /[\s;]/.test(masked[i])) i++
|
|
983
|
+
if (i >= end) return undefined
|
|
984
|
+
const rest = masked.slice(i, end)
|
|
985
|
+
let m
|
|
986
|
+
if ((m = rest.match(/^if\b/))) {
|
|
987
|
+
const brace = masked.indexOf('{', i)
|
|
988
|
+
if (brace === -1 || brace >= end) return null
|
|
989
|
+
const close = fn.file.pairs.get(brace)
|
|
990
|
+
if (close === undefined) return null
|
|
991
|
+
const cond = evalCond(masked.slice(i + 2, brace))
|
|
992
|
+
if (cond === null) return null
|
|
993
|
+
if (/^\s*else\b/.test(masked.slice(close + 1, end))) return null
|
|
994
|
+
if (cond) {
|
|
995
|
+
const r = run(brace + 1, close)
|
|
996
|
+
if (r !== undefined) return r
|
|
997
|
+
}
|
|
998
|
+
i = close + 1
|
|
999
|
+
continue
|
|
1000
|
+
}
|
|
1001
|
+
if ((m = rest.match(/^return\b/))) {
|
|
1002
|
+
const stop = statementEnd(masked, i + 6, end)
|
|
1003
|
+
const expr = masked.slice(i + 6, stop).trim()
|
|
1004
|
+
if (expr === '') return named ? vars.get(named[1]) : null
|
|
1005
|
+
return this.evalString(expr, sub())
|
|
1006
|
+
}
|
|
1007
|
+
if ((m = rest.match(/^(?:var\s+)?([A-Za-z_]\w*)\s*(\+=|:=|=)(?!=)\s*/))) {
|
|
1008
|
+
const exprStart = i + m[0].length
|
|
1009
|
+
const stop = statementEnd(masked, exprStart, end)
|
|
1010
|
+
const r = this.evalString(masked.slice(exprStart, stop), sub())
|
|
1011
|
+
if (m[2] === '+=') {
|
|
1012
|
+
const prev = vars.get(m[1])
|
|
1013
|
+
if (!prev) return null
|
|
1014
|
+
vars.set(m[1], { parts: mergeParts([...prev.parts, ...r.parts]), sawString: true })
|
|
1015
|
+
} else {
|
|
1016
|
+
vars.set(m[1], r)
|
|
1017
|
+
}
|
|
1018
|
+
i = stop + 1
|
|
1019
|
+
continue
|
|
1020
|
+
}
|
|
1021
|
+
return null
|
|
1022
|
+
}
|
|
1023
|
+
return undefined
|
|
1024
|
+
}
|
|
1025
|
+
const r = run(fn.bodyStart + 1, fn.bodyEnd)
|
|
1026
|
+
if (r === undefined) return named ? vars.get(named[1]) : null
|
|
1027
|
+
return r
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** The standard-library package an alias names in `file`, if any. */
|
|
1031
|
+
stdlibName (file, alias) {
|
|
1032
|
+
const importPath = file.imports.get(alias) || [...file.imports.values()].find((p) => p === alias)
|
|
1033
|
+
if (!importPath) return null
|
|
1034
|
+
return importPath.includes('.') ? null : importPath
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* The return statement of a function whose body runs straight to it: no
|
|
1040
|
+
* if/for/switch/select/goto, no function literal, and exactly one return.
|
|
1041
|
+
* Returns { expr, offset } (offset into fn.file.masked) or null.
|
|
1042
|
+
*/
|
|
1043
|
+
function straightLineReturn (fn) {
|
|
1044
|
+
const masked = fn.file.masked
|
|
1045
|
+
const body = masked.slice(fn.bodyStart + 1, fn.bodyEnd)
|
|
1046
|
+
if (/\b(?:if|for|switch|select|goto|func)\b/.test(stripStrings(body))) return null
|
|
1047
|
+
const returns = [...stripStrings(body).matchAll(/(^|[^\w.])return\b/g)]
|
|
1048
|
+
if (returns.length !== 1) return null
|
|
1049
|
+
const at = fn.bodyStart + 1 + returns[0].index + returns[0][1].length + 'return'.length
|
|
1050
|
+
const end = statementEnd(masked, at, fn.bodyEnd)
|
|
1051
|
+
const expr = masked.slice(at, end)
|
|
1052
|
+
if (masked.slice(end, fn.bodyEnd).replace(/[;\s]/g, '') !== '') return null
|
|
1053
|
+
if (expr.trim() === '') return null
|
|
1054
|
+
return { expr, offset: at }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Blank string and rune literal contents, preserving offsets. */
|
|
1058
|
+
function stripStrings (text) {
|
|
1059
|
+
let out = ''
|
|
1060
|
+
let i = 0
|
|
1061
|
+
while (i < text.length) {
|
|
1062
|
+
const ch = text[i]
|
|
1063
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
1064
|
+
const end = skipString(text, i)
|
|
1065
|
+
out += ch + text.slice(i + 1, end - 1).replace(/[^\n]/g, ' ') + (end - 1 > i ? text[end - 1] : '')
|
|
1066
|
+
i = end
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
1069
|
+
out += ch
|
|
1070
|
+
i++
|
|
1071
|
+
}
|
|
1072
|
+
return out
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** The expression of a function body that is exactly `return <expr>`. */
|
|
1076
|
+
function singleReturn (fn) {
|
|
1077
|
+
const body = fn.file.masked.slice(fn.bodyStart + 1, fn.bodyEnd).trim()
|
|
1078
|
+
const m = body.match(/^return\s+([\s\S]+)$/)
|
|
1079
|
+
if (!m) return null
|
|
1080
|
+
// A second statement after the return expression is not a single return.
|
|
1081
|
+
const expr = m[1]
|
|
1082
|
+
const end = statementEnd(expr, 0)
|
|
1083
|
+
if (expr.slice(end).trim() !== '') return null
|
|
1084
|
+
return expr
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function mergeParts (parts) {
|
|
1088
|
+
const out = []
|
|
1089
|
+
for (const part of parts) {
|
|
1090
|
+
const last = out[out.length - 1]
|
|
1091
|
+
if (part.text !== undefined && last && last.text !== undefined) last.text += part.text
|
|
1092
|
+
else out.push({ ...part })
|
|
1093
|
+
}
|
|
1094
|
+
return out
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function isResolved (result) {
|
|
1098
|
+
return result.sawString && result.parts.every((p) => p.text !== undefined)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** The string a result renders to, with numbered placeholders for gaps. */
|
|
1102
|
+
function renderResult (result) {
|
|
1103
|
+
let n = 0
|
|
1104
|
+
return result.parts.map((p) => (p.text !== undefined ? p.text : PLACEHOLDER(++n))).join('')
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function unresolvedParts (result) {
|
|
1108
|
+
return result.parts.filter((p) => p.unresolved !== undefined).map((p) => p.unresolved.replace(/\s+/g, ' '))
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const INDEX_CACHE = new Map()
|
|
1112
|
+
|
|
1113
|
+
/** A shared index per repo path, so one lint run parses each file once. */
|
|
1114
|
+
function indexFor (repo, options = {}) {
|
|
1115
|
+
const key = `${path.resolve(repo)}\0${options.external !== false}`
|
|
1116
|
+
if (!INDEX_CACHE.has(key)) INDEX_CACHE.set(key, new GoIndex(repo, options))
|
|
1117
|
+
return INDEX_CACHE.get(key)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function clearIndexCache () {
|
|
1121
|
+
INDEX_CACHE.clear()
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
module.exports = {
|
|
1125
|
+
GoIndex,
|
|
1126
|
+
indexFor,
|
|
1127
|
+
clearIndexCache,
|
|
1128
|
+
bracketPairs,
|
|
1129
|
+
statementEnd,
|
|
1130
|
+
splitTopLevel,
|
|
1131
|
+
parseParams,
|
|
1132
|
+
scanTopLevel,
|
|
1133
|
+
makeLineIndex,
|
|
1134
|
+
renderResult,
|
|
1135
|
+
isResolved,
|
|
1136
|
+
unresolvedParts,
|
|
1137
|
+
singleReturn,
|
|
1138
|
+
straightLineReturn,
|
|
1139
|
+
stripStrings,
|
|
1140
|
+
mergeParts,
|
|
1141
|
+
PLACEHOLDER,
|
|
1142
|
+
PLACEHOLDER_RE
|
|
1143
|
+
}
|