trooth 0.4.4 → 0.5.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/README.md +105 -39
- package/bin/lib/declarations.mjs +306 -0
- package/bin/lib/hcl.mjs +294 -0
- package/bin/trooth.mjs +393 -348
- package/npm-shrinkwrap.json +37 -0
- package/package.json +9 -5
package/bin/lib/hcl.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// A strict reader for the HCL native syntax Terraform uses (.tf files).
|
|
2
|
+
// Copyright 2025-2026 Trooth, LLC. Apache-2.0.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Up to 0.4.4, `trooth lint` read .tf files with regular
|
|
5
|
+
// expressions. A regular expression cannot tell a comment from a setting or an
|
|
6
|
+
// indented block from a top-level one, and it cannot tell that a file is
|
|
7
|
+
// malformed, so `encrypted = false # off` and `# encrypted = true` both counted
|
|
8
|
+
// as declaring encryption and a broken file read as a successful one.
|
|
9
|
+
//
|
|
10
|
+
// This module tokenizes and parses the syntax into a tree. It does not
|
|
11
|
+
// evaluate anything: a variable, a function call, a conditional or a reference
|
|
12
|
+
// is kept as an UNRESOLVED EXPRESSION ({ $expr: "<source text>" }) and is never
|
|
13
|
+
// promoted to a value. Comments are dropped by the tokenizer, so nothing inside
|
|
14
|
+
// one can count. A file that does not parse throws HclParseError, and the
|
|
15
|
+
// caller reports it as invalid rather than as read.
|
|
16
|
+
//
|
|
17
|
+
// Supported: attributes, blocks with any number of labels, nested blocks,
|
|
18
|
+
// strings with escapes and ${ } / %{ } templates, heredocs (<<EOF and <<-EOF),
|
|
19
|
+
// numbers, true/false/null, tuples [ ], objects { }, and any other expression
|
|
20
|
+
// as unresolved source text. Not supported, and reported as unresolved rather
|
|
21
|
+
// than guessed: for-expressions, splat values, function results, conditionals.
|
|
22
|
+
|
|
23
|
+
export class HclParseError extends Error {
|
|
24
|
+
constructor(message, line) {
|
|
25
|
+
super(`line ${line}: ${message}`);
|
|
26
|
+
this.name = 'HclParseError';
|
|
27
|
+
this.line = line;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isIdentStart = (ch) => /[A-Za-z_]/.test(ch);
|
|
32
|
+
const isIdentChar = (ch) => /[A-Za-z0-9_-]/.test(ch);
|
|
33
|
+
|
|
34
|
+
/** Source text to tokens. Comments are skipped; newlines are kept because they
|
|
35
|
+
* end an attribute. Throws HclParseError on an unterminated string, comment or
|
|
36
|
+
* heredoc, or a character the syntax does not allow. */
|
|
37
|
+
export function tokenize(src) {
|
|
38
|
+
const toks = [];
|
|
39
|
+
let i = 0, line = 1;
|
|
40
|
+
const n = src.length;
|
|
41
|
+
const push = (t, v, extra) => toks.push({ t, v, line, ...extra });
|
|
42
|
+
while (i < n) {
|
|
43
|
+
const ch = src[i];
|
|
44
|
+
if (ch === '\n') { push('nl', '\n'); line++; i++; continue; }
|
|
45
|
+
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '') { i++; continue; }
|
|
46
|
+
if (ch === '#' || (ch === '/' && src[i + 1] === '/')) {
|
|
47
|
+
while (i < n && src[i] !== '\n') i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
51
|
+
const end = src.indexOf('*/', i + 2);
|
|
52
|
+
if (end < 0) throw new HclParseError('unterminated /* comment', line);
|
|
53
|
+
for (let k = i; k < end; k++) if (src[k] === '\n') line++;
|
|
54
|
+
i = end + 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
const startLine = line;
|
|
59
|
+
let j = i + 1, value = '', interp = false, depth = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (j >= n) throw new HclParseError('unterminated string', startLine);
|
|
62
|
+
const c = src[j];
|
|
63
|
+
if (c === '\n' && depth === 0) throw new HclParseError('newline inside a quoted string', startLine);
|
|
64
|
+
if (c === '\n') line++;
|
|
65
|
+
if (c === '\\' && depth === 0) {
|
|
66
|
+
const e = src[j + 1];
|
|
67
|
+
const map = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' };
|
|
68
|
+
if (e in map) { value += map[e]; j += 2; continue; }
|
|
69
|
+
if (e === 'u' || e === 'U') { value += '\\' + e; j += 2; continue; }
|
|
70
|
+
throw new HclParseError(`invalid escape \\${e}`, line);
|
|
71
|
+
}
|
|
72
|
+
if ((c === '$' || c === '%') && src[j + 1] === '{' && depth === 0) {
|
|
73
|
+
if (src[j - 1] === c && src[j - 2] !== c) { value += '{'; j += 2; continue; } // $${ escapes
|
|
74
|
+
interp = true; depth = 1; value += c + '{'; j += 2; continue;
|
|
75
|
+
}
|
|
76
|
+
if (depth > 0) {
|
|
77
|
+
if (c === '{') depth++;
|
|
78
|
+
else if (c === '}') depth--;
|
|
79
|
+
else if (c === '"') {
|
|
80
|
+
// a quoted string nested inside a template expression
|
|
81
|
+
let k = j + 1;
|
|
82
|
+
while (k < n && src[k] !== '"') { if (src[k] === '\\') k++; if (src[k] === '\n') throw new HclParseError('unterminated string', startLine); k++; }
|
|
83
|
+
if (k >= n) throw new HclParseError('unterminated string', startLine);
|
|
84
|
+
value += src.slice(j, k + 1); j = k + 1; continue;
|
|
85
|
+
}
|
|
86
|
+
value += c; j++; continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === '"') break;
|
|
89
|
+
value += c; j++;
|
|
90
|
+
}
|
|
91
|
+
push('str', value, { interp, raw: src.slice(i, j + 1) });
|
|
92
|
+
i = j + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (ch === '<' && src[i + 1] === '<' && /[-A-Za-z_]/.test(src[i + 2] || '')) {
|
|
96
|
+
const m = /^<<(-?)([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\r?\n/.exec(src.slice(i));
|
|
97
|
+
if (m) {
|
|
98
|
+
const startLine = line;
|
|
99
|
+
const marker = m[2];
|
|
100
|
+
let j = i + m[0].length;
|
|
101
|
+
line++;
|
|
102
|
+
const lines = [];
|
|
103
|
+
let closed = false;
|
|
104
|
+
while (j <= n) {
|
|
105
|
+
let e = src.indexOf('\n', j);
|
|
106
|
+
if (e < 0) e = n;
|
|
107
|
+
const l = src.slice(j, e).replace(/\r$/, '');
|
|
108
|
+
if (l.trim() === marker) { closed = true; j = e; break; }
|
|
109
|
+
lines.push(l);
|
|
110
|
+
if (e >= n) break;
|
|
111
|
+
line++;
|
|
112
|
+
j = e + 1;
|
|
113
|
+
}
|
|
114
|
+
if (!closed) throw new HclParseError(`unterminated heredoc <<${marker}`, startLine);
|
|
115
|
+
const value = lines.join('\n');
|
|
116
|
+
push('str', value, { interp: /[$%]\{/.test(value), raw: src.slice(i, j), heredoc: true });
|
|
117
|
+
i = j;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (/[0-9]/.test(ch)) {
|
|
122
|
+
const m = /^[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(src.slice(i));
|
|
123
|
+
push('num', Number(m[0]), { raw: m[0] });
|
|
124
|
+
i += m[0].length;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (isIdentStart(ch)) {
|
|
128
|
+
let j = i + 1;
|
|
129
|
+
while (j < n && isIdentChar(src[j])) j++;
|
|
130
|
+
push('ident', src.slice(i, j), { raw: src.slice(i, j) });
|
|
131
|
+
i = j;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const two = src.slice(i, i + 2);
|
|
135
|
+
if (['==', '!=', '<=', '>=', '&&', '||', '=>', '...'].includes(src.slice(i, i + 3)) || ['==', '!=', '<=', '>=', '&&', '||', '=>'].includes(two)) {
|
|
136
|
+
const op = src.slice(i, i + 3) === '...' ? '...' : two;
|
|
137
|
+
push('op', op, { raw: op }); i += op.length; continue;
|
|
138
|
+
}
|
|
139
|
+
if ('{}[]()=:,.?!+-*/%<>'.includes(ch)) { push(ch, ch, { raw: ch }); i++; continue; }
|
|
140
|
+
throw new HclParseError(`unexpected character ${JSON.stringify(ch)}`, line);
|
|
141
|
+
}
|
|
142
|
+
push('eof', '');
|
|
143
|
+
return toks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const OPEN = { '{': '}', '[': ']', '(': ')' };
|
|
147
|
+
|
|
148
|
+
class Parser {
|
|
149
|
+
constructor(toks) { this.toks = toks; this.p = 0; }
|
|
150
|
+
peek(k = 0) { return this.toks[this.p + k]; }
|
|
151
|
+
next() { return this.toks[this.p++]; }
|
|
152
|
+
skipNl() { while (this.peek().t === 'nl') this.p++; }
|
|
153
|
+
error(msg, tok = this.peek()) { throw new HclParseError(msg, tok.line); }
|
|
154
|
+
|
|
155
|
+
/** A body: attributes and blocks until `}` (nested) or end of file (top). */
|
|
156
|
+
body(nested) {
|
|
157
|
+
const items = [];
|
|
158
|
+
for (;;) {
|
|
159
|
+
this.skipNl();
|
|
160
|
+
const tok = this.peek();
|
|
161
|
+
if (tok.t === 'eof') { if (nested) this.error('unclosed block: a } is missing'); return items; }
|
|
162
|
+
if (tok.t === '}') { if (!nested) this.error('unexpected }'); this.next(); return items; }
|
|
163
|
+
if (tok.t !== 'ident') this.error(`expected an attribute or block name, found ${JSON.stringify(tok.v)}`);
|
|
164
|
+
const name = this.next().v;
|
|
165
|
+
if (this.peek().t === '=' ) {
|
|
166
|
+
this.next();
|
|
167
|
+
const exprToks = this.collectExpr(tok.line);
|
|
168
|
+
items.push({ kind: 'attr', key: name, value: exprValue(exprToks), line: tok.line });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const labels = [];
|
|
172
|
+
while (this.peek().t === 'str' || this.peek().t === 'ident') {
|
|
173
|
+
const l = this.next();
|
|
174
|
+
if (l.t === 'str' && l.interp) this.error('a block label cannot be a template', l);
|
|
175
|
+
labels.push(l.v);
|
|
176
|
+
}
|
|
177
|
+
if (this.peek().t !== '{') this.error(`expected = or { after ${name}`);
|
|
178
|
+
this.next();
|
|
179
|
+
const inner = this.body(true);
|
|
180
|
+
items.push({ kind: 'block', type: name, labels, body: inner, line: tok.line });
|
|
181
|
+
// a block ends at a newline or the end of the enclosing body
|
|
182
|
+
const after = this.peek().t;
|
|
183
|
+
if (after !== 'nl' && after !== 'eof' && after !== '}') this.error('expected a newline after a block');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The tokens of one expression, up to the newline that ends it at depth 0. */
|
|
188
|
+
collectExpr(line) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const stack = [];
|
|
191
|
+
for (;;) {
|
|
192
|
+
const tok = this.peek();
|
|
193
|
+
if (tok.t === 'eof') {
|
|
194
|
+
if (stack.length) this.error(`unclosed ${stack[stack.length - 1]}`, tok);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (!stack.length && (tok.t === 'nl' || tok.t === '}')) break;
|
|
198
|
+
this.next();
|
|
199
|
+
if (OPEN[tok.t]) stack.push(OPEN[tok.t]);
|
|
200
|
+
else if (tok.t === '}' || tok.t === ']' || tok.t === ')') {
|
|
201
|
+
if (stack.pop() !== tok.t) this.error(`mismatched ${tok.t}`, tok);
|
|
202
|
+
}
|
|
203
|
+
out.push(tok);
|
|
204
|
+
}
|
|
205
|
+
const meaningful = out.filter((t) => t.t !== 'nl');
|
|
206
|
+
if (!meaningful.length) throw new HclParseError('an attribute has no value', line);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sourceOf = (toks) => toks.filter((t) => t.t !== 'nl').map((t) => (t.raw !== undefined ? t.raw : String(t.v))).join(' ')
|
|
212
|
+
.replace(/ ?\. ?/g, '.').replace(/\[ /g, '[').replace(/ \]/g, ']').replace(/\( /g, '(').replace(/ \)/g, ')');
|
|
213
|
+
|
|
214
|
+
/** Split tokens on a separator at depth 0 (commas and newlines inside a tuple
|
|
215
|
+
* or object). */
|
|
216
|
+
function splitTop(toks, isSep) {
|
|
217
|
+
const parts = [];
|
|
218
|
+
let cur = [], depth = 0;
|
|
219
|
+
for (const t of toks) {
|
|
220
|
+
if (OPEN[t.t]) depth++;
|
|
221
|
+
else if (t.t === '}' || t.t === ']' || t.t === ')') depth--;
|
|
222
|
+
if (depth === 0 && isSep(t)) { if (cur.some((x) => x.t !== 'nl')) parts.push(cur); cur = []; continue; }
|
|
223
|
+
cur.push(t);
|
|
224
|
+
}
|
|
225
|
+
if (cur.some((x) => x.t !== 'nl')) parts.push(cur);
|
|
226
|
+
return parts;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Tokens of an expression to a value. Literals become JS values; tuples and
|
|
230
|
+
* objects become arrays and objects when every member parses; anything else
|
|
231
|
+
* becomes { $expr: source }. Nothing is evaluated. */
|
|
232
|
+
export function exprValue(toks) {
|
|
233
|
+
const ts = toks.filter((t) => t.t !== 'nl');
|
|
234
|
+
if (ts.length === 1) {
|
|
235
|
+
const t = ts[0];
|
|
236
|
+
if (t.t === 'ident') {
|
|
237
|
+
if (t.v === 'true') return true;
|
|
238
|
+
if (t.v === 'false') return false;
|
|
239
|
+
if (t.v === 'null') return null;
|
|
240
|
+
return { $expr: t.v };
|
|
241
|
+
}
|
|
242
|
+
if (t.t === 'num') return t.v;
|
|
243
|
+
if (t.t === 'str') return t.interp ? { $expr: t.raw } : t.v;
|
|
244
|
+
}
|
|
245
|
+
if (ts.length === 2 && ts[0].t === '-' && ts[1].t === 'num') return -ts[1].v;
|
|
246
|
+
const first = ts[0], last = ts[ts.length - 1];
|
|
247
|
+
if (first.t === '[' && last.t === ']' && closesAt(ts, 0) === ts.length - 1) {
|
|
248
|
+
const inner = toks.slice(toks.indexOf(first) + 1, toks.lastIndexOf(last));
|
|
249
|
+
if (inner.some((t) => t.t === 'ident' && t.v === 'for')) return { $expr: sourceOf(ts) };
|
|
250
|
+
return splitTop(inner, (t) => t.t === ',').map(exprValue);
|
|
251
|
+
}
|
|
252
|
+
if (first.t === '{' && last.t === '}' && closesAt(ts, 0) === ts.length - 1) {
|
|
253
|
+
const inner = toks.slice(toks.indexOf(first) + 1, toks.lastIndexOf(last));
|
|
254
|
+
if (inner.some((t) => t.t === 'ident' && t.v === 'for')) return { $expr: sourceOf(ts) };
|
|
255
|
+
const obj = {};
|
|
256
|
+
for (const member of splitTop(inner, (t) => t.t === ',' || t.t === 'nl')) {
|
|
257
|
+
const m = member.filter((t) => t.t !== 'nl');
|
|
258
|
+
const eq = m.findIndex((t) => t.t === '=' || t.t === ':');
|
|
259
|
+
if (eq !== 1 || !(m[0].t === 'ident' || (m[0].t === 'str' && !m[0].interp))) return { $expr: sourceOf(ts) };
|
|
260
|
+
obj[m[0].v] = exprValue(m.slice(2));
|
|
261
|
+
}
|
|
262
|
+
return obj;
|
|
263
|
+
}
|
|
264
|
+
return { $expr: sourceOf(ts) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function closesAt(ts, start) {
|
|
268
|
+
let depth = 0;
|
|
269
|
+
for (let k = start; k < ts.length; k++) {
|
|
270
|
+
if (OPEN[ts[k].t]) depth++;
|
|
271
|
+
else if (ts[k].t === '}' || ts[k].t === ']' || ts[k].t === ')') { depth--; if (depth === 0) return k; }
|
|
272
|
+
}
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Parse a whole file. Returns the list of top-level items. */
|
|
277
|
+
export function parseHcl(src) {
|
|
278
|
+
return new Parser(tokenize(src)).body(false);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A block's items as a plain object tree: attributes become keys, nested
|
|
282
|
+
* blocks become objects (an array of them when a block type repeats). */
|
|
283
|
+
export function bodyToTree(items) {
|
|
284
|
+
const tree = {};
|
|
285
|
+
for (const it of items) {
|
|
286
|
+
if (it.kind === 'attr') { tree[it.key] = it.value; continue; }
|
|
287
|
+
const v = bodyToTree(it.body);
|
|
288
|
+
const key = it.type;
|
|
289
|
+
if (key in tree) tree[key] = Array.isArray(tree[key]) && tree[`__blocks_${key}`] ? [...tree[key], v] : [tree[key], v];
|
|
290
|
+
else tree[key] = v;
|
|
291
|
+
if (Array.isArray(tree[key])) Object.defineProperty(tree, `__blocks_${key}`, { value: true, enumerable: false, configurable: true });
|
|
292
|
+
}
|
|
293
|
+
return tree;
|
|
294
|
+
}
|