@kindgi/dotenv-file 0.0.0-stage → 0.1.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/src/expand.ts ADDED
@@ -0,0 +1,185 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `${VAR}` expansion over parsed dotenv values.
6
+ *
7
+ * Host frameworks expand with `dotenv-expand`, but not one version:
8
+ * Next.js 16 bundles v10's algorithm, Vite uses v12, and the two differ
9
+ * (v10 resolves references to keys defined later in the file, v12
10
+ * does not). This module implements one well-defined semantics that
11
+ * agrees with BOTH on everything they agree on — verified by
12
+ * `tests/conformance.test.ts` — and picks Next.js's (order-independent)
13
+ * answer where they differ.
14
+ *
15
+ * Syntax:
16
+ * - `$NAME` (`[A-Za-z_][A-Za-z0-9_]*`) and `${NAME}`
17
+ * - `${NAME:-default}` — default when NAME is unset or empty
18
+ * - `${NAME-default}` — default when NAME is unset
19
+ * - `${NAME:+alt}` — alt when NAME is set and non-empty, else empty
20
+ * - `${NAME+alt}` — alt when NAME is set, else empty
21
+ * - `\$` — a literal `$` (the writer escapes every `$` this way)
22
+ * Defaults / alts are themselves expanded. A `$` not followed by a
23
+ * name or `{…}` is literal.
24
+ *
25
+ * Lookup: a name defined in the files resolves to its (expanded) file
26
+ * value, wherever in the files it is defined. Only a name defined in no
27
+ * file falls back to `options.env` (so `${HOME}` works). Unlike
28
+ * `dotenv-expand`, the caller's env never overrides a file's own value:
29
+ * the files are the source, the environment only fills gaps. Nothing
30
+ * reads `process.env` implicitly — callers pass what they mean.
31
+ *
32
+ * Undefined references expand to `''` and cycles (`A=$B`, `B=$A`) are
33
+ * cut at the repeated name; both are reported in `diagnostics`.
34
+ */
35
+
36
+ export interface ExpandOptions {
37
+ /** Fallback for names no file defines. Never overrides a file value. */
38
+ readonly env?: Readonly<Record<string, string | undefined>>;
39
+ }
40
+
41
+ export interface ExpandDiagnostic {
42
+ readonly kind: 'unresolved' | 'cycle';
43
+ /** The key whose value contains the reference. */
44
+ readonly key: string;
45
+ /** The referenced name. */
46
+ readonly ref: string;
47
+ }
48
+
49
+ export interface ExpandResult {
50
+ readonly values: Record<string, string>;
51
+ readonly diagnostics: readonly ExpandDiagnostic[];
52
+ }
53
+
54
+ const NAME_START = /[A-Za-z_]/;
55
+ const NAME_CHAR = /[A-Za-z0-9_]/;
56
+ const OPERATORS = [':-', ':+', '-', '+'] as const;
57
+ type Operator = (typeof OPERATORS)[number];
58
+
59
+ export function expandEnv(
60
+ values: Readonly<Record<string, string>>,
61
+ options: ExpandOptions = {},
62
+ ): ExpandResult {
63
+ const env = options.env ?? {};
64
+ const resolved = new Map<string, string>();
65
+ const stack: string[] = [];
66
+ const diagnostics: ExpandDiagnostic[] = [];
67
+ const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(values, name);
68
+
69
+ function lookup(name: string, fromKey: string): string | undefined {
70
+ if (has(name)) {
71
+ if (stack.includes(name)) {
72
+ diagnostics.push({ kind: 'cycle', key: fromKey, ref: name });
73
+ return undefined;
74
+ }
75
+ return resolve(name);
76
+ }
77
+ const fromEnv = env[name];
78
+ return typeof fromEnv === 'string' ? fromEnv : undefined;
79
+ }
80
+
81
+ function resolve(key: string): string {
82
+ const cached = resolved.get(key);
83
+ if (cached !== undefined) return cached;
84
+ stack.push(key);
85
+ const out = expandString(values[key] ?? '', key);
86
+ stack.pop();
87
+ resolved.set(key, out);
88
+ return out;
89
+ }
90
+
91
+ function evaluate(expression: string, key: string): string {
92
+ const { name, op, operand } = splitExpression(expression);
93
+ const value = lookup(name, key);
94
+ if (op !== undefined) return APPLY[op](value, () => expandString(operand, key));
95
+ if (value !== undefined) return value;
96
+ if (!diagnostics.some((d) => d.key === key && d.ref === name)) {
97
+ diagnostics.push({ kind: 'unresolved', key, ref: name });
98
+ }
99
+ return '';
100
+ }
101
+
102
+ function expandString(input: string, key: string): string {
103
+ let out = '';
104
+ let i = 0;
105
+ while (i < input.length) {
106
+ const token = readToken(input, i);
107
+ if (token === undefined) {
108
+ out += input[i];
109
+ i += 1;
110
+ continue;
111
+ }
112
+ out += token.kind === 'escape' ? '$' : evaluate(token.expression, key);
113
+ i = token.end;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ const out: Record<string, string> = {};
119
+ for (const key of Object.keys(values)) out[key] = resolve(key);
120
+ return { values: out, diagnostics };
121
+ }
122
+
123
+ /** Operator semantics (bash): `:` also treats an empty value as unset. */
124
+ const APPLY: Readonly<
125
+ Record<Operator, (value: string | undefined, operand: () => string) => string>
126
+ > = {
127
+ ':-': (value, operand) => (value === undefined || value === '' ? operand() : value),
128
+ '-': (value, operand) => (value === undefined ? operand() : value),
129
+ ':+': (value, operand) => (value !== undefined && value !== '' ? operand() : ''),
130
+ '+': (value, operand) => (value !== undefined ? operand() : ''),
131
+ };
132
+
133
+ type Token =
134
+ | { readonly kind: 'escape'; readonly end: number }
135
+ | { readonly kind: 'ref'; readonly expression: string; readonly end: number };
136
+
137
+ /** The escape or reference starting at `i`, if any. */
138
+ function readToken(input: string, i: number): Token | undefined {
139
+ if (input[i] === '\\' && input[i + 1] === '$') return { kind: 'escape', end: i + 2 };
140
+ if (input[i] !== '$') return undefined;
141
+ const next = input[i + 1];
142
+ if (next === '{') {
143
+ const close = findBraceClose(input, i + 2);
144
+ return close === -1
145
+ ? undefined
146
+ : { kind: 'ref', expression: input.slice(i + 2, close), end: close + 1 };
147
+ }
148
+ if (next === undefined || !NAME_START.test(next)) return undefined;
149
+ let end = i + 2;
150
+ while (end < input.length && NAME_CHAR.test(input[end] ?? '')) end += 1;
151
+ return { kind: 'ref', expression: input.slice(i + 1, end), end };
152
+ }
153
+
154
+ /**
155
+ * Index of the `}` closing a `${…}` whose body starts at `from`, or -1
156
+ * when it is not an expression (empty body, unterminated, or a bare `{`
157
+ * inside). A nested `${…}` — e.g. in a default — nests; `\$` is skipped.
158
+ */
159
+ function findBraceClose(input: string, from: number): number {
160
+ const tokens = /\\\$|\$\{|[{}]/g;
161
+ tokens.lastIndex = from;
162
+ let depth = 1;
163
+ for (let m = tokens.exec(input); m !== null; m = tokens.exec(input)) {
164
+ if (m[0] === '{') return -1;
165
+ if (m[0] === '${') depth += 1;
166
+ else if (m[0] === '}') depth -= 1;
167
+ if (depth === 0) return m.index === from ? -1 : m.index;
168
+ }
169
+ return -1;
170
+ }
171
+
172
+ function splitExpression(expression: string): {
173
+ readonly name: string;
174
+ readonly op: Operator | undefined;
175
+ readonly operand: string;
176
+ } {
177
+ for (let i = 1; i < expression.length; i += 1) {
178
+ for (const op of OPERATORS) {
179
+ if (expression.startsWith(op, i)) {
180
+ return { name: expression.slice(0, i), op, operand: expression.slice(i + op.length) };
181
+ }
182
+ }
183
+ }
184
+ return { name: expression, op: undefined, operand: '' };
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `@kindgi/dotenv-file` — dotenv files the way applications read them,
6
+ * written the way people edit them.
7
+ *
8
+ * - **Read** (`parse.ts`): `dotenv`'s grammar (verified against the
9
+ * version Next.js bundles), returned as structured lines so edits
10
+ * round-trip losslessly.
11
+ * - **Expand** (`expand.ts`): `${VAR}` / `${VAR:-default}` / `\$`,
12
+ * agreeing with `dotenv-expand` wherever its versions agree.
13
+ * - **Layer** (`layers.ts`): several files (`.env` < `.env.local`) as
14
+ * one view, with per-key origin.
15
+ * - **Write** (`serialize.ts`): `setKey` / `unsetKey` touch one key and
16
+ * keep every other byte; values are quoted so they read back
17
+ * exactly.
18
+ *
19
+ * Zero runtime dependencies and no filesystem access — callers read and
20
+ * write the files. See `README.md`.
21
+ */
22
+
23
+ export {
24
+ type ExpandDiagnostic,
25
+ type ExpandOptions,
26
+ type ExpandResult,
27
+ expandEnv,
28
+ } from './expand.js';
29
+ export {
30
+ type EnvDiagnostic,
31
+ type EnvLayer,
32
+ type LayeredEnv,
33
+ readEnv,
34
+ readEnvLayers,
35
+ } from './layers.js';
36
+ export {
37
+ DOTENV_KEY_REGEX,
38
+ ENV_KEY_REGEX,
39
+ type EnvLine,
40
+ envLinesToRecord,
41
+ parseEntryRaw,
42
+ parseEnvFile,
43
+ } from './parse.js';
44
+ export {
45
+ EnvValueNotRepresentableError,
46
+ renderEntry,
47
+ serializeEnvFile,
48
+ setKey,
49
+ unsetKey,
50
+ } from './serialize.js';
package/src/layers.ts ADDED
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Layered env files — several dotenv files read as one view, the way
6
+ * an application reads `.env` + `.env.local`.
7
+ *
8
+ * Layers are ordered LOWEST precedence first: a key in a later layer
9
+ * overrides the same key in an earlier one. Raw values are merged
10
+ * first, then expanded once over the merged view, so a reference in
11
+ * `.env` to a name defined in `.env.local` sees the `.env.local` value
12
+ * (matching how Next.js loads its files). Pure — the caller reads the
13
+ * files and passes their contents (`null` for a file that doesn't
14
+ * exist), so this module never touches the filesystem.
15
+ */
16
+
17
+ import { type ExpandDiagnostic, type ExpandOptions, expandEnv } from './expand.js';
18
+ import { parseEnvFile } from './parse.js';
19
+
20
+ export interface EnvLayer {
21
+ /** Where the contents came from (a path, usually). Reported back in `origin`. */
22
+ readonly source: string;
23
+ /** File contents, or `null` when the file does not exist. */
24
+ readonly contents: string | null;
25
+ }
26
+
27
+ export type EnvDiagnostic =
28
+ | (ExpandDiagnostic & { readonly source: string })
29
+ | {
30
+ readonly kind: 'malformed';
31
+ readonly source: string;
32
+ /** 1-based line number in `source`. */
33
+ readonly line: number;
34
+ /** The line's text. May hold a secret — show `source:line` to users, not this. */
35
+ readonly raw: string;
36
+ readonly reason: string;
37
+ };
38
+
39
+ export interface LayeredEnv {
40
+ /** Merged, expanded values. */
41
+ readonly values: Readonly<Record<string, string>>;
42
+ /** Merged values as written (before expansion). */
43
+ readonly raw: Readonly<Record<string, string>>;
44
+ /** For each key, the `source` of the layer that supplied it. */
45
+ readonly origin: Readonly<Record<string, string>>;
46
+ /** Sources that exist (non-`null` contents), in layer order. */
47
+ readonly present: readonly string[];
48
+ /** Malformed lines (ignored, as dotenv does), unresolved references, cycles. */
49
+ readonly diagnostics: readonly EnvDiagnostic[];
50
+ }
51
+
52
+ export function readEnvLayers(
53
+ layers: readonly EnvLayer[],
54
+ options: ExpandOptions = {},
55
+ ): LayeredEnv {
56
+ const raw: Record<string, string> = {};
57
+ const origin: Record<string, string> = {};
58
+ const present: string[] = [];
59
+ const diagnostics: EnvDiagnostic[] = [];
60
+
61
+ for (const layer of layers) {
62
+ if (layer.contents === null) continue;
63
+ present.push(layer.source);
64
+ let lineNo = 1;
65
+ for (const line of parseEnvFile(layer.contents)) {
66
+ if (line.kind === 'entry' && line.key !== undefined && line.value !== undefined) {
67
+ raw[line.key] = line.value;
68
+ origin[line.key] = layer.source;
69
+ } else if (line.kind === 'malformed') {
70
+ diagnostics.push({
71
+ kind: 'malformed',
72
+ source: layer.source,
73
+ line: lineNo,
74
+ raw: line.raw,
75
+ reason: line.reason ?? 'not a `KEY=value` line',
76
+ });
77
+ }
78
+ lineNo += line.raw.split('\n').length;
79
+ }
80
+ }
81
+
82
+ const expanded = expandEnv(raw, options);
83
+ for (const d of expanded.diagnostics) {
84
+ diagnostics.push({ ...d, source: origin[d.key] ?? '' });
85
+ }
86
+ return { values: expanded.values, raw, origin, present, diagnostics };
87
+ }
88
+
89
+ /** One file's values, parsed and expanded. */
90
+ export function readEnv(contents: string, options: ExpandOptions = {}): Record<string, string> {
91
+ return { ...readEnvLayers([{ source: '', contents }], options).values };
92
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,266 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Structured, lossless dotenv parser.
6
+ *
7
+ * **Grammar = `dotenv`'s `parse()`** (verified against `dotenv@16.3.1`,
8
+ * the version Next.js 16 bundles, by `tests/conformance.test.ts`). An
9
+ * application Kindgi is embedded in reads its `.env` files with that
10
+ * parser; Kindgi reading the same file must see the same values.
11
+ *
12
+ * - `KEY=value` or `KEY: value` (colon + at least one space).
13
+ * Optional leading whitespace and an optional `export ` prefix.
14
+ * - Keys: `[A-Za-z0-9_.-]+` (dotenv accepts lowercase, dots, dashes).
15
+ * - Unquoted values end at the first `#` (inline comment) and are
16
+ * trimmed.
17
+ * - `'…'`, `"…"` and `` `…` `` values may span lines. The closing
18
+ * quote must be followed only by whitespace or a `# comment` on its
19
+ * line; otherwise the value is read unquoted. Inside double quotes
20
+ * `\n` / `\r` become newline / carriage return; nothing else is
21
+ * unescaped (`\"` stays `\"`, exactly as dotenv does).
22
+ * - Duplicate keys: the last one wins.
23
+ * - Anything else is ignored by dotenv; here it surfaces as a
24
+ * `malformed` line so hand-edited files round-trip untouched.
25
+ *
26
+ * Deliberate divergence (pathological inputs only): dotenv's regex lets
27
+ * a key's separator or an empty value's leading whitespace run across
28
+ * a newline (`KEY=` followed by a line that starts with a quote joins
29
+ * the two). Here a key, its separator, and the start of its value are
30
+ * always on one line.
31
+ *
32
+ * Variable expansion (`${VAR}`) is a separate step — see `expand.ts`.
33
+ * `parseEnvFile` returns values exactly as written.
34
+ */
35
+
36
+ export interface EnvLine {
37
+ readonly kind: 'blank' | 'comment' | 'entry' | 'malformed';
38
+ /**
39
+ * The line's source text. For an `entry` whose quoted value spans
40
+ * several lines, `raw` holds all of them joined with `\n`.
41
+ */
42
+ readonly raw: string;
43
+ readonly key?: string;
44
+ /** Parsed (unexpanded) value. */
45
+ readonly value?: string;
46
+ /** `true` when the entry was written with an `export ` prefix. */
47
+ readonly exported?: boolean;
48
+ readonly reason?: string;
49
+ }
50
+
51
+ /** Keys dotenv accepts when READING a file. */
52
+ export const DOTENV_KEY_REGEX = /^[\w.-]+$/;
53
+
54
+ /**
55
+ * Keys Kindgi WRITES: POSIX-shell env-var shape. Stricter than
56
+ * `DOTENV_KEY_REGEX` so every name Kindgi writes is also exportable
57
+ * from a shell.
58
+ */
59
+ export const ENV_KEY_REGEX = /^[A-Z_][A-Z0-9_]*$/;
60
+
61
+ const ENTRY_HEAD = /^\s*(export\s+)?([\w.-]+)(?:\s*=|:\s)\s*/;
62
+ const TRAILER = /^\s*(?:#.*)?$/;
63
+ const QUOTES = new Set(["'", '"', '`']);
64
+
65
+ /**
66
+ * Split dotenv text into structured lines. `serializeEnvFile` of the
67
+ * result reproduces the input (line endings normalised to `\n`, which
68
+ * is also what dotenv does before parsing).
69
+ */
70
+ export function parseEnvFile(raw: string): readonly EnvLine[] {
71
+ if (raw === '') return [];
72
+ const physical = raw.split(/\r\n?|\n/);
73
+ const lines =
74
+ physical.length > 0 && physical[physical.length - 1] === '' ? physical.slice(0, -1) : physical;
75
+ const out: EnvLine[] = [];
76
+ let i = 0;
77
+ while (i < lines.length) {
78
+ const { line, consumed } = parseAt(lines, i);
79
+ out.push(line);
80
+ i += consumed;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Parse a single entry's source text (possibly multi-line). Returns
87
+ * `undefined` unless the whole text is exactly one entry. Used by the
88
+ * serializer to decide whether an entry's `raw` is still faithful to
89
+ * its `key` / `value`.
90
+ */
91
+ export function parseEntryRaw(raw: string): EnvLine | undefined {
92
+ const lines = parseEnvFile(raw);
93
+ if (lines.length !== 1) return undefined;
94
+ const [only] = lines;
95
+ return only?.kind === 'entry' ? only : undefined;
96
+ }
97
+
98
+ /** Flatten entries into a record (last duplicate wins). Values are unexpanded. */
99
+ export function envLinesToRecord(lines: readonly EnvLine[]): Record<string, string> {
100
+ const out: Record<string, string> = {};
101
+ for (const line of lines) {
102
+ if (line.kind === 'entry' && line.key !== undefined && line.value !== undefined) {
103
+ out[line.key] = line.value;
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------
110
+ // Internals
111
+ // ---------------------------------------------------------------------
112
+
113
+ interface ParseStep {
114
+ readonly line: EnvLine;
115
+ readonly consumed: number;
116
+ }
117
+
118
+ function parseAt(lines: readonly string[], i: number): ParseStep {
119
+ const first = lines[i] ?? '';
120
+ if (first.trim() === '') return { line: { kind: 'blank', raw: first }, consumed: 1 };
121
+ if (first.trimStart().startsWith('#')) {
122
+ return { line: { kind: 'comment', raw: first }, consumed: 1 };
123
+ }
124
+
125
+ const head = ENTRY_HEAD.exec(first);
126
+ if (head === null) {
127
+ return {
128
+ line: { kind: 'malformed', raw: first, reason: 'not a `KEY=value` line' },
129
+ consumed: 1,
130
+ };
131
+ }
132
+ const key = head[2] ?? '';
133
+ const exported = head[1] !== undefined;
134
+ const valueStart = head[0].length;
135
+ const opener = first[valueStart];
136
+
137
+ if (opener !== undefined && QUOTES.has(opener)) {
138
+ const quoted = scanQuoted(lines, i, valueStart, opener);
139
+ if (quoted !== undefined) {
140
+ const raw = lines.slice(i, i + quoted.lineCount).join('\n');
141
+ return {
142
+ line: entry(raw, key, unescapeQuoted(quoted.inner, opener), exported),
143
+ consumed: quoted.lineCount,
144
+ };
145
+ }
146
+ }
147
+
148
+ return { line: entry(first, key, unquotedValue(first.slice(valueStart)), exported), consumed: 1 };
149
+ }
150
+
151
+ function entry(raw: string, key: string, value: string, exported: boolean): EnvLine {
152
+ return exported
153
+ ? { kind: 'entry', raw, key, value, exported: true }
154
+ : { kind: 'entry', raw, key, value };
155
+ }
156
+
157
+ /**
158
+ * Unquoted value: everything up to the first `#`, trimmed. dotenv then
159
+ * strips one pair of matching outer quotes from the trimmed text (e.g.
160
+ * `KEY="a" "b"` → `a" "b`), and expands `\n` / `\r` if that pair was
161
+ * double quotes. Replicated exactly.
162
+ */
163
+ function unquotedValue(rest: string): string {
164
+ const hash = rest.indexOf('#');
165
+ const v = (hash === -1 ? rest : rest.slice(0, hash)).trim();
166
+ const q = v[0];
167
+ if (v.length >= 2 && q !== undefined && QUOTES.has(q) && v[v.length - 1] === q) {
168
+ return unescapeQuoted(v.slice(1, -1), q);
169
+ }
170
+ return v;
171
+ }
172
+
173
+ function unescapeQuoted(inner: string, quote: string): string {
174
+ return quote === '"' ? inner.replace(/\\n/g, '\n').replace(/\\r/g, '\r') : inner;
175
+ }
176
+
177
+ interface QuotedScan {
178
+ readonly inner: string;
179
+ readonly lineCount: number;
180
+ }
181
+
182
+ interface Position {
183
+ readonly line: number;
184
+ readonly col: number;
185
+ }
186
+
187
+ /**
188
+ * Closing-quote candidates in the order dotenv's regex tries them: the
189
+ * first quote not preceded by a backslash, then each backslash-escaped
190
+ * quote before it, last to first.
191
+ */
192
+ function closingCandidates(
193
+ lines: readonly string[],
194
+ startLine: number,
195
+ startCol: number,
196
+ quote: string,
197
+ ): Position[] {
198
+ const escaped: Position[] = [];
199
+ for (let line = startLine; line < lines.length; line += 1) {
200
+ const text = lines[line] ?? '';
201
+ const found = scanLine(text, line === startLine ? startCol + 1 : 0, quote);
202
+ for (const col of found.escaped) escaped.push({ line, col });
203
+ if (found.closing !== -1) return [{ line, col: found.closing }, ...escaped.reverse()];
204
+ }
205
+ return escaped.reverse();
206
+ }
207
+
208
+ /** One line's escaped-quote columns, and the first unescaped quote (-1 if none). */
209
+ function scanLine(
210
+ text: string,
211
+ from: number,
212
+ quote: string,
213
+ ): { readonly escaped: readonly number[]; readonly closing: number } {
214
+ const escaped: number[] = [];
215
+ let col = from;
216
+ while (col < text.length) {
217
+ if (text[col] === '\\' && text[col + 1] === quote) {
218
+ escaped.push(col + 1);
219
+ col += 2;
220
+ } else if (text[col] === quote) {
221
+ return { escaped, closing: col };
222
+ } else {
223
+ col += 1;
224
+ }
225
+ }
226
+ return { escaped, closing: -1 };
227
+ }
228
+
229
+ /**
230
+ * Find the closing quote the way dotenv's `'(?:\\'|[^'])*'` alternative
231
+ * does (per quote character): the first quote not preceded by a
232
+ * backslash is tried first; then, backtracking, each backslash-escaped
233
+ * quote before it, last to first. A candidate is accepted when the rest
234
+ * of its line is whitespace or a `# comment`. `undefined` = no candidate
235
+ * works and the value is read unquoted.
236
+ */
237
+ function scanQuoted(
238
+ lines: readonly string[],
239
+ startLine: number,
240
+ startCol: number,
241
+ quote: string,
242
+ ): QuotedScan | undefined {
243
+ for (const c of closingCandidates(lines, startLine, startCol, quote)) {
244
+ const closingLine = lines[c.line] ?? '';
245
+ if (!TRAILER.test(closingLine.slice(c.col + 1))) continue;
246
+ return {
247
+ inner: sliceAcross(lines, startLine, startCol + 1, c.line, c.col),
248
+ lineCount: c.line - startLine + 1,
249
+ };
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ function sliceAcross(
255
+ lines: readonly string[],
256
+ fromLine: number,
257
+ fromCol: number,
258
+ toLine: number,
259
+ toCol: number,
260
+ ): string {
261
+ if (fromLine === toLine) return (lines[fromLine] ?? '').slice(fromCol, toCol);
262
+ const parts = [(lines[fromLine] ?? '').slice(fromCol)];
263
+ for (let li = fromLine + 1; li < toLine; li += 1) parts.push(lines[li] ?? '');
264
+ parts.push((lines[toLine] ?? '').slice(0, toCol));
265
+ return parts.join('\n');
266
+ }