mirror of https://github.com/garrytan/gstack.git
239 lines
7.5 KiB
TypeScript
239 lines
7.5 KiB
TypeScript
/**
|
|
* Minimal YAML subset parser for gstack lens metadata and project policy files.
|
|
*
|
|
* Supported syntax:
|
|
* - mappings by indentation
|
|
* - scalar sequences (`- value`)
|
|
* - inline arrays (`[a, "b", c]`)
|
|
* - quoted and unquoted strings, booleans, numbers, and null
|
|
*
|
|
* Deliberately unsupported:
|
|
* - anchors, aliases, tags, block scalars, flow mappings, and multi-document YAML
|
|
*
|
|
* The lens contract does not need the full YAML language. Keeping the parser
|
|
* constrained makes the accepted configuration surface explicit and testable.
|
|
*/
|
|
|
|
export type YamlScalar = string | number | boolean | null;
|
|
export type YamlValue = YamlScalar | YamlValue[] | { [key: string]: YamlValue };
|
|
export type YamlObject = { [key: string]: YamlValue };
|
|
|
|
interface ParsedLine {
|
|
indent: number;
|
|
text: string;
|
|
line: number;
|
|
}
|
|
|
|
function stripComment(raw: string): string {
|
|
let quote: '"' | "'" | null = null;
|
|
let escaped = false;
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const ch = raw[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === '\\' && quote === '"') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (ch === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === '#' && (i === 0 || /\s/.test(raw[i - 1]))) {
|
|
return raw.slice(0, i).trimEnd();
|
|
}
|
|
}
|
|
return raw.trimEnd();
|
|
}
|
|
|
|
function splitInlineArray(input: string): string[] {
|
|
const values: string[] = [];
|
|
let quote: '"' | "'" | null = null;
|
|
let escaped = false;
|
|
let start = 0;
|
|
for (let i = 0; i < input.length; i++) {
|
|
const ch = input[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === '\\' && quote === '"') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (ch === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === ',') {
|
|
values.push(input.slice(start, i).trim());
|
|
start = i + 1;
|
|
}
|
|
}
|
|
values.push(input.slice(start).trim());
|
|
return values.filter((value) => value.length > 0);
|
|
}
|
|
|
|
function parseQuoted(input: string): string {
|
|
if (input.startsWith('"')) {
|
|
try {
|
|
return JSON.parse(input);
|
|
} catch (error) {
|
|
throw new Error(`Invalid double-quoted YAML string: ${input} (${String(error)})`);
|
|
}
|
|
}
|
|
return input.slice(1, -1).replace(/''/g, "'");
|
|
}
|
|
|
|
export function parseYamlScalar(input: string): YamlValue {
|
|
const value = input.trim();
|
|
if (value === '') return '';
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
return parseQuoted(value);
|
|
}
|
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
return splitInlineArray(value.slice(1, -1)).map(parseYamlScalar);
|
|
}
|
|
if (value === 'true') return true;
|
|
if (value === 'false') return false;
|
|
if (value === 'null' || value === '~') return null;
|
|
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) return Number(value);
|
|
return value;
|
|
}
|
|
|
|
function tokenize(input: string): ParsedLine[] {
|
|
const lines: ParsedLine[] = [];
|
|
input.split(/\r?\n/).forEach((raw, index) => {
|
|
if (/\t/.test(raw.slice(0, raw.length - raw.trimStart().length))) {
|
|
throw new Error(`Tabs are not allowed for YAML indentation (line ${index + 1})`);
|
|
}
|
|
const noComment = stripComment(raw);
|
|
if (noComment.trim() === '') return;
|
|
const indent = noComment.length - noComment.trimStart().length;
|
|
if (indent % 2 !== 0) {
|
|
throw new Error(`YAML indentation must use multiples of two spaces (line ${index + 1})`);
|
|
}
|
|
lines.push({ indent, text: noComment.trim(), line: index + 1 });
|
|
});
|
|
return lines;
|
|
}
|
|
|
|
function parseSequence(lines: ParsedLine[], start: number, indent: number): [YamlValue[], number] {
|
|
const result: YamlValue[] = [];
|
|
let index = start;
|
|
while (index < lines.length) {
|
|
const line = lines[index];
|
|
if (line.indent < indent) break;
|
|
if (line.indent !== indent || !line.text.startsWith('-')) break;
|
|
const rest = line.text.slice(1).trim();
|
|
if (rest === '') {
|
|
const next = lines[index + 1];
|
|
if (!next || next.indent <= indent) {
|
|
result.push(null);
|
|
index += 1;
|
|
} else {
|
|
const [child, nextIndex] = parseBlock(lines, index + 1, next.indent);
|
|
result.push(child);
|
|
index = nextIndex;
|
|
}
|
|
continue;
|
|
}
|
|
// The lens configuration intentionally represents structured trigger entries
|
|
// as strings such as `pr_label=privileged-surface`. Reject sequence mappings
|
|
// rather than silently accepting a YAML shape the runtime cannot validate.
|
|
if (/^[A-Za-z0-9_.-]+:\s*/.test(rest)) {
|
|
throw new Error(`Sequence mappings are not supported by the lens YAML subset (line ${line.line})`);
|
|
}
|
|
result.push(parseYamlScalar(rest));
|
|
index += 1;
|
|
}
|
|
return [result, index];
|
|
}
|
|
|
|
function parseMapping(lines: ParsedLine[], start: number, indent: number): [YamlObject, number] {
|
|
const result: YamlObject = {};
|
|
let index = start;
|
|
while (index < lines.length) {
|
|
const line = lines[index];
|
|
if (line.indent < indent) break;
|
|
if (line.indent !== indent) {
|
|
throw new Error(`Unexpected indentation at line ${line.line}`);
|
|
}
|
|
if (line.text.startsWith('-')) break;
|
|
const match = line.text.match(/^([A-Za-z0-9_.-]+):(?:\s+(.*))?$/);
|
|
if (!match) throw new Error(`Invalid YAML mapping at line ${line.line}: ${line.text}`);
|
|
const [, key, rest] = match;
|
|
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
throw new Error(`Duplicate YAML key '${key}' at line ${line.line}`);
|
|
}
|
|
if (rest !== undefined) {
|
|
result[key] = parseYamlScalar(rest);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const next = lines[index + 1];
|
|
if (!next || next.indent <= indent) {
|
|
result[key] = {};
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const [child, nextIndex] = parseBlock(lines, index + 1, next.indent);
|
|
result[key] = child;
|
|
index = nextIndex;
|
|
}
|
|
return [result, index];
|
|
}
|
|
|
|
function parseBlock(lines: ParsedLine[], start: number, indent: number): [YamlValue, number] {
|
|
if (start >= lines.length) return [{}, start];
|
|
const first = lines[start];
|
|
if (first.indent !== indent) {
|
|
throw new Error(`Expected indentation ${indent}, found ${first.indent} at line ${first.line}`);
|
|
}
|
|
return first.text.startsWith('-')
|
|
? parseSequence(lines, start, indent)
|
|
: parseMapping(lines, start, indent);
|
|
}
|
|
|
|
export function parseYamlSubset(input: string): YamlObject {
|
|
const lines = tokenize(input);
|
|
if (lines.length === 0) return {};
|
|
if (lines[0].indent !== 0) throw new Error(`Top-level YAML must start at column 1 (line ${lines[0].line})`);
|
|
const [value, next] = parseBlock(lines, 0, 0);
|
|
if (next !== lines.length) {
|
|
throw new Error(`Could not parse YAML at line ${lines[next].line}`);
|
|
}
|
|
if (Array.isArray(value) || value === null || typeof value !== 'object') {
|
|
throw new Error('Top-level YAML must be a mapping');
|
|
}
|
|
return value as YamlObject;
|
|
}
|
|
|
|
export interface FrontmatterDocument {
|
|
frontmatter: YamlObject;
|
|
body: string;
|
|
}
|
|
|
|
export function parseFrontmatterDocument(content: string): FrontmatterDocument {
|
|
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
|
|
throw new Error('Lens file must begin with YAML frontmatter');
|
|
}
|
|
const normalized = content.replace(/\r\n/g, '\n');
|
|
const end = normalized.indexOf('\n---\n', 4);
|
|
if (end < 0) throw new Error('Lens file frontmatter is missing a closing --- marker');
|
|
return {
|
|
frontmatter: parseYamlSubset(normalized.slice(4, end)),
|
|
body: normalized.slice(end + 5).trim(),
|
|
};
|
|
}
|