@redpanda-data/docs-extensions-and-macros 5.51.1 → 5.52.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 +1 -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/rpk-docs/helpers/index.js +33 -36
|
@@ -2,34 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
|
+
const Papa = require('papaparse')
|
|
5
6
|
|
|
6
7
|
const { SourceCache } = require('../source-text')
|
|
7
|
-
const {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
splitTopLevelArgs,
|
|
11
|
-
collectStringConsts,
|
|
12
|
-
evalStringExpr,
|
|
13
|
-
lineOf,
|
|
14
|
-
collectGoFiles
|
|
15
|
-
} = require('../go-source')
|
|
8
|
+
const { splitTopLevelArgs, findBalancedClose, collectGoFiles } = require('../go-source')
|
|
9
|
+
const { GoIndex, statementEnd, straightLineReturn, stripStrings, renderResult, isResolved, unresolvedParts } = require('../go-index')
|
|
10
|
+
const { parseLiteralFields } = require('./rpk')
|
|
16
11
|
|
|
17
12
|
/**
|
|
18
|
-
* Connect surface:
|
|
19
|
-
* service.NewXField(name).Description(...) chains in internal/impl.
|
|
13
|
+
* Connect surface: every published string a Redpanda Connect build defines.
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
15
|
+
* - Component specs (service.ConfigSpec): Summary, Description, Footnotes,
|
|
16
|
+
* and Example titles and summaries.
|
|
17
|
+
* - Config fields (service.ConfigField): Description, ShortDescription, and
|
|
18
|
+
* the option descriptions of annotated enum fields.
|
|
19
|
+
* - Bloblang plugins (bloblang.PluginSpec): Description, Example summaries,
|
|
20
|
+
* and parameter descriptions.
|
|
21
|
+
* - `rpk connect` help (urfave/cli): command Usage, UsageText, ArgsUsage and
|
|
22
|
+
* Description, and flag Usage.
|
|
29
23
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
24
|
+
* Calls are found wherever they are: in helper packages outside
|
|
25
|
+
* internal/impl (httpclient, retries, ...), on specs built by helper
|
|
26
|
+
* constructors (`azureComponentSpec().Summary(...)`), and on variables
|
|
27
|
+
* updated by reassignment (`spec = spec.Description(...)`). The receiver of
|
|
28
|
+
* each call is traced back to its constructor, through local variables,
|
|
29
|
+
* parameters, package variables and helper return types, so a
|
|
30
|
+
* `.Description()` on something that is not a benthos spec is never taken
|
|
31
|
+
* for one.
|
|
32
|
+
*
|
|
33
|
+
* Strings are evaluated across files and packages with GoIndex: constants
|
|
34
|
+
* from another file or package, helper functions that return a string,
|
|
35
|
+
* fmt.Sprintf, strings.Join and local variables all resolve to the exact
|
|
36
|
+
* shipped text. A part that cannot be evaluated is never guessed at: the
|
|
37
|
+
* declaration is still reported, with a numbered `{{unresolved:N}}`
|
|
38
|
+
* placeholder for each gap, meta.unverifiable set (so mechanical rules,
|
|
39
|
+
* which would judge partial text, skip it) and meta.unresolved naming the
|
|
40
|
+
* source of each gap. Calls whose receiver cannot be traced are reported in
|
|
41
|
+
* the extraction's `skipped` list. Nothing is dropped silently.
|
|
42
|
+
*
|
|
43
|
+
* Component declarations carry their registrations (type and name) and the
|
|
44
|
+
* matching internal/plugins/info.csv row (support level, Cloud and Cloud
|
|
45
|
+
* GPU availability), so a review can catch self-managed-only wording in a
|
|
46
|
+
* component that runs in Cloud.
|
|
47
|
+
*
|
|
48
|
+
* Spec strings are AsciiDoc page bodies (cmd/tools/docs_gen writes them into
|
|
49
|
+
* partials verbatim), so AsciiDoc constructs are legitimate: `==` headings,
|
|
50
|
+
* links, tables, and the `raw` + "`literal`" + `raw` backtick idiom. The
|
|
51
|
+
* verbatim raw-pipe rule is skipped because a bare `|` is table syntax in
|
|
52
|
+
* that position. CLI help is not AsciiDoc: rpk-docs formats it like cobra
|
|
53
|
+
* help, so CLI declarations skip the verbatim rules.
|
|
33
54
|
*/
|
|
34
55
|
|
|
35
56
|
const CONVENTION = {
|
|
@@ -38,173 +59,1416 @@ const CONVENTION = {
|
|
|
38
59
|
verbatim_asciidoc: true
|
|
39
60
|
}
|
|
40
61
|
|
|
62
|
+
const CLI_CONVENTION = {
|
|
63
|
+
case: 'sentence',
|
|
64
|
+
terminal_period: false,
|
|
65
|
+
verbatim_asciidoc: false,
|
|
66
|
+
transformer: 'formatDescription',
|
|
67
|
+
auto_inline_code: {
|
|
68
|
+
kinds: ['flag'],
|
|
69
|
+
path_prefixes: ['/etc/', '/var/', '/usr/', '/home/', '/tmp/', '~/.']
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
// Field constructors that ship with NO built-in documentation: a chain on
|
|
42
74
|
// one of these without .Description() publishes an empty field description.
|
|
43
75
|
// Composite helpers (NewTLSToggledField, NewAutoRetryNacksToggleField,
|
|
44
76
|
// NewBatchPolicyField, ...) carry their own docs and are exempt.
|
|
45
77
|
const BARE_FIELD_CTORS = new Set([
|
|
46
|
-
'NewStringField', 'NewStringListField', 'NewStringMapField',
|
|
78
|
+
'NewStringField', 'NewStringListField', 'NewStringMapField', 'NewStringListOfListsField',
|
|
47
79
|
'NewStringEnumField', 'NewStringAnnotatedEnumField',
|
|
48
80
|
'NewIntField', 'NewIntListField', 'NewIntMapField',
|
|
49
|
-
'NewFloatField', 'NewBoolField', 'NewDurationField',
|
|
81
|
+
'NewFloatField', 'NewFloatListField', 'NewFloatMapField', 'NewBoolField', 'NewDurationField',
|
|
50
82
|
'NewInterpolatedStringField', 'NewInterpolatedStringListField',
|
|
51
83
|
'NewInterpolatedStringMapField', 'NewInterpolatedStringEnumField',
|
|
52
84
|
'NewBloblangField', 'NewAnyField', 'NewAnyListField', 'NewAnyMapField',
|
|
53
|
-
'NewObjectField', 'NewObjectListField', 'NewURLField'
|
|
85
|
+
'NewObjectField', 'NewObjectListField', 'NewObjectMapField', 'NewURLField', 'NewURLListField'
|
|
54
86
|
])
|
|
55
87
|
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
88
|
+
const SERVICE_PKG = 'github.com/redpanda-data/benthos/v4/public/service'
|
|
89
|
+
const BLOBLANG_PKG = 'github.com/redpanda-data/benthos/v4/public/bloblang'
|
|
90
|
+
const CLI_PKG = /^github\.com\/urfave\/cli(?:\/v\d+)?$/
|
|
91
|
+
|
|
92
|
+
// Repos whose urfave/cli commands are `rpk connect` help.
|
|
93
|
+
const CLI_MODULES = /^github\.com\/redpanda-data\/(?:connect|benthos)(?:\/v\d+)?$/
|
|
94
|
+
|
|
95
|
+
// Directories scanned in a whole-repo run. Helper packages outside
|
|
96
|
+
// internal/impl (httpclient, retries, ...) and the CLI live under these.
|
|
97
|
+
const SCAN_ROOTS = ['internal', 'public', 'cmd']
|
|
98
|
+
|
|
99
|
+
const DOC_METHODS = /\.\s*(Summary|Description|ShortDescription|Footnotes|Example|ExampleNotTested)\s*\(/g
|
|
100
|
+
|
|
101
|
+
const REGISTRATION = /\b(Must)?Register(Batch)?(Input|Output|Processor|Cache|RateLimit|Buffer|MetricsExporter|OtelTracerProvider|Scanner)(?:Creator)?\s*\(/g
|
|
102
|
+
const BLOBLANG_REGISTRATION = /\b(Must)?Register(Method|Function)V2\s*\(/g
|
|
103
|
+
|
|
104
|
+
const REGISTRATION_TYPES = {
|
|
105
|
+
Input: 'input',
|
|
106
|
+
Output: 'output',
|
|
107
|
+
Processor: 'processor',
|
|
108
|
+
Cache: 'cache',
|
|
109
|
+
RateLimit: 'rate_limit',
|
|
110
|
+
Buffer: 'buffer',
|
|
111
|
+
MetricsExporter: 'metric',
|
|
112
|
+
OtelTracerProvider: 'tracer',
|
|
113
|
+
Scanner: 'scanner',
|
|
114
|
+
Method: 'bloblang-method',
|
|
115
|
+
Function: 'bloblang-function'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MAX_COMPONENTS = 10
|
|
119
|
+
const MAX_TRACE_DEPTH = 40
|
|
120
|
+
|
|
121
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
59
122
|
|
|
60
123
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
124
|
+
* Read internal/plugins/info.csv: type/name -> support and availability.
|
|
125
|
+
* Columns are matched by header name, so a reordered or extended CSV still
|
|
126
|
+
* reads correctly.
|
|
64
127
|
*/
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
let
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
128
|
+
function readInfoCsv (repo) {
|
|
129
|
+
const rows = new Map()
|
|
130
|
+
let text
|
|
131
|
+
try {
|
|
132
|
+
text = fs.readFileSync(path.join(repo, 'internal', 'plugins', 'info.csv'), 'utf8')
|
|
133
|
+
} catch {
|
|
134
|
+
return rows
|
|
135
|
+
}
|
|
136
|
+
// Parse as CSV, so a quoted comma in a commercial name can't shift the
|
|
137
|
+
// support and Cloud columns.
|
|
138
|
+
const { data } = Papa.parse(text, { skipEmptyLines: 'greedy' })
|
|
139
|
+
if (data.length === 0) return rows
|
|
140
|
+
const header = data[0].map((h) => h.trim())
|
|
141
|
+
for (const record of data.slice(1)) {
|
|
142
|
+
const cells = record.map((c) => c.trim())
|
|
143
|
+
const get = (name) => (header.indexOf(name) === -1 ? '' : cells[header.indexOf(name)] || '')
|
|
144
|
+
const name = get('name')
|
|
145
|
+
const type = get('type')
|
|
146
|
+
if (!name || !type) continue
|
|
147
|
+
rows.set(`${type}/${name}`, {
|
|
148
|
+
support: get('support') || null,
|
|
149
|
+
cloud: get('cloud') === 'y',
|
|
150
|
+
cloud_ai: get('cloud_with_gpu') === 'y',
|
|
151
|
+
deprecated: get('deprecated') === 'y',
|
|
152
|
+
commercial_name: get('commercial_name') || null,
|
|
153
|
+
cloud_unsupported_reason: get('cloud_unsupported_reason') || null
|
|
80
154
|
})
|
|
81
|
-
i = closeIndex + 1
|
|
82
155
|
}
|
|
83
|
-
return
|
|
156
|
+
return rows
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The package name an unaliased import is known by. */
|
|
160
|
+
function importName (importPath) {
|
|
161
|
+
return importPath.split('/').filter((s) => !/^v\d+$/.test(s)).pop()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Import aliases in `file` that name a package whose path passes `test`. */
|
|
165
|
+
function aliasesFor (file, test) {
|
|
166
|
+
const out = new Set()
|
|
167
|
+
for (const [key, value] of file.imports) {
|
|
168
|
+
if (key === null) continue
|
|
169
|
+
const matches = typeof test === 'string' ? value === test : test.test(value)
|
|
170
|
+
if (!matches) continue
|
|
171
|
+
if (key.startsWith('\0')) out.add(importName(value))
|
|
172
|
+
else if (key !== '_' && key !== '.') out.add(key)
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The top-level function whose body contains `offset`, or null. */
|
|
178
|
+
function enclosingFunc (file, offset) {
|
|
179
|
+
for (const fn of file.funcs) {
|
|
180
|
+
if (offset > fn.bodyStart && offset < fn.bodyEnd) return { ...fn, file }
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The top-level declaration (func or var) whose text contains `offset`. */
|
|
186
|
+
function enclosingUnit (file, offset) {
|
|
187
|
+
for (const fn of file.funcs) {
|
|
188
|
+
if (offset >= fn.start && offset <= fn.bodyEnd) return { kind: 'func', name: fn.name }
|
|
189
|
+
}
|
|
190
|
+
for (const value of file.values) {
|
|
191
|
+
if (offset >= value.start && offset <= value.end) return { kind: value.kind, name: value.name }
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function enclosingFuncKey (file, offset) {
|
|
197
|
+
const fn = enclosingFunc(file, offset)
|
|
198
|
+
return fn ? `${file.abs}:${fn.start}` : `${file.abs}:top`
|
|
84
199
|
}
|
|
85
200
|
|
|
201
|
+
const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
|
202
|
+
const isWord = (ch) => ch !== undefined && /\w/.test(ch)
|
|
203
|
+
|
|
86
204
|
/**
|
|
87
|
-
*
|
|
205
|
+
* Walk a builder chain backwards from just after its last character (the
|
|
206
|
+
* index of the `.` before a method name, or the end of an expression) to the
|
|
207
|
+
* expression that starts it. Returns the head:
|
|
208
|
+
* { type: 'call', qual, name, open, close, start } - `qual.name(...)` or `name(...)`
|
|
209
|
+
* { type: 'ident', name, start } - a variable
|
|
210
|
+
* { type: 'unknown', start } - anything else
|
|
211
|
+
* plus `chain`, the methods between the head and that position.
|
|
88
212
|
*/
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
213
|
+
function chainHead (masked, pairs, dotIndex) {
|
|
214
|
+
let i = dotIndex - 1
|
|
215
|
+
const chain = []
|
|
216
|
+
for (;;) {
|
|
217
|
+
while (i >= 0 && isSpace(masked[i])) i--
|
|
218
|
+
if (masked[i] === ')') {
|
|
219
|
+
const open = pairs.get(i)
|
|
220
|
+
if (open === undefined) return { type: 'unknown', chain, start: i }
|
|
221
|
+
let j = open - 1
|
|
222
|
+
while (j >= 0 && isSpace(masked[j])) j--
|
|
223
|
+
let k = j
|
|
224
|
+
while (k >= 0 && isWord(masked[k])) k--
|
|
225
|
+
const name = masked.slice(k + 1, j + 1)
|
|
226
|
+
if (!name || /^\d/.test(name)) return { type: 'unknown', chain, start: open }
|
|
227
|
+
let d = k
|
|
228
|
+
while (d >= 0 && isSpace(masked[d])) d--
|
|
229
|
+
if (masked[d] !== '.') return { type: 'call', qual: null, name, open, close: i, chain, start: k + 1 }
|
|
230
|
+
let e = d - 1
|
|
231
|
+
while (e >= 0 && isSpace(masked[e])) e--
|
|
232
|
+
if (masked[e] === ')' || masked[e] === ']') {
|
|
233
|
+
chain.unshift({ method: name, open, close: i })
|
|
234
|
+
i = d - 1
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
let f = e
|
|
238
|
+
while (f >= 0 && isWord(masked[f])) f--
|
|
239
|
+
const qual = masked.slice(f + 1, e + 1)
|
|
240
|
+
let g = f
|
|
241
|
+
while (g >= 0 && isSpace(masked[g])) g--
|
|
242
|
+
// a.b.Method(...): a field-selector receiver, not traceable.
|
|
243
|
+
if (!qual || masked[g] === '.') return { type: 'unknown', chain, start: f + 1 }
|
|
244
|
+
return { type: 'call', qual, name, open, close: i, chain, start: f + 1 }
|
|
245
|
+
}
|
|
246
|
+
if (masked[i] === ']') {
|
|
247
|
+
// fields[0].Description(...): an element of a field list.
|
|
248
|
+
const open = pairs.get(i)
|
|
249
|
+
if (open === undefined) return { type: 'unknown', chain, start: i }
|
|
250
|
+
let k = open - 1
|
|
251
|
+
while (k >= 0 && isWord(masked[k])) k--
|
|
252
|
+
const name = masked.slice(k + 1, open)
|
|
253
|
+
let d = k
|
|
254
|
+
while (d >= 0 && isSpace(masked[d])) d--
|
|
255
|
+
if (!name || masked[d] === '.') return { type: 'unknown', chain, start: k + 1 }
|
|
256
|
+
return { type: 'index', name, index: masked.slice(open + 1, i).trim(), chain, start: k + 1 }
|
|
257
|
+
}
|
|
258
|
+
if (isWord(masked[i])) {
|
|
259
|
+
let k = i
|
|
260
|
+
while (k >= 0 && isWord(masked[k])) k--
|
|
261
|
+
const name = masked.slice(k + 1, i + 1)
|
|
262
|
+
let d = k
|
|
263
|
+
while (d >= 0 && isSpace(masked[d])) d--
|
|
264
|
+
if (masked[d] === '.') return { type: 'unknown', chain, start: k + 1 }
|
|
265
|
+
return { type: 'ident', name, chain, start: k + 1 }
|
|
266
|
+
}
|
|
267
|
+
return { type: 'unknown', chain, start: i }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Classify a Go type or function result list as a spec kind, or null. */
|
|
272
|
+
function kindOfType (typeText) {
|
|
273
|
+
const t = String(typeText || '').replace(/^\(\s*/, '').trim()
|
|
274
|
+
if (/^\[\]\*?(?:\w+\.)?ConfigField\b/.test(t)) return 'field-list'
|
|
275
|
+
if (/^\[\]/.test(t) || /^map\[/.test(t)) return null
|
|
276
|
+
if (/^\*?(?:\w+\.)?ConfigSpec\b/.test(t)) return 'spec'
|
|
277
|
+
if (/^\*?(?:\w+\.)?ConfigField\b/.test(t)) return 'field'
|
|
278
|
+
if (/^\*?(?:\w+\.)?PluginSpec\b/.test(t)) return 'blobl-spec'
|
|
279
|
+
if (/^(?:\w+\.)?ParamDefinition\b/.test(t)) return 'blobl-param'
|
|
280
|
+
// The javascript processor's own builder for the functions it exposes to
|
|
281
|
+
// scripts, published in the processor's footnotes.
|
|
282
|
+
if (/^\*?jsFunctionDefinition\b/.test(t)) return 'js-function'
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Index of the first top-level `:` in a `key: value` entry, or -1. */
|
|
287
|
+
function topLevelColon (text) {
|
|
288
|
+
let depth = 0
|
|
289
|
+
for (let i = 0; i < text.length; i++) {
|
|
290
|
+
const ch = text[i]
|
|
291
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
292
|
+
const q = ch
|
|
293
|
+
i++
|
|
294
|
+
while (i < text.length && text[i] !== q) i += (q !== '`' && text[i] === '\\') ? 2 : 1
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
if (ch === '(' || ch === '{' || ch === '[') depth++
|
|
298
|
+
else if (ch === ')' || ch === '}' || ch === ']') depth--
|
|
299
|
+
else if (ch === ':' && depth === 0) return i
|
|
300
|
+
}
|
|
301
|
+
return -1
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Forward builder chain from `start`: [{ method, open, close }]. */
|
|
305
|
+
function readChain (masked, pairs, start) {
|
|
306
|
+
const calls = []
|
|
307
|
+
let i = start
|
|
308
|
+
for (;;) {
|
|
309
|
+
const m = masked.slice(i, i + 200).match(/^\s*\.\s*([A-Za-z0-9_]+)\s*\(/)
|
|
310
|
+
if (!m) break
|
|
311
|
+
const open = i + m[0].length - 1
|
|
312
|
+
const close = pairs.get(open)
|
|
313
|
+
if (close === undefined) break
|
|
314
|
+
calls.push({ method: m[1], open, close })
|
|
315
|
+
i = close + 1
|
|
316
|
+
}
|
|
317
|
+
return calls
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
class ConnectScanner {
|
|
321
|
+
constructor (index, { repo }) {
|
|
322
|
+
this.index = index
|
|
323
|
+
this.info = readInfoCsv(repo)
|
|
324
|
+
this.registrations = new Map() // package dir -> registrations
|
|
325
|
+
this.callers = new Map() // package dir -> Map(name -> Set(caller names))
|
|
326
|
+
this.cli = CLI_MODULES.test(index.gomod.module || '')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
aliases (file) {
|
|
330
|
+
if (!file._aliases) {
|
|
331
|
+
file._aliases = {
|
|
332
|
+
service: aliasesFor(file, SERVICE_PKG),
|
|
333
|
+
bloblang: aliasesFor(file, BLOBLANG_PKG),
|
|
334
|
+
cli: aliasesFor(file, CLI_PKG)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return file._aliases
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
isImportAlias (file, alias) {
|
|
341
|
+
for (const [key, value] of file.imports) {
|
|
342
|
+
if (key === alias) return true
|
|
343
|
+
if (key && key.startsWith('\0')) {
|
|
344
|
+
const last = importName(value)
|
|
345
|
+
if (last === alias || last.replace(/-/g, '_') === alias) return true
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Evaluate a string expression found at `offset` in `file`. */
|
|
352
|
+
evalAt (file, exprText, offset) {
|
|
353
|
+
const fn = enclosingFunc(file, offset)
|
|
354
|
+
return this.index.evalString(exprText, {
|
|
355
|
+
file,
|
|
356
|
+
depth: 0,
|
|
357
|
+
resolveLocal: fn ? this.index.localResolver(file, fn, offset) : undefined
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* What a chain head constructs: { kind, ctor, name } where kind is
|
|
363
|
+
* spec | field | blobl-spec | blobl-param | null, and name (fields and
|
|
364
|
+
* params) is an evaluated Result.
|
|
365
|
+
*/
|
|
366
|
+
classifyHead (file, head, offset, depth = 0) {
|
|
367
|
+
const none = { kind: null }
|
|
368
|
+
if (depth > MAX_TRACE_DEPTH) return none
|
|
369
|
+
const { service, bloblang } = this.aliases(file)
|
|
370
|
+
if (head.type === 'call') {
|
|
371
|
+
const args = () => splitTopLevelArgs(file.masked.slice(head.open + 1, head.close))
|
|
372
|
+
if (head.qual && service.has(head.qual)) {
|
|
373
|
+
if (/^New(?:Struct)?ConfigSpec$/.test(head.name)) return { kind: 'spec', ctor: head.name }
|
|
374
|
+
if (/^New\w*Field$/.test(head.name)) return { kind: 'field', ctor: head.name, ...this.fieldNameOf(file, head, args()) }
|
|
375
|
+
return this.classifyFunc(file, `${head.qual}.${head.name}`, args(), depth)
|
|
376
|
+
}
|
|
377
|
+
if (head.qual && bloblang.has(head.qual)) {
|
|
378
|
+
if (head.name === 'NewPluginSpec') return { kind: 'blobl-spec', ctor: head.name }
|
|
379
|
+
if (/^New\w*Param$/.test(head.name)) {
|
|
380
|
+
const a = args()
|
|
381
|
+
return { kind: 'blobl-param', ctor: head.name, name: a.length ? this.evalAt(file, a[0], head.open) : null }
|
|
382
|
+
}
|
|
383
|
+
return none
|
|
384
|
+
}
|
|
385
|
+
if (head.qual && !this.isImportAlias(file, head.qual)) {
|
|
386
|
+
// `spec.Field(...).Description(...)`: a method on a variable.
|
|
387
|
+
return this.classifyIdent(file, head.qual, head.start, depth + 1)
|
|
388
|
+
}
|
|
389
|
+
return this.classifyFunc(file, head.qual ? `${head.qual}.${head.name}` : head.name, args(), depth)
|
|
390
|
+
}
|
|
391
|
+
if (head.type === 'ident') return this.classifyIdent(file, head.name, head.start, depth)
|
|
392
|
+
if (head.type === 'index') {
|
|
393
|
+
const base = this.classifyIdent(file, head.name, head.start, depth + 1)
|
|
394
|
+
if (base.kind !== 'field-list') return none
|
|
395
|
+
return { kind: 'field', ctor: null, via: `${head.name}[${head.index}]`, name: this.listElementName(file, head, depth) }
|
|
396
|
+
}
|
|
397
|
+
return none
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The name of element N of a field list built by a helper that returns a
|
|
402
|
+
* `[]*service.ConfigField{...}` literal: `fields := FranzConnectionFields()`
|
|
403
|
+
* then `fields[0].Description(...)`.
|
|
404
|
+
*/
|
|
405
|
+
listElementName (file, head, depth) {
|
|
406
|
+
if (!/^\d+$/.test(head.index)) return null
|
|
407
|
+
const fn = enclosingFunc(file, head.start)
|
|
408
|
+
const assignment = fn ? this.lastAssignment(file, fn, head.name, head.start) : null
|
|
409
|
+
const call = assignment && assignment.expr && assignment.expr.trim().match(/^([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)\s*\(\s*\)$/)
|
|
410
|
+
const helper = call ? this.index.lookupFunc(file, call[1]) : null
|
|
411
|
+
const ret = helper ? straightLineReturn(helper) : null
|
|
412
|
+
const lit = ret && ret.expr.match(/^\s*\[\]\*?(?:\w+\.)?ConfigField\s*\{/)
|
|
413
|
+
if (!lit) return null
|
|
414
|
+
const braceOpen = ret.offset + lit[0].length - 1
|
|
415
|
+
const braceClose = helper.file.pairs.get(braceOpen)
|
|
416
|
+
if (braceClose === undefined) return null
|
|
417
|
+
const element = splitTopLevelArgs(helper.file.masked.slice(braceOpen + 1, braceClose))[Number(head.index)]
|
|
418
|
+
const m = element && element.match(/New\w*Field\s*\(/)
|
|
419
|
+
if (!m) return null
|
|
420
|
+
const open = element.indexOf('(', m.index)
|
|
421
|
+
const close = findBalancedClose(element, open)
|
|
422
|
+
if (close === -1) return null
|
|
423
|
+
const nameArg = splitTopLevelArgs(element.slice(open + 1, close))[0]
|
|
424
|
+
return nameArg ? this.index.evalString(nameArg, { file: helper.file, depth: depth + 1 }) : null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A call to a helper: classified by its declared result type. */
|
|
428
|
+
classifyFunc (file, callee, args, depth) {
|
|
429
|
+
const fn = this.index.lookupFunc(file, callee)
|
|
430
|
+
if (!fn) return { kind: null }
|
|
431
|
+
const kind = kindOfType(fn.results)
|
|
432
|
+
if (!kind) return { kind: null }
|
|
433
|
+
const out = { kind, ctor: callee, helper: true }
|
|
434
|
+
if (kind === 'field' || kind === 'blobl-param') Object.assign(out, this.helperFieldName(fn, args, file, depth))
|
|
435
|
+
return out
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The field a helper builds: the first benthos field or param
|
|
440
|
+
* constructor in its body, its name argument evaluated with the helper's
|
|
441
|
+
* parameters bound to this call's arguments. Inside benthos itself the
|
|
442
|
+
* constructors are unqualified.
|
|
443
|
+
*/
|
|
444
|
+
helperFieldName (fn, args, callerFile, depth) {
|
|
445
|
+
const helperFile = fn.file
|
|
446
|
+
const body = helperFile.masked.slice(fn.bodyStart, fn.bodyEnd)
|
|
447
|
+
const { service, bloblang } = this.aliases(helperFile)
|
|
448
|
+
const quals = [...service, ...bloblang].map(escapeRe)
|
|
449
|
+
const qualified = quals.length > 0 ? `(?:(?:${quals.join('|')})\\s*\\.\\s*)?` : ''
|
|
450
|
+
const m = body.match(new RegExp(`(?:^|[^\\w.])${qualified}(New\\w*(?:Field|Param))\\s*\\(`))
|
|
451
|
+
if (!m) return { name: null }
|
|
452
|
+
const open = fn.bodyStart + m.index + m[0].length - 1
|
|
453
|
+
const close = helperFile.pairs.get(open)
|
|
454
|
+
if (close === undefined) return { name: null }
|
|
455
|
+
const ctorArgs = splitTopLevelArgs(helperFile.masked.slice(open + 1, close))
|
|
456
|
+
if (ctorArgs.length === 0 || ctorArgs[0].trim() === '') return { name: null }
|
|
457
|
+
const locals = new Map()
|
|
458
|
+
fn.params.forEach((param, i) => {
|
|
459
|
+
if (!param.name) return
|
|
460
|
+
const r = i < args.length ? this.index.evalString(args[i], { file: callerFile, depth: depth + 1 }) : null
|
|
461
|
+
locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
|
|
462
|
+
})
|
|
463
|
+
const name = this.index.evalString(ctorArgs[0], {
|
|
464
|
+
file: helperFile,
|
|
465
|
+
locals,
|
|
466
|
+
depth: depth + 1,
|
|
467
|
+
resolveLocal: this.index.localResolver(helperFile, fn, open)
|
|
468
|
+
})
|
|
469
|
+
return { name }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The field name of a benthos field constructor call. */
|
|
473
|
+
fieldNameOf (file, head, args) {
|
|
474
|
+
if (args.length === 0 || args[0].trim() === '') {
|
|
475
|
+
// Nameless composite constructors (NewOutputMaxInFlightField) name
|
|
476
|
+
// their field inside benthos.
|
|
477
|
+
const fn = this.index.lookupFunc(file, `${head.qual}.${head.name}`)
|
|
478
|
+
return fn ? this.helperFieldName(fn, [], file, 1) : { name: null }
|
|
479
|
+
}
|
|
480
|
+
return { name: this.evalAt(file, args[0], head.open), nameExpr: { text: args[0], offset: head.open } }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A variable, traced to its assignment, parameter type, or package var. */
|
|
484
|
+
classifyIdent (file, ident, offset, depth) {
|
|
485
|
+
if (depth > MAX_TRACE_DEPTH) return { kind: null }
|
|
486
|
+
const fn = enclosingFunc(file, offset)
|
|
487
|
+
if (fn) {
|
|
488
|
+
const assignment = this.lastAssignment(file, fn, ident, offset)
|
|
489
|
+
if (assignment) {
|
|
490
|
+
const kind = assignment.type ? kindOfType(assignment.type) : null
|
|
491
|
+
if (kind) return { kind, via: ident }
|
|
492
|
+
if (assignment.expr) {
|
|
493
|
+
const cls = this.classifyExpr(file, assignment.expr, assignment.exprStart, depth + 1)
|
|
494
|
+
return { ...cls, via: ident }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const param = fn.params.find((p) => p.name === ident)
|
|
498
|
+
if (param) return { kind: kindOfType(param.type), via: ident }
|
|
499
|
+
if (fn.recv) {
|
|
500
|
+
const m = fn.recv.match(/^(\w+)\s+(.+)$/)
|
|
501
|
+
if (m && m[1] === ident) return { kind: kindOfType(m[2]), via: ident }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const value = this.index.lookupValue(file, ident)
|
|
505
|
+
if (value) {
|
|
506
|
+
const kind = value.type ? kindOfType(value.type) : null
|
|
507
|
+
if (kind) return { kind, via: ident }
|
|
508
|
+
if (value.expr) return { ...this.classifyExpr(value.file, value.expr, value.exprStart, depth + 1), via: ident }
|
|
509
|
+
}
|
|
510
|
+
return { kind: null }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Classify an expression by the constructor at the head of its chain. */
|
|
514
|
+
classifyExpr (file, exprText, exprStart, depth) {
|
|
515
|
+
let end = exprStart + exprText.length
|
|
516
|
+
while (end > exprStart && isSpace(file.masked[end - 1])) end--
|
|
517
|
+
const trimmed = exprText.trim().replace(/^&/, '')
|
|
518
|
+
if (/^[A-Za-z_]\w*$/.test(trimmed)) {
|
|
519
|
+
return this.classifyIdent(file, trimmed, exprStart + exprText.indexOf(trimmed), depth + 1)
|
|
520
|
+
}
|
|
521
|
+
if (file.masked[end - 1] !== ')') return { kind: null }
|
|
522
|
+
// The builder methods return their receiver type, so the head of the
|
|
523
|
+
// chain decides what the whole expression is.
|
|
524
|
+
return this.classifyHead(file, chainHead(file.masked, file.pairs, end), exprStart, depth + 1)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The last assignment to `ident` before offset in fn: `ident := expr`,
|
|
529
|
+
* `ident = expr`, `var ident [T] = expr` or `var ident T`.
|
|
530
|
+
* Returns { type, expr, exprStart } or null.
|
|
531
|
+
*/
|
|
532
|
+
lastAssignment (file, fn, ident, offset) {
|
|
533
|
+
return this.assignments(file, fn, ident, offset)[0] || null
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Every assignment to `ident` before offset in fn, latest first. See
|
|
538
|
+
* lastAssignment for the forms. A spec built up by reassignment
|
|
539
|
+
* (`conf = conf.Field(f)` in a loop) has several, and each one is part of
|
|
540
|
+
* the spec.
|
|
541
|
+
*/
|
|
542
|
+
assignments (file, fn, ident, offset) {
|
|
543
|
+
const masked = file.masked
|
|
544
|
+
const text = masked.slice(fn.bodyStart, offset)
|
|
545
|
+
const esc = escapeRe(ident)
|
|
546
|
+
const re = new RegExp(`(^|[^\\w.])(?:var\\s+${esc}\\s+([^=\\n;]+?)\\s*(?:(=)(?!=)|[\\n;])|${esc}\\s*(:=|=)(?!=))`, 'g')
|
|
547
|
+
const matches = []
|
|
548
|
+
let m
|
|
549
|
+
while ((m = re.exec(text)) !== null) matches.push(m)
|
|
550
|
+
const out = []
|
|
551
|
+
// Latest first. An assignment whose right-hand side contains offset is
|
|
552
|
+
// the statement being traced (`spec = spec.Example(...)`): the variable
|
|
553
|
+
// there still holds its previous value.
|
|
554
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
555
|
+
const last = matches[i]
|
|
556
|
+
const at = fn.bodyStart + last.index + last[0].length
|
|
557
|
+
if (last[2] && !last[3]) {
|
|
558
|
+
out.push({ type: last[2].trim() })
|
|
559
|
+
break
|
|
560
|
+
}
|
|
561
|
+
const exprEnd = statementEnd(masked, at, fn.bodyEnd)
|
|
562
|
+
if (offset >= at && offset <= exprEnd) continue
|
|
563
|
+
out.push({ type: last[2] ? last[2].trim() : null, expr: masked.slice(at, exprEnd), exprStart: at, declares: Boolean(last[2] || last[4] === ':=') })
|
|
564
|
+
// A declaration starts the variable: nothing earlier is the same one.
|
|
565
|
+
if (last[2] || last[4] === ':=') break
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Registrations in one package: [{ type, name, refs, spans }]. */
|
|
571
|
+
packageRegistrations (dir) {
|
|
572
|
+
if (this.registrations.has(dir)) return this.registrations.get(dir)
|
|
573
|
+
const regs = []
|
|
574
|
+
for (const file of this.index.package(dir).files) {
|
|
575
|
+
for (const pattern of [REGISTRATION, BLOBLANG_REGISTRATION]) {
|
|
576
|
+
pattern.lastIndex = 0
|
|
577
|
+
let m
|
|
578
|
+
while ((m = pattern.exec(file.masked)) !== null) {
|
|
579
|
+
const open = m.index + m[0].length - 1
|
|
580
|
+
const close = file.pairs.get(open)
|
|
581
|
+
if (close === undefined) continue
|
|
582
|
+
const args = splitTopLevelArgs(file.masked.slice(open + 1, close))
|
|
583
|
+
if (args.length < 2) continue
|
|
584
|
+
const nameResult = this.evalAt(file, args[0], open)
|
|
585
|
+
const reg = {
|
|
586
|
+
type: REGISTRATION_TYPES[pattern === REGISTRATION ? m[3] : m[2]],
|
|
587
|
+
name: isResolved(nameResult) ? renderResult(nameResult) : null,
|
|
588
|
+
refs: new Set(),
|
|
589
|
+
spans: []
|
|
590
|
+
}
|
|
591
|
+
this.collectSpecRefs(file, args[1], open + 1 + args[0].length + 1, reg, 0)
|
|
592
|
+
regs.push(reg)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
this.registrations.set(dir, regs)
|
|
597
|
+
return regs
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** What a registration's spec argument refers to. */
|
|
601
|
+
collectSpecRefs (file, exprText, exprStart, reg, depth) {
|
|
602
|
+
if (depth > 3) return
|
|
603
|
+
const trimmed = exprText.trim()
|
|
604
|
+
const lead = exprText.length - exprText.trimStart().length
|
|
605
|
+
if (/^[A-Za-z_]\w*$/.test(trimmed)) {
|
|
606
|
+
const fn = enclosingFunc(file, exprStart)
|
|
607
|
+
const assignments = fn ? this.assignments(file, fn, trimmed, exprStart).filter((a) => a.expr) : []
|
|
608
|
+
if (fn) this.collectMutations(file, fn, trimmed, exprStart, reg)
|
|
609
|
+
if (assignments.length > 0) {
|
|
610
|
+
for (const a of assignments) this.collectSpecRefs(file, a.expr, a.exprStart, reg, depth + 1)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
reg.refs.add(trimmed)
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const call = trimmed.match(/^([A-Za-z_]\w*)\s*\(/)
|
|
617
|
+
if (call) reg.refs.add(call[1])
|
|
618
|
+
reg.spans.push([file.abs, exprStart + lead, exprStart + lead + trimmed.length])
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Builder statements that change a spec in place without reassigning it:
|
|
623
|
+
* `spec.Field(...)` on a line of its own. ConfigSpec methods mutate their
|
|
624
|
+
* receiver, so these are part of the registered spec too.
|
|
625
|
+
*/
|
|
626
|
+
collectMutations (file, fn, ident, before, reg) {
|
|
627
|
+
const masked = file.masked
|
|
628
|
+
const re = new RegExp(`(^|[\\n;{]\\s*)${escapeRe(ident)}\\s*\\.\\s*[A-Z]\\w*\\s*\\(`, 'g')
|
|
629
|
+
// String contents blanked, so example YAML in a raw string never
|
|
630
|
+
// reads as a statement.
|
|
631
|
+
const text = stripStrings(masked.slice(fn.bodyStart + 1, before))
|
|
632
|
+
let m
|
|
633
|
+
while ((m = re.exec(text)) !== null) {
|
|
634
|
+
const start = fn.bodyStart + 1 + m.index + m[1].length
|
|
635
|
+
const end = statementEnd(masked, start, fn.bodyEnd)
|
|
636
|
+
reg.spans.push([file.abs, start, end])
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Package-level names a registration's inline spec text mentions, such as
|
|
642
|
+
* the field helper in `conf = conf.Field(accountField("x"))`, so a
|
|
643
|
+
* declaration in that helper reaches the registration.
|
|
644
|
+
*/
|
|
645
|
+
addSpanRefs (dir, regs) {
|
|
646
|
+
if (regs._spanRefs) return
|
|
647
|
+
regs._spanRefs = true
|
|
648
|
+
const pkg = this.index.package(dir)
|
|
649
|
+
const names = new Set([...pkg.funcs.keys(), ...pkg.values.keys()])
|
|
650
|
+
for (const reg of regs) {
|
|
651
|
+
for (const [abs, start, end] of reg.spans) {
|
|
652
|
+
const file = this.index.file(abs)
|
|
653
|
+
if (!file) continue
|
|
654
|
+
const text = stripStrings(file.masked.slice(start, end))
|
|
655
|
+
for (const m of text.matchAll(/(?:^|[^\w.])([A-Za-z_]\w*)/g)) if (names.has(m[1])) reg.refs.add(m[1])
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** name -> Set of package-level names whose text mentions it. */
|
|
661
|
+
packageCallers (dir) {
|
|
662
|
+
if (this.callers.has(dir)) return this.callers.get(dir)
|
|
663
|
+
const units = []
|
|
664
|
+
for (const file of this.index.package(dir).files) {
|
|
665
|
+
for (const fn of file.funcs) units.push({ name: fn.name, text: file.masked.slice(fn.start, fn.bodyEnd + 1) })
|
|
666
|
+
for (const value of file.values) units.push({ name: value.name, text: file.masked.slice(value.start, value.end) })
|
|
667
|
+
}
|
|
668
|
+
const names = new Set(units.map((u) => u.name))
|
|
669
|
+
const callers = new Map()
|
|
670
|
+
for (const unit of units) {
|
|
671
|
+
for (const m of unit.text.matchAll(/(?:^|[^\w.])([A-Za-z_]\w*)/g)) {
|
|
672
|
+
const name = m[1]
|
|
673
|
+
if (name === unit.name || !names.has(name)) continue
|
|
674
|
+
if (!callers.has(name)) callers.set(name, new Set())
|
|
675
|
+
callers.get(name).add(unit.name)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
this.callers.set(dir, callers)
|
|
679
|
+
return callers
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Directories of the in-module packages that import `importPath`, built
|
|
684
|
+
* once per run from the import blocks of every scanned root.
|
|
685
|
+
*/
|
|
686
|
+
importersOf (importPath) {
|
|
687
|
+
if (!this.importers) {
|
|
688
|
+
this.importers = new Map()
|
|
689
|
+
const mod = this.index.gomod.module
|
|
690
|
+
for (const root of SCAN_ROOTS) {
|
|
691
|
+
for (const rel of collectGoFiles(path.join(this.index.repo, root))) {
|
|
692
|
+
const abs = path.join(this.index.repo, root, rel)
|
|
693
|
+
let head
|
|
694
|
+
try {
|
|
695
|
+
head = fs.readFileSync(abs, 'utf8')
|
|
696
|
+
} catch {
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
const end = head.search(/\n(?:func|type|var|const)\b/)
|
|
700
|
+
for (const m of (end === -1 ? head : head.slice(0, end)).matchAll(/"([^"\s]+)"/g)) {
|
|
701
|
+
if (!mod || !m[1].startsWith(`${mod}/`)) continue
|
|
702
|
+
if (!this.importers.has(m[1])) this.importers.set(m[1], new Set())
|
|
703
|
+
this.importers.get(m[1]).add(path.dirname(abs))
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return [...(this.importers.get(importPath) || [])]
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Registrations in other packages that reach an exported helper: a spec
|
|
713
|
+
* built in one package (azure/cosmosdb, httpclient) and registered in
|
|
714
|
+
* another.
|
|
715
|
+
*/
|
|
716
|
+
componentsViaImporters (file, unitName, hops) {
|
|
717
|
+
const mod = this.index.gomod.module
|
|
718
|
+
if (!mod || hops > 2 || !/^[A-Z]/.test(unitName)) return []
|
|
719
|
+
const importPath = `${mod}/${this.index.rel(file.dir).split(path.sep).join('/')}`
|
|
720
|
+
const found = []
|
|
721
|
+
for (const dir of this.importersOf(importPath)) {
|
|
722
|
+
for (const other of this.index.package(dir).files) {
|
|
723
|
+
for (const [key, value] of other.imports) {
|
|
724
|
+
if (value !== importPath || key === null) continue
|
|
725
|
+
const alias = key.startsWith('\0') ? this.index.package(file.dir).name || importName(value) : key
|
|
726
|
+
const re = new RegExp(`(?:^|[^\\w.])${escapeRe(alias)}\\s*\\.\\s*${escapeRe(unitName)}\\b`, 'g')
|
|
727
|
+
for (const m of other.masked.matchAll(re)) found.push(...this.componentsAt(other, m.index + 1, hops + 1))
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return found
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Registrations whose spec reaches the code at `offset` in `file`. */
|
|
735
|
+
componentsAt (file, offset, hops = 0) {
|
|
736
|
+
const regs = this.packageRegistrations(file.dir)
|
|
737
|
+
const inline = regs.filter((r) => r.spans.some(([abs, s, e]) => abs === file.abs && offset >= s && offset <= e))
|
|
738
|
+
if (inline.length > 0) return inline
|
|
739
|
+
const unit = enclosingUnit(file, offset)
|
|
740
|
+
if (!unit) return []
|
|
741
|
+
this.addSpanRefs(file.dir, regs)
|
|
742
|
+
const callers = this.packageCallers(file.dir)
|
|
743
|
+
const seen = new Set([unit.name])
|
|
744
|
+
let frontier = [unit.name]
|
|
745
|
+
const found = new Set()
|
|
746
|
+
for (let depth = 0; depth < MAX_TRACE_DEPTH && frontier.length > 0; depth++) {
|
|
747
|
+
for (const reg of regs) if (frontier.some((n) => reg.refs.has(n))) found.add(reg)
|
|
748
|
+
const next = []
|
|
749
|
+
for (const name of frontier) {
|
|
750
|
+
for (const caller of callers.get(name) || []) {
|
|
751
|
+
if (seen.has(caller)) continue
|
|
752
|
+
seen.add(caller)
|
|
753
|
+
next.push(caller)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
frontier = next
|
|
757
|
+
}
|
|
758
|
+
if (found.size === 0) {
|
|
759
|
+
// Nothing in this package registers it: follow the exported names it
|
|
760
|
+
// was reached through into the packages that import them.
|
|
761
|
+
for (const name of seen) {
|
|
762
|
+
for (const reg of this.componentsViaImporters(file, name, hops)) found.add(reg)
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return [...found]
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* { components: [{ type, name, ...info.csv row }], components_total }.
|
|
770
|
+
* An offset of -1 asks for the package's single component.
|
|
771
|
+
*/
|
|
772
|
+
componentMeta (file, offset) {
|
|
773
|
+
const seen = new Set()
|
|
774
|
+
const components = []
|
|
775
|
+
let regs = offset < 0 ? [] : this.componentsAt(file, offset)
|
|
776
|
+
let via = null
|
|
777
|
+
if (offset < 0) {
|
|
778
|
+
// Text that no spec holds (the javascript functions, which the
|
|
779
|
+
// processor renders at runtime): a package that registers exactly
|
|
780
|
+
// one component documents only that one. Spec text never falls back:
|
|
781
|
+
// a spec nothing registers (timeplus's output) belongs to no page.
|
|
782
|
+
const own = this.packageRegistrations(file.dir)
|
|
783
|
+
if (own.length === 1) {
|
|
784
|
+
regs = own
|
|
785
|
+
via = 'package'
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
for (const reg of regs) {
|
|
789
|
+
const key = `${reg.type}/${reg.name}`
|
|
790
|
+
if (seen.has(key)) continue
|
|
791
|
+
seen.add(key)
|
|
792
|
+
const info = reg.name ? this.info.get(key) : null
|
|
793
|
+
components.push({ type: reg.type, name: reg.name, ...(info || {}) })
|
|
794
|
+
}
|
|
795
|
+
components.sort((a, b) => String(a.type).localeCompare(String(b.type)) || String(a.name).localeCompare(String(b.name)))
|
|
796
|
+
const out = { components: components.slice(0, MAX_COMPONENTS), components_total: components.length }
|
|
797
|
+
if (via) out.components_via = via
|
|
798
|
+
return out
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Scan one file. Returns { declarations, skipped }. */
|
|
802
|
+
scan (file, rel) {
|
|
803
|
+
const declarations = []
|
|
804
|
+
const skipped = []
|
|
805
|
+
const { service, bloblang, cli } = this.aliases(file)
|
|
806
|
+
if (service.size > 0 || bloblang.size > 0) this.scanSpecs(file, rel, declarations, skipped)
|
|
807
|
+
if (cli.size > 0 && this.cli) this.scanCli(file, rel, declarations)
|
|
808
|
+
return { declarations, skipped }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
makeDecl (file, rel, { kind, name, result, start, end, extra = {}, convention = CONVENTION }) {
|
|
812
|
+
const unresolved = result ? unresolvedParts(result) : []
|
|
813
|
+
const text = result ? renderResult(result).trim() : null
|
|
814
|
+
const meta = { kind, ...extra }
|
|
815
|
+
if (unresolved.length > 0) {
|
|
816
|
+
meta.unverifiable = true
|
|
817
|
+
meta.unresolved = unresolved
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
surface: 'connect',
|
|
821
|
+
name: name || null,
|
|
822
|
+
file: rel,
|
|
823
|
+
line_start: file.lineOf(start),
|
|
824
|
+
line_end: file.lineOf(end),
|
|
825
|
+
string: text === '' ? null : text,
|
|
826
|
+
declaration_text: null,
|
|
827
|
+
convention,
|
|
828
|
+
meta
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Push a declaration, expanded per call site when it lives in a helper
|
|
834
|
+
* whose parameters fill its gaps: `regionField(product string)` builds
|
|
835
|
+
* "The AWS region to target for " + product + "." and is called once per
|
|
836
|
+
* component, so each call publishes a different string. Each expansion
|
|
837
|
+
* keeps the helper's lines (that is where a fix goes), and records the
|
|
838
|
+
* call site, the template with its gaps, and the calling component.
|
|
839
|
+
*
|
|
840
|
+
* @param {Object} d - makeDecl options plus valueExpr / nameExpr
|
|
841
|
+
* ({ text, offset }) to re-evaluate with the bound parameters
|
|
842
|
+
*/
|
|
843
|
+
pushDecl (declarations, file, rel, d) {
|
|
844
|
+
const fallback = () => declarations.push(this.makeDecl(file, rel, d))
|
|
845
|
+
if (!d.valueExpr) return fallback()
|
|
846
|
+
const bindings = this.callSiteBindings(file, d.valueExpr.offset, [d.result, d.nameResult])
|
|
847
|
+
if (!bindings) return fallback()
|
|
848
|
+
const seen = new Set()
|
|
849
|
+
const template = d.result ? renderResult(d.result).trim() : null
|
|
850
|
+
let pushed = 0
|
|
851
|
+
for (const { locals, site } of bindings) {
|
|
852
|
+
const result = this.evalWith(file, d.valueExpr, locals)
|
|
853
|
+
const nameResult = d.nameExpr ? this.evalWith(file, d.nameExpr, locals) : null
|
|
854
|
+
const name = nameResult && isResolved(nameResult) ? renderResult(nameResult) : d.name
|
|
855
|
+
const key = `${name}\0${renderResult(result)}`
|
|
856
|
+
if (seen.has(key)) continue
|
|
857
|
+
seen.add(key)
|
|
858
|
+
const meta = d.components === false ? {} : this.componentMeta(site.file, site.offset)
|
|
859
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
860
|
+
...d,
|
|
861
|
+
name,
|
|
862
|
+
result,
|
|
863
|
+
extra: { ...d.extra, ...meta, call_site: `${this.index.rel(site.file.abs)}:${site.file.lineOf(site.offset)}`, template }
|
|
864
|
+
}))
|
|
865
|
+
pushed++
|
|
866
|
+
}
|
|
867
|
+
if (pushed === 0) fallback()
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Evaluate { text, offset } in file with parameters bound to `locals`. */
|
|
871
|
+
evalWith (file, expr, locals) {
|
|
872
|
+
const fn = enclosingFunc(file, expr.offset)
|
|
873
|
+
return this.index.evalString(expr.text, {
|
|
874
|
+
file,
|
|
875
|
+
depth: 0,
|
|
876
|
+
locals,
|
|
877
|
+
resolveLocal: fn ? this.index.localResolver(file, fn, expr.offset) : undefined
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* When every gap in `results` is a parameter of the function enclosing
|
|
883
|
+
* `offset`, the parameter bindings at each call of that function.
|
|
884
|
+
* Returns [{ locals, site: { file, offset } }] or null.
|
|
885
|
+
*/
|
|
886
|
+
callSiteBindings (file, offset, results) {
|
|
887
|
+
const fn = enclosingFunc(file, offset)
|
|
888
|
+
if (!fn || fn.recv) return null
|
|
889
|
+
const params = new Set(fn.params.map((p) => p.name).filter(Boolean))
|
|
890
|
+
const gaps = results.filter(Boolean).flatMap((r) => unresolvedParts(r))
|
|
891
|
+
if (gaps.length === 0 || !gaps.every((g) => params.has(g.trim().replace(/\.\.\.$/, '')))) return null
|
|
892
|
+
const sites = this.callSites(file, fn)
|
|
893
|
+
if (sites.length === 0) return null
|
|
894
|
+
return sites.map((site) => {
|
|
895
|
+
const locals = new Map()
|
|
896
|
+
fn.params.forEach((param, i) => {
|
|
897
|
+
if (!param.name) return
|
|
898
|
+
if (/^\.\.\./.test(param.type)) {
|
|
899
|
+
// A variadic parameter is the list of the remaining arguments,
|
|
900
|
+
// or the list a trailing `xs...` spreads.
|
|
901
|
+
const rest = site.args.slice(i).filter((a) => a !== '')
|
|
902
|
+
const spread = rest.length === 1 && rest[0].endsWith('...')
|
|
903
|
+
? this.index.evalStringList(rest[0].slice(0, -3), { file: site.file, depth: 1 })
|
|
904
|
+
: rest.map((a) => this.evalAt(site.file, a, site.offset))
|
|
905
|
+
locals.set(param.name, { parts: [{ unresolved: param.name }], sawString: false, list: spread || [{ parts: [{ unresolved: rest.join(', ') }], sawString: false }] })
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
const arg = i < site.args.length ? site.args[i] : null
|
|
909
|
+
const r = arg === null ? null : this.evalAt(site.file, arg, site.offset)
|
|
910
|
+
locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
|
|
119
911
|
})
|
|
912
|
+
return { locals, site }
|
|
913
|
+
})
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Calls of top-level function fn: in its package, and through importers. */
|
|
917
|
+
callSites (file, fn) {
|
|
918
|
+
const key = `${file.abs}:${fn.start}`
|
|
919
|
+
this.siteCache = this.siteCache || new Map()
|
|
920
|
+
if (this.siteCache.has(key)) return this.siteCache.get(key)
|
|
921
|
+
const sites = []
|
|
922
|
+
const scan = (other, prefix) => {
|
|
923
|
+
const re = new RegExp(`(^|[^\\w.])${prefix}${escapeRe(fn.name)}\\s*\\(`, 'g')
|
|
924
|
+
const text = stripStrings(other.masked)
|
|
925
|
+
let m
|
|
926
|
+
while ((m = re.exec(text)) !== null) {
|
|
927
|
+
if (/\bfunc\s*$/.test(text.slice(Math.max(0, m.index - 10), m.index + m[1].length))) continue
|
|
928
|
+
const open = m.index + m[0].length - 1
|
|
929
|
+
const close = other.pairs.get(open)
|
|
930
|
+
if (close === undefined) continue
|
|
931
|
+
sites.push({ file: other, offset: open, args: splitTopLevelArgs(other.masked.slice(open + 1, close)).map((a) => a.trim()) })
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
for (const other of this.index.package(file.dir).files) scan(other, '')
|
|
935
|
+
const mod = this.index.gomod.module
|
|
936
|
+
if (mod && /^[A-Z]/.test(fn.name)) {
|
|
937
|
+
const importPath = `${mod}/${this.index.rel(file.dir).split(path.sep).join('/')}`
|
|
938
|
+
for (const dir of this.importersOf(importPath)) {
|
|
939
|
+
for (const other of this.index.package(dir).files) {
|
|
940
|
+
for (const [k, value] of other.imports) {
|
|
941
|
+
if (value !== importPath || k === null || k === '_' || k === '.') continue
|
|
942
|
+
const alias = k.startsWith('\0') ? this.index.package(file.dir).name || importName(value) : k
|
|
943
|
+
scan(other, `${escapeRe(alias)}\\s*\\.\\s*`)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
120
947
|
}
|
|
948
|
+
this.siteCache.set(key, sites)
|
|
949
|
+
return sites
|
|
121
950
|
}
|
|
122
951
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
952
|
+
scanSpecs (file, rel, declarations, skipped) {
|
|
953
|
+
const masked = file.masked
|
|
954
|
+
const describedVars = new Set()
|
|
955
|
+
const nameOf = (r) => (r && isResolved(r) ? renderResult(r) : null)
|
|
956
|
+
DOC_METHODS.lastIndex = 0
|
|
957
|
+
let m
|
|
958
|
+
while ((m = DOC_METHODS.exec(masked)) !== null) {
|
|
959
|
+
const method = m[1]
|
|
960
|
+
const dot = m.index
|
|
961
|
+
const open = m.index + m[0].length - 1
|
|
962
|
+
const close = file.pairs.get(open)
|
|
963
|
+
if (close === undefined) continue
|
|
964
|
+
// A method declaration such as `func (c *X) Description(` is not a call.
|
|
965
|
+
if (/func\s*\([^()]*\)\s*$/.test(masked.slice(Math.max(0, dot - 200), dot))) continue
|
|
966
|
+
const head = chainHead(masked, file.pairs, dot)
|
|
967
|
+
const cls = this.classifyHead(file, head, dot)
|
|
968
|
+
// javascript function docs are read by scanJsFunctions; their
|
|
969
|
+
// Example() is script code, not prose.
|
|
970
|
+
if (cls.kind === 'js-function') continue
|
|
971
|
+
if (!cls.kind || cls.kind === 'field-list') {
|
|
972
|
+
const receiver = head.type === 'ident'
|
|
973
|
+
? head.name
|
|
974
|
+
: head.type === 'call' ? `${head.qual ? `${head.qual}.` : ''}${head.name}()` : null
|
|
975
|
+
skipped.push({
|
|
976
|
+
surface: 'connect',
|
|
977
|
+
file: rel,
|
|
978
|
+
line: file.lineOf(dot),
|
|
979
|
+
method,
|
|
980
|
+
reason: receiver
|
|
981
|
+
? `receiver ${receiver} does not trace to a benthos spec, field or Bloblang plugin`
|
|
982
|
+
: 'receiver is not a traceable expression'
|
|
983
|
+
})
|
|
984
|
+
continue
|
|
985
|
+
}
|
|
986
|
+
if (head.type === 'ident' && method === 'Description') describedVars.add(`${enclosingFuncKey(file, dot)}\0${head.name}`)
|
|
987
|
+
const args = splitTopLevelArgs(masked.slice(open + 1, close))
|
|
988
|
+
const argStart = (i) => open + 1 + args.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
|
|
989
|
+
let evalArg = (i) => this.evalAt(file, args[i], argStart(i))
|
|
990
|
+
let tupled = false
|
|
991
|
+
// `spec.Example(exampleConfig())`: one call returning every argument.
|
|
992
|
+
if (args.length === 1 && /^(?:Example|ExampleNotTested)$/.test(method) && (cls.kind === 'spec' || cls.kind === 'blobl-spec')) {
|
|
993
|
+
const fn = enclosingFunc(file, open)
|
|
994
|
+
const tuple = this.index.evalTuple(args[0], {
|
|
995
|
+
file,
|
|
996
|
+
depth: 0,
|
|
997
|
+
resolveLocal: fn ? this.index.localResolver(file, fn, open) : undefined
|
|
998
|
+
})
|
|
999
|
+
if (tuple && tuple.length > 1) {
|
|
1000
|
+
args.length = 0
|
|
1001
|
+
args.push(...tuple.map(() => ''))
|
|
1002
|
+
evalArg = (i) => tuple[i]
|
|
1003
|
+
tupled = true
|
|
1004
|
+
} else {
|
|
1005
|
+
skipped.push({ surface: 'connect', file: rel, line: file.lineOf(dot), method, reason: `example arguments come from ${args[0].trim().slice(0, 60)}, which does not evaluate to a title, a summary and a config` })
|
|
1006
|
+
continue
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (cls.kind === 'spec' || cls.kind === 'blobl-spec') {
|
|
1011
|
+
const meta = this.componentMeta(file, dot)
|
|
1012
|
+
const names = [...new Set(meta.components.map((c) => c.name).filter(Boolean))]
|
|
1013
|
+
const name = names.length === 1 ? names[0] : null
|
|
1014
|
+
const bloblang = cls.kind === 'blobl-spec'
|
|
1015
|
+
const exprOf = (i) => (tupled ? null : { text: args[i], offset: argStart(i) })
|
|
1016
|
+
const push = (kind, result, extra = {}, i = 0) => this.pushDecl(declarations, file, rel, {
|
|
1017
|
+
kind, name, result, start: open, end: close, extra: { ...meta, ...extra }, valueExpr: exprOf(i)
|
|
1018
|
+
})
|
|
1019
|
+
if (method === 'Summary' || method === 'Description' || method === 'Footnotes') {
|
|
1020
|
+
if (args.length < 1) continue
|
|
1021
|
+
if (bloblang) push('bloblang', evalArg(0), { part: method.toLowerCase() })
|
|
1022
|
+
else push(method.toLowerCase(), evalArg(0))
|
|
1023
|
+
} else if (!bloblang && method === 'Example') {
|
|
1024
|
+
if (args.length < 3) continue
|
|
1025
|
+
push('example-title', evalArg(0), {}, 0)
|
|
1026
|
+
const summary = evalArg(1)
|
|
1027
|
+
if (renderResult(summary).trim() !== '') push('example-summary', summary, {}, 1)
|
|
1028
|
+
} else if (bloblang && (method === 'Example' || method === 'ExampleNotTested')) {
|
|
1029
|
+
if (args.length < 2) continue
|
|
1030
|
+
const summary = evalArg(0)
|
|
1031
|
+
if (renderResult(summary).trim() !== '') push('bloblang-example', summary)
|
|
1032
|
+
}
|
|
1033
|
+
continue
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Fields and Bloblang params: Example() takes a value, not prose.
|
|
1037
|
+
if (method !== 'Description' && method !== 'ShortDescription') continue
|
|
1038
|
+
if (args.length < 1) continue
|
|
1039
|
+
const kind = cls.kind === 'blobl-param'
|
|
1040
|
+
? 'bloblang-param'
|
|
1041
|
+
: method === 'ShortDescription' ? 'field-short' : 'field'
|
|
1042
|
+
this.pushDecl(declarations, file, rel, {
|
|
1043
|
+
kind,
|
|
1044
|
+
name: nameOf(cls.name),
|
|
1045
|
+
nameResult: cls.name || null,
|
|
1046
|
+
nameExpr: cls.nameExpr || null,
|
|
1047
|
+
result: evalArg(0),
|
|
1048
|
+
valueExpr: { text: args[0], offset: argStart(0) },
|
|
1049
|
+
start: head.type === 'call' ? head.start : open,
|
|
1050
|
+
end: close,
|
|
1051
|
+
extra: { ctor: cls.ctor || null, ...(cls.via ? { via: cls.via } : {}), ...this.componentMeta(file, dot) }
|
|
1052
|
+
})
|
|
1053
|
+
}
|
|
1054
|
+
this.scanFieldCtors(file, rel, declarations, describedVars)
|
|
1055
|
+
this.scanJsFunctions(file, rel, declarations)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* javascript processor functions: `registerVMRunnerFunction(name,
|
|
1060
|
+
* description).Param(name, type, what)`. The processor renders each one
|
|
1061
|
+
* into its footnotes, so the description and every parameter's text are
|
|
1062
|
+
* published prose.
|
|
1063
|
+
*/
|
|
1064
|
+
scanJsFunctions (file, rel, declarations) {
|
|
1065
|
+
const pkg = this.index.package(file.dir)
|
|
1066
|
+
const builders = [...pkg.funcs.values()].filter((fn) => kindOfType(fn.results) === 'js-function')
|
|
1067
|
+
if (builders.length === 0) return
|
|
1068
|
+
const masked = file.masked
|
|
1069
|
+
const meta = this.componentMeta(file, -1)
|
|
1070
|
+
for (const fn of builders) {
|
|
1071
|
+
const descIndex = fn.params.findIndex((p) => p.name === 'description')
|
|
1072
|
+
const nameIndex = fn.params.findIndex((p) => p.name === 'name')
|
|
1073
|
+
if (descIndex === -1) continue
|
|
1074
|
+
const re = new RegExp(`(^|[^\\w.])${escapeRe(fn.name)}\\s*\\(`, 'g')
|
|
1075
|
+
const text = stripStrings(masked)
|
|
1076
|
+
let m
|
|
1077
|
+
while ((m = re.exec(text)) !== null) {
|
|
1078
|
+
if (/\bfunc\s*$/.test(text.slice(Math.max(0, m.index - 10), m.index + m[1].length))) continue
|
|
1079
|
+
const open = m.index + m[0].length - 1
|
|
1080
|
+
const close = file.pairs.get(open)
|
|
1081
|
+
if (close === undefined) continue
|
|
1082
|
+
const args = splitTopLevelArgs(masked.slice(open + 1, close))
|
|
1083
|
+
const argStart = (i) => open + 1 + args.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
|
|
1084
|
+
const nameResult = nameIndex !== -1 && args[nameIndex] ? this.evalAt(file, args[nameIndex], argStart(nameIndex)) : null
|
|
1085
|
+
const fnName = nameResult && isResolved(nameResult) ? renderResult(nameResult) : null
|
|
1086
|
+
if (args[descIndex] === undefined) continue
|
|
1087
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
1088
|
+
kind: 'js-function',
|
|
1089
|
+
name: fnName,
|
|
1090
|
+
result: this.evalAt(file, args[descIndex], argStart(descIndex)),
|
|
1091
|
+
start: m.index + m[1].length,
|
|
1092
|
+
end: close,
|
|
1093
|
+
extra: { ...meta }
|
|
1094
|
+
}))
|
|
1095
|
+
for (const call of readChain(masked, file.pairs, close + 1)) {
|
|
1096
|
+
if (call.method !== 'Param') continue
|
|
1097
|
+
const pargs = splitTopLevelArgs(masked.slice(call.open + 1, call.close))
|
|
1098
|
+
if (pargs.length < 3) continue
|
|
1099
|
+
const pStart = (i) => call.open + 1 + pargs.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
|
|
1100
|
+
const pname = this.evalAt(file, pargs[0], pStart(0))
|
|
1101
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
1102
|
+
kind: 'js-function-param',
|
|
1103
|
+
name: fnName && isResolved(pname) ? `${fnName}.${renderResult(pname)}` : null,
|
|
1104
|
+
result: this.evalAt(file, pargs[2], pStart(2)),
|
|
1105
|
+
start: call.open,
|
|
1106
|
+
end: call.close,
|
|
1107
|
+
extra: { ...meta }
|
|
1108
|
+
}))
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Field constructors: annotated enum options, and bare constructors that
|
|
1116
|
+
* never get a Description.
|
|
1117
|
+
*/
|
|
1118
|
+
scanFieldCtors (file, rel, declarations, describedVars) {
|
|
1119
|
+
const masked = file.masked
|
|
1120
|
+
const { service } = this.aliases(file)
|
|
1121
|
+
if (service.size === 0) return
|
|
1122
|
+
const pattern = new RegExp(`\\b(?:${[...service].map(escapeRe).join('|')})\\s*\\.\\s*(New\\w*Field)\\s*\\(`, 'g')
|
|
1123
|
+
let m
|
|
1124
|
+
while ((m = pattern.exec(masked)) !== null) {
|
|
1125
|
+
const ctor = m[1]
|
|
1126
|
+
const open = m.index + m[0].length - 1
|
|
1127
|
+
const close = file.pairs.get(open)
|
|
1128
|
+
if (close === undefined) continue
|
|
1129
|
+
const args = splitTopLevelArgs(masked.slice(open + 1, close))
|
|
1130
|
+
const nameResult = args.length > 0 && args[0].trim() !== '' ? this.evalAt(file, args[0], open) : null
|
|
1131
|
+
const fieldName = nameResult && isResolved(nameResult) ? renderResult(nameResult) : null
|
|
1132
|
+
const calls = readChain(masked, file.pairs, close + 1)
|
|
1133
|
+
const end = calls.length > 0 ? calls[calls.length - 1].close : close
|
|
1134
|
+
|
|
1135
|
+
if (ctor === 'NewStringAnnotatedEnumField' && args.length >= 2) {
|
|
1136
|
+
this.scanEnumOptions(file, rel, args[1], open + 1 + args[0].length + 1, fieldName, declarations)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (!BARE_FIELD_CTORS.has(ctor)) continue
|
|
1140
|
+
// Deprecated fields are left out of the published reference.
|
|
1141
|
+
if (calls.some((c) => c.method === 'Description' || c.method === 'Deprecated')) continue
|
|
1142
|
+
// `f := service.NewStringField(...)` followed by
|
|
1143
|
+
// `f = f.Description(...)` documents the field by reassignment.
|
|
1144
|
+
const assigned = masked.slice(Math.max(0, m.index - 80), m.index).match(/(?:^|[^\w.])([A-Za-z_]\w*)\s*:?=\s*$/)
|
|
1145
|
+
if (assigned && describedVars.has(`${enclosingFuncKey(file, m.index)}\0${assigned[1]}`)) continue
|
|
1146
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
1147
|
+
kind: 'field',
|
|
1148
|
+
name: fieldName,
|
|
1149
|
+
result: null,
|
|
1150
|
+
start: m.index,
|
|
1151
|
+
end,
|
|
1152
|
+
extra: { ctor, missing_description: true, ...this.componentMeta(file, m.index) }
|
|
1153
|
+
}))
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
130
1156
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1157
|
+
/** `map[string]string{"opt": "desc", ...}`, inline or in a variable. */
|
|
1158
|
+
scanEnumOptions (file, rel, argText, argStart, fieldName, declarations) {
|
|
1159
|
+
let text = argText
|
|
1160
|
+
let start = argStart
|
|
1161
|
+
let srcFile = file
|
|
1162
|
+
const trimmed = argText.trim()
|
|
1163
|
+
if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(trimmed)) {
|
|
1164
|
+
const fn = enclosingFunc(file, argStart)
|
|
1165
|
+
const local = fn && !trimmed.includes('.') ? this.lastAssignment(file, fn, trimmed, argStart) : null
|
|
1166
|
+
if (local && local.expr) {
|
|
1167
|
+
text = local.expr
|
|
1168
|
+
start = local.exprStart
|
|
1169
|
+
} else {
|
|
1170
|
+
const value = this.index.lookupValue(file, trimmed)
|
|
1171
|
+
if (!value || !value.expr) return
|
|
1172
|
+
text = value.expr
|
|
1173
|
+
start = value.exprStart
|
|
1174
|
+
srcFile = value.file
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const lit = text.match(/^\s*map\s*\[\s*string\s*\]\s*string\s*\{/)
|
|
1178
|
+
if (!lit) return
|
|
1179
|
+
const local = /^[A-Za-z_]\w*$/.test(trimmed) && srcFile === file ? enclosingFunc(file, argStart) : null
|
|
1180
|
+
if (local) this.scanEnumIndexAssignments(file, rel, local, trimmed, argStart, fieldName, declarations)
|
|
1181
|
+
const braceOpen = start + lit[0].length - 1
|
|
1182
|
+
const braceClose = srcFile.pairs.get(braceOpen)
|
|
1183
|
+
if (braceClose === undefined) return
|
|
1184
|
+
const srcRel = srcFile === file ? rel : this.index.rel(srcFile.abs)
|
|
1185
|
+
let offset = braceOpen + 1
|
|
1186
|
+
for (const entry of splitTopLevelArgs(srcFile.masked.slice(braceOpen + 1, braceClose))) {
|
|
1187
|
+
const entryStart = offset
|
|
1188
|
+
offset += entry.length + 1
|
|
1189
|
+
if (entry.trim() === '') continue
|
|
1190
|
+
const colon = topLevelColon(entry)
|
|
1191
|
+
if (colon === -1) continue
|
|
1192
|
+
const key = this.evalAt(srcFile, entry.slice(0, colon), entryStart)
|
|
1193
|
+
const value = this.evalAt(srcFile, entry.slice(colon + 1), entryStart + colon + 1)
|
|
1194
|
+
const opt = isResolved(key) ? renderResult(key) : null
|
|
1195
|
+
const lead = entry.length - entry.trimStart().length
|
|
1196
|
+
declarations.push(this.makeDecl(srcFile, srcRel, {
|
|
1197
|
+
kind: 'enum-option',
|
|
1198
|
+
name: fieldName && opt ? `${fieldName}=${opt}` : opt,
|
|
1199
|
+
result: value,
|
|
1200
|
+
start: entryStart + lead,
|
|
1201
|
+
end: entryStart + entry.trimEnd().length - 1,
|
|
1202
|
+
extra: { field: fieldName, option: opt, ...this.componentMeta(file, argStart) }
|
|
1203
|
+
}))
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
134
1206
|
|
|
135
|
-
|
|
136
|
-
|
|
1207
|
+
/** `opts["key"] = "desc"` assignments that fill an enum option map. */
|
|
1208
|
+
scanEnumIndexAssignments (file, rel, fn, ident, before, fieldName, declarations) {
|
|
1209
|
+
const masked = file.masked
|
|
1210
|
+
const re = new RegExp(`(^|[^\\w.])${escapeRe(ident)}\\s*\\[`, 'g')
|
|
1211
|
+
const text = masked.slice(fn.bodyStart, before)
|
|
1212
|
+
let m
|
|
1213
|
+
while ((m = re.exec(text)) !== null) {
|
|
1214
|
+
const bracket = fn.bodyStart + m.index + m[0].length - 1
|
|
1215
|
+
const close = file.pairs.get(bracket)
|
|
1216
|
+
if (close === undefined) continue
|
|
1217
|
+
const assign = masked.slice(close + 1, close + 40).match(/^\s*=(?!=)\s*/)
|
|
1218
|
+
if (!assign) continue
|
|
1219
|
+
const exprStart = close + 1 + assign[0].length
|
|
1220
|
+
const exprEnd = statementEnd(masked, exprStart, fn.bodyEnd)
|
|
1221
|
+
const key = this.evalAt(file, masked.slice(bracket + 1, close), bracket)
|
|
1222
|
+
const opt = isResolved(key) ? renderResult(key) : null
|
|
1223
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
1224
|
+
kind: 'enum-option',
|
|
1225
|
+
name: fieldName && opt ? `${fieldName}=${opt}` : opt,
|
|
1226
|
+
result: this.evalAt(file, masked.slice(exprStart, exprEnd), exprStart),
|
|
1227
|
+
start: bracket,
|
|
1228
|
+
end: exprEnd,
|
|
1229
|
+
extra: { field: fieldName, option: opt, ...this.componentMeta(file, before) }
|
|
1230
|
+
}))
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
137
1233
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
1234
|
+
/** urfave/cli Command and Flag composite literals. */
|
|
1235
|
+
scanCli (file, rel, declarations) {
|
|
1236
|
+
const masked = file.masked
|
|
1237
|
+
const { cli } = this.aliases(file)
|
|
1238
|
+
const pattern = new RegExp(`\\b(?:${[...cli].map(escapeRe).join('|')})\\s*\\.\\s*(Command|\\w+Flag)\\s*\\{`, 'g')
|
|
1239
|
+
let m
|
|
1240
|
+
while ((m = pattern.exec(masked)) !== null) {
|
|
1241
|
+
const literal = m[1]
|
|
1242
|
+
const braceOpen = m.index + m[0].length - 1
|
|
1243
|
+
const braceClose = file.pairs.get(braceOpen)
|
|
1244
|
+
if (braceClose === undefined) continue
|
|
1245
|
+
const body = masked.slice(braceOpen + 1, braceClose)
|
|
1246
|
+
const fields = parseLiteralFields(body)
|
|
1247
|
+
const valueOf = (key) => {
|
|
1248
|
+
const f = fields.get(key)
|
|
1249
|
+
if (!f) return null
|
|
1250
|
+
const at = braceOpen + 1 + f.valueStart
|
|
1251
|
+
return {
|
|
1252
|
+
result: this.evalAt(file, body.slice(f.valueStart, f.end), at),
|
|
1253
|
+
start: braceOpen + 1 + f.start,
|
|
1254
|
+
end: braceOpen + f.end
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const nameField = valueOf('Name')
|
|
1258
|
+
const name = nameField && isResolved(nameField.result) ? renderResult(nameField.result) : null
|
|
1259
|
+
const keys = literal === 'Command'
|
|
1260
|
+
? [['Usage', 'cli-usage'], ['UsageText', 'cli-usage-text'], ['ArgsUsage', 'cli-args-usage'], ['Description', 'cli-description']]
|
|
1261
|
+
: [['Usage', 'cli-flag']]
|
|
1262
|
+
for (const [key, kind] of keys) {
|
|
1263
|
+
const v = valueOf(key)
|
|
1264
|
+
if (!v) continue
|
|
1265
|
+
declarations.push(this.makeDecl(file, rel, {
|
|
1266
|
+
kind,
|
|
1267
|
+
name,
|
|
1268
|
+
result: v.result,
|
|
1269
|
+
start: v.start,
|
|
1270
|
+
end: v.end,
|
|
1271
|
+
convention: CLI_CONVENTION,
|
|
1272
|
+
extra: {
|
|
1273
|
+
literal: literal === 'Command' ? 'command' : literal,
|
|
1274
|
+
// rpk-docs formats CLI help like cobra help; it is not AsciiDoc.
|
|
1275
|
+
skip_rules: ['raw-pipe', 'unknown-attribute', 'broken-macro', 'too-short']
|
|
1276
|
+
}
|
|
1277
|
+
}))
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Repo-relative .go and template files to scan in a whole-repo run. */
|
|
1284
|
+
function repoFiles (repo) {
|
|
1285
|
+
const out = []
|
|
1286
|
+
for (const root of SCAN_ROOTS) {
|
|
1287
|
+
for (const f of collectGoFiles(path.join(repo, root))) out.push(path.join(root, f))
|
|
1288
|
+
for (const f of collectTemplates(path.join(repo, root))) out.push(path.join(root, f))
|
|
1289
|
+
}
|
|
1290
|
+
return out
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function collectTemplates (root, base = root, out = []) {
|
|
1294
|
+
let entries = []
|
|
1295
|
+
try {
|
|
1296
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
1297
|
+
} catch {
|
|
1298
|
+
return out
|
|
1299
|
+
}
|
|
1300
|
+
for (const entry of entries) {
|
|
1301
|
+
if (entry.name.startsWith('.')) continue
|
|
1302
|
+
const full = path.join(root, entry.name)
|
|
1303
|
+
if (entry.isDirectory()) {
|
|
1304
|
+
if (entry.name === 'testdata' || entry.name === 'node_modules') continue
|
|
1305
|
+
collectTemplates(full, base, out)
|
|
1306
|
+
} else if (TEMPLATE_FILE.test(entry.name)) {
|
|
1307
|
+
out.push(path.relative(base, full))
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return out
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const TEMPLATE_FILE = /\.tmpl\.ya?ml$/
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* A config template (`*.tmpl.yaml`, registered with RegisterTemplateYAML):
|
|
1317
|
+
* its summary, description and field descriptions are published like a
|
|
1318
|
+
* component spec's.
|
|
1319
|
+
*/
|
|
1320
|
+
function scanTemplate (content, rel, scanner) {
|
|
1321
|
+
const YAML = require('yaml')
|
|
1322
|
+
const declarations = []
|
|
1323
|
+
let doc
|
|
1324
|
+
try {
|
|
1325
|
+
doc = YAML.parseDocument(content)
|
|
1326
|
+
} catch {
|
|
1327
|
+
return declarations
|
|
1328
|
+
}
|
|
1329
|
+
if (!doc || !YAML.isMap(doc.contents)) return declarations
|
|
1330
|
+
const lineIndex = (offset) => content.slice(0, offset).split('\n').length
|
|
1331
|
+
const name = doc.get('name')
|
|
1332
|
+
const type = doc.get('type')
|
|
1333
|
+
if (typeof name !== 'string' || typeof type !== 'string') return declarations
|
|
1334
|
+
const info = scanner.info.get(`${type}/${name}`)
|
|
1335
|
+
const meta = { components: [{ type, name, ...(info || {}) }], components_total: 1, config_template: true }
|
|
1336
|
+
const push = (kind, declName, node, extra = {}) => {
|
|
1337
|
+
if (!node || !YAML.isScalar(node) || typeof node.value !== 'string') return
|
|
1338
|
+
const [start, , end] = node.range
|
|
1339
|
+
const text = node.value.trim()
|
|
1340
|
+
declarations.push({
|
|
1341
|
+
surface: 'connect',
|
|
1342
|
+
name: declName,
|
|
1343
|
+
file: rel,
|
|
1344
|
+
line_start: lineIndex(start),
|
|
1345
|
+
line_end: lineIndex(Math.max(start, end - 1)),
|
|
1346
|
+
string: text === '' ? null : text,
|
|
1347
|
+
declaration_text: null,
|
|
1348
|
+
convention: CONVENTION,
|
|
1349
|
+
meta: { kind, ...meta, ...extra }
|
|
1350
|
+
})
|
|
1351
|
+
}
|
|
1352
|
+
push('summary', name, doc.contents.get('summary', true))
|
|
1353
|
+
push('description', name, doc.contents.get('description', true))
|
|
1354
|
+
const fields = doc.contents.get('fields', true)
|
|
1355
|
+
if (YAML.isSeq(fields)) {
|
|
1356
|
+
for (const field of fields.items) {
|
|
1357
|
+
if (!YAML.isMap(field)) continue
|
|
1358
|
+
const fieldName = field.get('name')
|
|
1359
|
+
const description = field.get('description', true)
|
|
1360
|
+
if (description) push('field', typeof fieldName === 'string' ? fieldName : null, description, { ctor: 'template' })
|
|
1361
|
+
else if (typeof fieldName === 'string') {
|
|
1362
|
+
const node = field.get('name', true)
|
|
141
1363
|
declarations.push({
|
|
142
1364
|
surface: 'connect',
|
|
143
1365
|
name: fieldName,
|
|
144
|
-
file,
|
|
145
|
-
line_start:
|
|
146
|
-
line_end:
|
|
147
|
-
string:
|
|
1366
|
+
file: rel,
|
|
1367
|
+
line_start: lineIndex(node.range[0]),
|
|
1368
|
+
line_end: lineIndex(node.range[0]),
|
|
1369
|
+
string: null,
|
|
148
1370
|
declaration_text: null,
|
|
149
1371
|
convention: CONVENTION,
|
|
150
|
-
meta: { kind: 'field', ctor }
|
|
1372
|
+
meta: { kind: 'field', ctor: 'template', missing_description: true, ...meta }
|
|
151
1373
|
})
|
|
152
1374
|
}
|
|
153
|
-
// Unverifiable description: skip silently (cross-file constant or call).
|
|
154
|
-
} else if (BARE_FIELD_CTORS.has(ctor) && !calls.some((c) => c.method === 'Deprecated')) {
|
|
155
|
-
// Deprecated fields are not part of the user-facing docs contract.
|
|
156
|
-
// A bare constructor with no Description() anywhere in its chain
|
|
157
|
-
// ships an empty field description.
|
|
158
|
-
declarations.push({
|
|
159
|
-
surface: 'connect',
|
|
160
|
-
name: fieldName,
|
|
161
|
-
file,
|
|
162
|
-
line_start: lineOf(masked, match.index),
|
|
163
|
-
line_end: lineOf(masked, Math.max(closeIndex, end - 1)),
|
|
164
|
-
string: null,
|
|
165
|
-
declaration_text: null,
|
|
166
|
-
convention: CONVENTION,
|
|
167
|
-
meta: { kind: 'field', ctor, missing_description: true }
|
|
168
|
-
})
|
|
169
1375
|
}
|
|
170
|
-
// Resume INSIDE the constructor args: object fields nest child field
|
|
171
|
-
// constructors (NewObjectField("x", NewStringField("y")...)).
|
|
172
|
-
FIELD_CTOR.lastIndex = openIndex + 1
|
|
173
1376
|
}
|
|
174
|
-
|
|
175
1377
|
return declarations
|
|
176
1378
|
}
|
|
177
1379
|
|
|
1380
|
+
function spanText (content, start, end) {
|
|
1381
|
+
return content.split('\n').slice(start - 1, end).join('\n')
|
|
1382
|
+
}
|
|
1383
|
+
|
|
178
1384
|
/**
|
|
179
1385
|
* Extract connect declarations.
|
|
180
1386
|
*
|
|
181
|
-
* @param {Object} options
|
|
182
|
-
*
|
|
1387
|
+
* @param {Object} options
|
|
1388
|
+
* @param {string} options.repo - The connect (or benthos) checkout
|
|
1389
|
+
* @param {Set<string>} [options.files] - Repo-relative paths (diff mode);
|
|
1390
|
+
* when omitted, scans internal/, public/ and cmd/
|
|
1391
|
+
* @param {boolean} [options.external=true] - Resolve benthos and other
|
|
1392
|
+
* dependencies through the Go module cache when it holds them
|
|
1393
|
+
* @param {Map<string,string>} [options.overlay] - In-memory file contents
|
|
1394
|
+
* @returns {Array} declarations. The array also carries a non-enumerable
|
|
1395
|
+
* `skipped` list: every doc-method call whose receiver could not be traced
|
|
1396
|
+
* to a benthos type, so a caller can report it instead of losing it.
|
|
183
1397
|
*/
|
|
184
|
-
function extract ({ repo, files = null }) {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
fileList = collectGoFiles(path.join(repo, IMPL_ROOT)).map((f) => path.join(IMPL_ROOT, f))
|
|
191
|
-
}
|
|
192
|
-
|
|
1398
|
+
function extract ({ repo, files = null, external = true, overlay = null }) {
|
|
1399
|
+
const index = new GoIndex(repo, { external, overlay })
|
|
1400
|
+
const scanner = new ConnectScanner(index, { repo })
|
|
1401
|
+
const fileList = files
|
|
1402
|
+
? [...files].filter((f) => (f.endsWith('.go') && !f.endsWith('_test.go')) || TEMPLATE_FILE.test(f))
|
|
1403
|
+
: repoFiles(repo)
|
|
193
1404
|
const cache = new SourceCache(repo)
|
|
194
1405
|
const declarations = []
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
1406
|
+
const skipped = []
|
|
1407
|
+
for (const rel of fileList) {
|
|
1408
|
+
if (TEMPLATE_FILE.test(rel)) {
|
|
1409
|
+
let content = overlay && overlay.has(rel) ? overlay.get(rel) : null
|
|
1410
|
+
if (content === null) {
|
|
1411
|
+
try {
|
|
1412
|
+
content = fs.readFileSync(path.join(index.repo, rel), 'utf8')
|
|
1413
|
+
} catch {
|
|
1414
|
+
continue
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
for (const decl of scanTemplate(content, rel, scanner)) {
|
|
1418
|
+
decl.declaration_text = spanText(content, decl.line_start, decl.line_end)
|
|
1419
|
+
declarations.push(decl)
|
|
1420
|
+
}
|
|
1421
|
+
continue
|
|
1422
|
+
}
|
|
1423
|
+
const file = index.file(path.isAbsolute(rel) ? rel : path.join(index.repo, rel))
|
|
1424
|
+
if (!file) continue
|
|
1425
|
+
const result = scanner.scan(file, rel)
|
|
1426
|
+
for (const decl of result.declarations) {
|
|
1427
|
+
decl.declaration_text = overlay && overlay.has(decl.file)
|
|
1428
|
+
? spanText(overlay.get(decl.file), decl.line_start, decl.line_end)
|
|
1429
|
+
: cache.span(decl.file, decl.line_start, decl.line_end)
|
|
202
1430
|
declarations.push(decl)
|
|
203
1431
|
}
|
|
1432
|
+
skipped.push(...result.skipped)
|
|
204
1433
|
}
|
|
1434
|
+
Object.defineProperty(declarations, 'skipped', { value: skipped, enumerable: false })
|
|
205
1435
|
return declarations
|
|
206
1436
|
}
|
|
207
1437
|
|
|
1438
|
+
/**
|
|
1439
|
+
* Scan one file's content with no checkout around it. Exported for tests:
|
|
1440
|
+
* only same-file constants and helpers resolve.
|
|
1441
|
+
*/
|
|
1442
|
+
function scanFile (content, file) {
|
|
1443
|
+
return extract({
|
|
1444
|
+
repo: path.join(path.sep, '__lint_strings_virtual__'),
|
|
1445
|
+
files: new Set([file]),
|
|
1446
|
+
external: false,
|
|
1447
|
+
overlay: new Map([[file, content]])
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// AsciiDoc and Markdown markup that a plain-text UI shows literally.
|
|
1452
|
+
const MARKUP_PATTERNS = [
|
|
1453
|
+
[/`/, 'backticks'],
|
|
1454
|
+
[/\b(?:xref|link|image|include|glossterm):[^\s[]*\[/, 'an AsciiDoc macro'],
|
|
1455
|
+
[/https?:\/\/\S+\[[^\]]*\]/, 'an AsciiDoc link'],
|
|
1456
|
+
[/<<[^>]+>>/, 'a cross-reference'],
|
|
1457
|
+
[/\*\*[^*]+\*\*/, 'bold markup'],
|
|
1458
|
+
[/^\s*(?:=+|\*+|-|\.)\s/m, 'a heading or list marker'],
|
|
1459
|
+
[/\{[a-z][a-z0-9_-]*\}/, 'an attribute reference'],
|
|
1460
|
+
[/\n/, 'a line break']
|
|
1461
|
+
]
|
|
1462
|
+
|
|
1463
|
+
const ENV_CONDITIONAL = /\b(?:ifdef|ifndef|ifeval|endif)::/
|
|
1464
|
+
|
|
1465
|
+
// Modules of the Redpanda Connect docs component. A generated partial is
|
|
1466
|
+
// rendered on the self-managed page and included into Cloud pages, so a
|
|
1467
|
+
// link needs an explicit module to resolve in both places.
|
|
1468
|
+
const XREF_MODULES = new Set(['components', 'configuration', 'cookbooks', 'get-started', 'guides', 'install', 'reference'])
|
|
1469
|
+
|
|
1470
|
+
const AVAILABILITY_PROSE = /\b(?:not (?:available|supported) (?:in|on) (?:Redpanda )?Cloud|only available (?:in|on|for) (?:self-managed|Redpanda Cloud|Cloud)|self-managed only|requires? (?:a )?cgo|CGO_ENABLED|x_benthos_extra)\b/i
|
|
1471
|
+
|
|
208
1472
|
/** Surface-specific convention rules. */
|
|
209
1473
|
const RULES = [
|
|
210
1474
|
{
|
|
@@ -215,6 +1479,69 @@ const RULES = [
|
|
|
215
1479
|
if (decl.meta.kind !== 'field' || !decl.meta.missing_description) return []
|
|
216
1480
|
return [{ message: `Field ${decl.name ? `"${decl.name}" ` : ''}(${decl.meta.ctor}) has no .Description(). It ships as an undocumented field on the connector page.` }]
|
|
217
1481
|
}
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
name: 'connect-short-description-markup',
|
|
1485
|
+
description: 'ShortDescription is plain text in form-based editors',
|
|
1486
|
+
severity: 'warning',
|
|
1487
|
+
check: (decl) => {
|
|
1488
|
+
if (decl.meta.kind !== 'field-short' || !decl.string) return []
|
|
1489
|
+
const found = MARKUP_PATTERNS.filter(([re]) => re.test(decl.string)).map(([, what]) => what)
|
|
1490
|
+
if (found.length === 0) return []
|
|
1491
|
+
return [{ message: `ShortDescription contains ${found.join(', ')}. Form-based config editors, such as the one in Redpanda Cloud, show it as plain text, so markup renders literally. Write one plain sentence and keep markup in Description().` }]
|
|
1492
|
+
}
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
name: 'connect-env-conditional',
|
|
1496
|
+
description: 'AsciiDoc ifdef/ifndef conditional in a Go spec string',
|
|
1497
|
+
severity: 'warning',
|
|
1498
|
+
check: (decl) => {
|
|
1499
|
+
if (!decl.string || !ENV_CONDITIONAL.test(decl.string)) return []
|
|
1500
|
+
return [{ message: 'Contains an AsciiDoc conditional (ifdef::, ifndef:: or endif::). The docs generator owns every Cloud conditional in the partials, and a hand-written one nests inside them, so the Cloud copy shows or hides the wrong text. Fix the availability data in internal/plugins/info.csv instead, or ask the docs team to carry the sentence on the page.' }]
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
name: 'connect-xref-module',
|
|
1505
|
+
description: 'xref without a module, or to a module the Connect docs do not have',
|
|
1506
|
+
severity: 'warning',
|
|
1507
|
+
check: (decl) => {
|
|
1508
|
+
if (!decl.string || (decl.convention && decl.convention.verbatim_asciidoc === false)) return []
|
|
1509
|
+
const issues = []
|
|
1510
|
+
for (const m of decl.string.matchAll(/\bxref:([^\s[\]]+)\[/g)) {
|
|
1511
|
+
const parts = m[1].split(':')
|
|
1512
|
+
// component:module:page names its docs component, so it resolves
|
|
1513
|
+
// wherever the partial is included.
|
|
1514
|
+
if (parts.length >= 3) continue
|
|
1515
|
+
if (parts.length === 2 && XREF_MODULES.has(parts[0])) continue
|
|
1516
|
+
issues.push({
|
|
1517
|
+
message: parts.length === 1
|
|
1518
|
+
? `"xref:${m[1]}[" has no module. It resolves on the self-managed page, but Cloud includes the same partial into its own module, where the link breaks. Name the module, for example xref:components:${m[1]}[...].`
|
|
1519
|
+
: `"xref:${m[1]}[" names the ${parts[0]} module, which the Redpanda Connect docs do not have, so the link breaks on the rendered page. Use one of ${[...XREF_MODULES].join(', ')}, a component:module:page xref, or a full https:// link.`
|
|
1520
|
+
})
|
|
1521
|
+
}
|
|
1522
|
+
return issues
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
name: 'connect-availability-prose',
|
|
1527
|
+
description: 'Cloud or cgo availability written as prose instead of data',
|
|
1528
|
+
severity: 'warning',
|
|
1529
|
+
check: (decl) => {
|
|
1530
|
+
if (!decl.string) return []
|
|
1531
|
+
const m = decl.string.match(AVAILABILITY_PROSE)
|
|
1532
|
+
if (!m) return []
|
|
1533
|
+
return [{ message: `States availability in prose ("${m[0]}"). The generator renders Cloud and cgo availability notes from internal/plugins/info.csv and the build lists, and prose here shows on both the self-managed and the Cloud page. Remove it and fix the data instead.` }]
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
{
|
|
1537
|
+
name: 'connect-cli-usage-multiline',
|
|
1538
|
+
description: 'urfave/cli Usage is a one-line label',
|
|
1539
|
+
severity: 'warning',
|
|
1540
|
+
check: (decl) => {
|
|
1541
|
+
if (decl.meta.kind !== 'cli-usage' && decl.meta.kind !== 'cli-flag') return []
|
|
1542
|
+
if (!decl.string || !decl.string.includes('\n')) return []
|
|
1543
|
+
return [{ message: 'Usage spans multiple lines. `rpk connect` help prints Usage as a one-line label, so move detail into the command Description.' }]
|
|
1544
|
+
}
|
|
218
1545
|
}
|
|
219
1546
|
]
|
|
220
1547
|
|
|
@@ -229,12 +1556,32 @@ function identity (decl) {
|
|
|
229
1556
|
return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
|
|
230
1557
|
}
|
|
231
1558
|
|
|
1559
|
+
const DETAIL_KEYS = ['kind', 'part', 'ctor', 'field', 'option', 'components', 'components_total', 'components_via', 'call_site', 'template', 'unresolved', 'config_template']
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* What a review needs beyond the string: the kind of declaration, the
|
|
1563
|
+
* components that publish it with their info.csv row (support level, Cloud
|
|
1564
|
+
* and Cloud GPU availability), and, for helper text, the call site and the
|
|
1565
|
+
* template with its gaps.
|
|
1566
|
+
*/
|
|
1567
|
+
function reviewContext (decl) {
|
|
1568
|
+
const meta = decl.meta || {}
|
|
1569
|
+
const out = {}
|
|
1570
|
+
for (const key of DETAIL_KEYS) if (meta[key] !== undefined && meta[key] !== null) out[key] = meta[key]
|
|
1571
|
+
return out
|
|
1572
|
+
}
|
|
1573
|
+
|
|
232
1574
|
module.exports = {
|
|
233
1575
|
name: 'connect',
|
|
234
1576
|
convention: CONVENTION,
|
|
235
1577
|
extract,
|
|
236
1578
|
scanFile,
|
|
237
1579
|
identity,
|
|
1580
|
+
reviewContext,
|
|
1581
|
+
readInfoCsv,
|
|
1582
|
+
chainHead,
|
|
1583
|
+
kindOfType,
|
|
1584
|
+
_ConnectScanner: ConnectScanner,
|
|
238
1585
|
rules: RULES,
|
|
239
1586
|
// A connect description is the AsciiDoc page body, not a table cell:
|
|
240
1587
|
// bare | is legitimate table syntax there, and missing prose is handled
|