trooth 0.4.3 → 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.
@@ -0,0 +1,306 @@
1
+ // What a declaration file says, read from its parsed tree.
2
+ // Copyright 2025-2026 Trooth, LLC. Apache-2.0.
3
+ //
4
+ // Every supported format is parsed first (HCL by ./hcl.mjs, JSON by
5
+ // JSON.parse, YAML by the maintained `yaml` package) and then read here from
6
+ // the tree, so a comment can never count, indentation cannot change a count,
7
+ // a minified file counts the same as a pretty one, and a file that does not
8
+ // parse is reported as invalid instead of being read.
9
+ //
10
+ // An unresolved expression ({ $expr: "..." }) is never promoted to a value.
11
+ // Where it decides a fact, the fact is reported as UNRESOLVED.
12
+
13
+ import { parseHcl, bodyToTree, HclParseError } from './hcl.mjs';
14
+ import { parseAllDocuments } from 'yaml';
15
+
16
+ export { HclParseError };
17
+
18
+ export const isExpr = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$expr === 'string';
19
+
20
+ /* ------------------------------------------------------------ parsing ---- */
21
+
22
+ export class InvalidDeclaration extends Error {
23
+ constructor(message) { super(message); this.name = 'InvalidDeclaration'; }
24
+ }
25
+
26
+ /** A string in Terraform's JSON syntax that holds a template is an expression. */
27
+ function tfJsonValue(v) {
28
+ if (typeof v === 'string') {
29
+ if (/[$%]\{/.test(v)) return { $expr: v };
30
+ return v;
31
+ }
32
+ if (Array.isArray(v)) return v.map(tfJsonValue);
33
+ if (v && typeof v === 'object') {
34
+ const o = {};
35
+ for (const [k, x] of Object.entries(v)) o[k] = tfJsonValue(x);
36
+ return o;
37
+ }
38
+ return v;
39
+ }
40
+
41
+ const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
42
+
43
+ /**
44
+ * One file's units. Each unit: { type, name, address, tree, typed }.
45
+ * `type` is a resource type or Kubernetes kind (null for everything else in the
46
+ * file), `tree` is the parsed value, `address` is how other resources refer to
47
+ * it. Throws InvalidDeclaration when the file does not parse.
48
+ */
49
+ export function unitsOf(kind, text) {
50
+ if (kind === 'terraform') {
51
+ let items;
52
+ try { items = parseHcl(text); } catch (e) { throw new InvalidDeclaration(e.message); }
53
+ const units = [];
54
+ const rest = [];
55
+ for (const it of items) {
56
+ if (it.kind === 'block' && it.type === 'resource') {
57
+ if (it.labels.length !== 2) throw new InvalidDeclaration(`line ${it.line}: a resource block needs a type and a name`);
58
+ const [type, name] = it.labels;
59
+ units.push({ type, name, address: `${type}.${name}`, tree: bodyToTree(it.body), typed: true });
60
+ } else rest.push(it);
61
+ }
62
+ if (rest.length) units.push({ type: null, name: null, address: null, tree: bodyToTree(rest), typed: false });
63
+ return units;
64
+ }
65
+ if (kind === 'terraform-json') {
66
+ let doc;
67
+ try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
68
+ if (!doc || typeof doc !== 'object') throw new InvalidDeclaration('a .tf.json file must hold a JSON object');
69
+ const units = [];
70
+ each(doc, (top) => each(top.resource, (byType) => {
71
+ for (const [type, byName] of Object.entries(byType)) {
72
+ if (!/^[a-z0-9_]+$/.test(type)) continue;
73
+ each(byName, (names) => {
74
+ for (const [name, body] of Object.entries(names)) {
75
+ each(body, (b) => units.push({ type, name, address: `${type}.${name}`, tree: tfJsonValue(b), typed: true }));
76
+ }
77
+ });
78
+ }
79
+ }));
80
+ if (!Array.isArray(doc)) {
81
+ const rest = { ...doc }; delete rest.resource;
82
+ if (Object.keys(rest).length) units.push({ type: null, name: null, address: null, tree: tfJsonValue(rest), typed: false });
83
+ }
84
+ return units;
85
+ }
86
+ if (kind === 'terraform-plan') {
87
+ let doc;
88
+ try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
89
+ const units = [];
90
+ const walkModule = (m) => {
91
+ if (!m || typeof m !== 'object') return;
92
+ for (const r of Array.isArray(m.resources) ? m.resources : []) {
93
+ if (r && r.mode !== 'data' && typeof r.type === 'string') {
94
+ units.push({ type: r.type, name: String(r.name ?? ''), address: r.address || `${r.type}.${r.name}`, tree: r.values ?? {}, typed: true, plan: true });
95
+ }
96
+ }
97
+ for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
98
+ };
99
+ if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
100
+ else {
101
+ for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
102
+ const after = rc && rc.change ? rc.change.after : null;
103
+ if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) {
104
+ units.push({ type: rc.type, name: String(rc.name ?? ''), address: rc.address || `${rc.type}.${rc.name}`, tree: after, typed: true, plan: true });
105
+ }
106
+ }
107
+ }
108
+ return units;
109
+ }
110
+ if (kind === 'kubernetes') {
111
+ const docs = parseAllDocuments(text, { strict: true, uniqueKeys: true, prettyErrors: false });
112
+ const list = Array.isArray(docs) ? docs : [docs];
113
+ const units = [];
114
+ for (const d of list) {
115
+ if (d.errors && d.errors.length) throw new InvalidDeclaration(`not valid YAML: ${d.errors[0].message.split('\n')[0]}`);
116
+ const v = d.toJS({ maxAliasCount: 100 });
117
+ if (v === null || v === undefined) continue;
118
+ if (typeof v !== 'object' || Array.isArray(v)) throw new InvalidDeclaration('a Kubernetes document must be a mapping');
119
+ const k = typeof v.kind === 'string' && /^[A-Za-z][A-Za-z0-9]*$/.test(v.kind) ? v.kind : null;
120
+ if (!k || typeof v.apiVersion !== 'string') throw new InvalidDeclaration('a document in a Kubernetes file has no apiVersion and kind');
121
+ units.push({ type: k, name: v.metadata && v.metadata.name ? String(v.metadata.name) : null, address: null, tree: v, typed: true });
122
+ }
123
+ return units;
124
+ }
125
+ if (kind === 'container') {
126
+ return [{ type: null, name: null, address: null, tree: dockerfileTree(text), typed: false }];
127
+ }
128
+ throw new Error(`unknown kind ${kind}`);
129
+ }
130
+
131
+ /** A Dockerfile's ENV and ARG settings as a tree. Comment lines and line
132
+ * continuations are handled; every other instruction declares nothing lint
133
+ * counts. A value that uses $ is unresolved. */
134
+ function dockerfileTree(text) {
135
+ const tree = {};
136
+ const logical = [];
137
+ let cur = '';
138
+ for (const raw of text.split(/\r?\n/)) {
139
+ if (/^\s*#/.test(raw) && !cur) continue;
140
+ if (/\\\s*$/.test(raw)) { cur += raw.replace(/\\\s*$/, ' '); continue; }
141
+ logical.push(cur + raw); cur = '';
142
+ }
143
+ if (cur) logical.push(cur);
144
+ const val = (s) => {
145
+ let v = s;
146
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
147
+ return /\$/.test(v) ? { $expr: v } : v;
148
+ };
149
+ for (const l of logical) {
150
+ const m = /^\s*(ENV|ARG)\s+(.*)$/i.exec(l);
151
+ if (!m) continue;
152
+ const rest = m[2].trim();
153
+ const pairs = rest.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)/g);
154
+ if (pairs && pairs.join(' ').length >= rest.replace(/\s+/g, ' ').length - pairs.length) {
155
+ for (const p of pairs) { const i = p.indexOf('='); tree[p.slice(0, i)] = val(p.slice(i + 1)); }
156
+ } else {
157
+ const sp = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(.*))?$/.exec(rest);
158
+ if (sp) tree[sp[1]] = sp[2] !== undefined ? val(sp[2].trim()) : { $expr: `ARG ${sp[1]}` };
159
+ }
160
+ }
161
+ return tree;
162
+ }
163
+
164
+ /* ----------------------------------------------------------- reading ---- */
165
+
166
+ /** Every (key, value) pair in a tree, depth first. */
167
+ function* pairs(tree, parent = null) {
168
+ if (Array.isArray(tree)) { for (const x of tree) yield* pairs(x, parent); return; }
169
+ if (!tree || typeof tree !== 'object' || isExpr(tree)) return;
170
+ for (const [k, v] of Object.entries(tree)) {
171
+ yield [k, v, parent];
172
+ yield* pairs(v, k);
173
+ }
174
+ }
175
+
176
+ /** Every string (literal or expression source) in a tree. */
177
+ function* strings(tree) {
178
+ if (typeof tree === 'string') { yield tree; return; }
179
+ if (isExpr(tree)) { yield tree.$expr; return; }
180
+ if (Array.isArray(tree)) { for (const x of tree) yield* strings(x); return; }
181
+ if (tree && typeof tree === 'object') for (const v of Object.values(tree)) yield* strings(v);
182
+ }
183
+
184
+ const REGION_KEY = /^(region|location|availability_zone|aws_region|aws_default_region|zone)$/i;
185
+ const REGION_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{2,40}$/;
186
+ const SECRET_KEY = /(password|passwd|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)/i;
187
+ const OPEN_CIDRS = new Set(['0.0.0.0/0', '::/0']);
188
+
189
+ export function regionsIn(tree) {
190
+ const found = [];
191
+ for (const [k, v] of pairs(tree)) if (REGION_KEY.test(k) && typeof v === 'string' && REGION_VALUE.test(v)) found.push(v);
192
+ return found;
193
+ }
194
+
195
+ /** A credential literal: a secret-named key holding a literal string of at
196
+ * least eight characters. One count per key, wherever it sits in the file. */
197
+ export function credentialLiterals(tree) {
198
+ let n = 0;
199
+ for (const [k, v] of pairs(tree)) {
200
+ if (!SECRET_KEY.test(k)) continue;
201
+ if (typeof v === 'string' && v.length >= 8 && !/[$%]\{/.test(v)) n++;
202
+ }
203
+ return n;
204
+ }
205
+
206
+ export function opensToAnyAddress(tree) {
207
+ for (const s of strings(tree)) if (OPEN_CIDRS.has(s.trim())) return true;
208
+ return false;
209
+ }
210
+
211
+ export function markedPublic(tree) {
212
+ for (const [k, v] of pairs(tree)) {
213
+ if ((k === 'publicly_accessible' || k === 'public_network_access_enabled' || k === 'associate_public_ip_address') && (v === true || v === 'true')) return true;
214
+ if (k === 'acl' && typeof v === 'string' && /^public-read/.test(v)) return true;
215
+ if (k === 'type' && (v === 'LoadBalancer' || v === 'NodePort')) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /* -------------------------------------------------------- encryption ---- */
221
+
222
+ // Keys whose value is the encryption switch itself.
223
+ const BOOL_KEY = /^(encrypted|storage_encrypted|encrypt_at_rest|encryption_enabled|enable_encryption|encrypted_at_rest|at_rest_encryption_enabled|encryption_at_rest_enabled)$/i;
224
+ // Keys that name the key or algorithm a store is encrypted with.
225
+ const KEY_KEY = /^(kms_key_id|kms_key_arn|kms_key_name|kms_key|kms_master_key_id|kms_key_self_link|encryption_key|encryption_key_name|disk_encryption_set_id|key_vault_key_id|sse_algorithm|default_kms_key_name)$/i;
226
+ // Blocks whose presence configures encryption, unless they switch it off.
227
+ const BLOCK_KEY = /^(server_side_encryption_configuration|server_side_encryption|encryption_configuration|encryption_config|encryption|encryption_at_rest|apply_server_side_encryption_by_default|disk_encryption|customer_managed_key)$/i;
228
+ // An expression that refers to a managed resource (aws_kms_key.main.arn) or a
229
+ // data source names a key that exists in the configuration; a variable, a
230
+ // local or anything else is unresolved.
231
+ const RESOURCE_REF = /^\$?\{?\s*(?:data\.)?[a-z][a-z0-9]*_[a-z0-9_]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_]+\s*\}?$/;
232
+
233
+ export const ENCRYPTION = Object.freeze({
234
+ TRUE: 'declared',
235
+ FALSE: 'declared_off',
236
+ ABSENT: 'not_declared',
237
+ UNRESOLVED: 'unresolved',
238
+ UNSUPPORTED: 'unsupported',
239
+ });
240
+
241
+ function boolState(v) {
242
+ if (v === true || v === 'true') return ENCRYPTION.TRUE;
243
+ if (v === false || v === 'false') return ENCRYPTION.FALSE;
244
+ if (isExpr(v)) return ENCRYPTION.UNRESOLVED;
245
+ if (v === null) return ENCRYPTION.ABSENT;
246
+ return ENCRYPTION.UNSUPPORTED;
247
+ }
248
+
249
+ function keyState(v) {
250
+ if (v === null || v === '' || v === false) return ENCRYPTION.ABSENT;
251
+ if (typeof v === 'string') return ENCRYPTION.TRUE;
252
+ if (isExpr(v)) {
253
+ const s = v.$expr.replace(/^"\$\{|\}"$/g, '').trim();
254
+ return RESOURCE_REF.test(s) ? ENCRYPTION.TRUE : ENCRYPTION.UNRESOLVED;
255
+ }
256
+ return ENCRYPTION.UNSUPPORTED;
257
+ }
258
+
259
+ function blockState(v) {
260
+ const blocks = Array.isArray(v) ? v : [v];
261
+ let state = ENCRYPTION.ABSENT;
262
+ for (const b of blocks) {
263
+ if (!b || typeof b !== 'object' || isExpr(b)) { if (isExpr(b)) state = state === ENCRYPTION.TRUE ? state : ENCRYPTION.UNRESOLVED; continue; }
264
+ if ('enabled' in b) {
265
+ const s = boolState(b.enabled);
266
+ if (s === ENCRYPTION.FALSE) return ENCRYPTION.FALSE;
267
+ if (s === ENCRYPTION.UNRESOLVED) { state = ENCRYPTION.UNRESOLVED; continue; }
268
+ }
269
+ if (state !== ENCRYPTION.UNRESOLVED) state = ENCRYPTION.TRUE;
270
+ }
271
+ return state;
272
+ }
273
+
274
+ /**
275
+ * What one store's tree declares about encryption at rest:
276
+ * declared an explicit true, a key, or an encryption block
277
+ * declared_off an explicit false (it wins over any other signal)
278
+ * not_declared nothing about encryption (a comment is nothing)
279
+ * unresolved decided by a variable, local or other expression
280
+ * unsupported a value lint does not interpret (a number for a switch)
281
+ * An explicit switch decides; keys and blocks decide only when no switch is set.
282
+ */
283
+ export function encryptionState(tree) {
284
+ const sw = [], other = [];
285
+ for (const [k, v] of pairs(tree)) {
286
+ if (BOOL_KEY.test(k)) sw.push(boolState(v));
287
+ else if (KEY_KEY.test(k)) other.push(keyState(v));
288
+ else if (BLOCK_KEY.test(k) && v && typeof v === 'object') other.push(blockState(v));
289
+ }
290
+ const decide = (list) => {
291
+ if (list.includes(ENCRYPTION.FALSE)) return ENCRYPTION.FALSE;
292
+ if (list.includes(ENCRYPTION.UNRESOLVED)) return ENCRYPTION.UNRESOLVED;
293
+ if (list.includes(ENCRYPTION.UNSUPPORTED)) return ENCRYPTION.UNSUPPORTED;
294
+ if (list.includes(ENCRYPTION.TRUE)) return ENCRYPTION.TRUE;
295
+ return ENCRYPTION.ABSENT;
296
+ };
297
+ const s = decide(sw);
298
+ if (s !== ENCRYPTION.ABSENT) return s;
299
+ return decide(other);
300
+ }
301
+
302
+ /** Every string and expression source in a tree, joined, for finding a
303
+ * reference to another resource's address. */
304
+ export function referenceText(tree) {
305
+ return [...strings(tree)].join('\n');
306
+ }
@@ -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
+ }