/** * Parse a compose file into a plain object plus a key-path -> line resolver. * * Two parsers on purpose: js-yaml gives the ergonomic plain object the rules read, * and eemeli's `yaml` (already in the tree) gives node ranges + a LineCounter for * precise editor markers. A duplicate-key or invalid-YAML error surfaces as a * parseError with its line (js-yaml is strict about duplicate keys by default). */ import jsyaml from 'js-yaml'; import { parseDocument, LineCounter, isNode, isMap, isScalar } from 'yaml'; import type { ParsedCompose } from './types'; export function parseCompose(source: string): ParsedCompose { // Plain object for the rules. js-yaml throws on duplicate keys and bad syntax. let doc: Record | null = null; let parseError: ParsedCompose['parseError'] | undefined; try { const loaded = jsyaml.load(source); doc = loaded && typeof loaded === 'object' && !Array.isArray(loaded) ? (loaded as Record) : null; if (loaded !== null && loaded !== undefined && doc === null) { parseError = { message: 'Compose file must be a YAML mapping at the top level' }; } } catch (e: unknown) { const mark = (e as { mark?: { line?: number } })?.mark; parseError = { message: e instanceof Error ? e.message.split('\n')[0] : 'Invalid YAML', line: typeof mark?.line === 'number' ? mark.line + 1 : undefined }; } // Position map via eemeli's yaml. Independent of js-yaml so a lint-worthy but // js-yaml-rejected file (duplicate key) can still report the error's line above. const lineCounter = new LineCounter(); let positionDoc: ReturnType | null = null; try { positionDoc = parseDocument(source, { lineCounter, keepSourceTokens: false }); } catch { positionDoc = null; } const lineOf = (path: (string | number)[]): number | undefined => { if (!positionDoc) return undefined; try { // For a mapping key (string last segment) return the KEY's line, not its // value node: a value that is a nested map/seq starts on the NEXT line, which // would push the marker one line down (off-by-one on any parent key). const last = path[path.length - 1]; if (typeof last === 'string') { const parent = path.length === 1 ? positionDoc.contents : positionDoc.getIn(path.slice(0, -1), true); if (isMap(parent)) { const pair = parent.items.find((it) => isScalar(it.key) && it.key.value === last); if (pair && isNode(pair.key) && pair.key.range) { return lineCounter.linePos(pair.key.range[0]).line; } } } const node = positionDoc.getIn(path, true); if (isNode(node) && node.range) { return lineCounter.linePos(node.range[0]).line; } } catch { // bad path -> no line } return undefined; }; // The 0-based indentation (column - 1) of the KEY at a mapping path, so an // insert-fix can match the file's own indentation width. undefined if unresolved. const indentOf = (path: (string | number)[]): number | undefined => { if (!positionDoc) return undefined; try { const last = path[path.length - 1]; if (typeof last !== 'string') return undefined; const parent = path.length === 1 ? positionDoc.contents : positionDoc.getIn(path.slice(0, -1), true); if (isMap(parent)) { const pair = parent.items.find((it) => isScalar(it.key) && it.key.value === last); if (pair && isNode(pair.key) && pair.key.range) { return lineCounter.linePos(pair.key.range[0]).col - 1; } } } catch { // bad path -> no indent } return undefined; }; // The 0-based column where the VALUE node at a path begins (for a scalar in a // sequence, the start of that item), so a replace-fix can anchor to the exact token // instead of the first substring match on the line. undefined if unresolved. const columnOf = (path: (string | number)[]): number | undefined => { if (!positionDoc) return undefined; try { const node = positionDoc.getIn(path, true); if (isNode(node) && node.range) { return lineCounter.linePos(node.range[0]).col - 1; } } catch { // bad path -> no column } return undefined; }; return { doc, parseError, lineOf, indentOf, columnOf }; }