diff --git a/graph-ui/src/browser-ai/file-outline.toml.test.ts b/graph-ui/src/browser-ai/file-outline.toml.test.ts new file mode 100644 index 000000000..0afae841c --- /dev/null +++ b/graph-ui/src/browser-ai/file-outline.toml.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { fileOutline } from './file-outline'; + +/** django's pyproject.toml, its first table and the start of [project]. */ +const PYPROJECT = `[build-system] +requires = ["setuptools>=75.8.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "Django" +requires-python = ">= 3.10" +dependencies = [ + "asgiref>=3.8.1", + "sqlparse>=0.3.1", + "tzdata; sys_platform == 'win32'", +] +authors = [ + {name = "Django Software Foundation", email = "foundation@djangoproject.com"}, +] +license = {text = "BSD-3-Clause"} +`; + +/** "was macht diese datei?" on pyproject.toml showed "dependencies [; authors [" for arrays over several lines. */ +describe('TOML arrays over several lines in the outline', () => { + it('lists the items of a multi-line array, and keeps commas inside an inline table', () => { + const outline = fileOutline('pyproject.toml', PYPROJECT, 'en')!; + expect(outline).toContain('- `[project]`: name `Django`; requires-python `>= 3.10`; dependencies: `asgiref>=3.8.1`, `sqlparse>=0.3.1`, `tzdata; sys_platform == \'win32\'`; ' + + 'authors: `{name = "Django Software Foundation", email = "foundation@djangoproject.com"}`; license `{text = "BSD-3-Clause"}`'); + expect(outline).not.toMatch(/dependencies \[|authors \[/); + expect(outline).toContain('- `[build-system]`: requires: `setuptools>=75.8.1`; build-backend `setuptools.build_meta`'); + }); +}); diff --git a/graph-ui/src/browser-ai/file-outline.ts b/graph-ui/src/browser-ai/file-outline.ts index 5ed60d04d..4328f49e8 100644 --- a/graph-ui/src/browser-ai/file-outline.ts +++ b/graph-ui/src/browser-ai/file-outline.ts @@ -29,17 +29,48 @@ function jsonValue(value: unknown): YamlValue { return { kind: 'scalar', value: String(value) }; } -/** TOML: the keys before the first table, each `[table]` with its keys, each `[[table]]` as a list item. */ +/** Brackets still open after `text`, outside strings and comments. */ +function openBrackets(text: string): number { + let depth = 0, quote: string | undefined; + for (let index = 0; index < text.length; index++) { + const character = text[index]; + if (quote) { if (character === '\\' && quote === '"') index++; else if (character === quote) quote = undefined; continue; } + if (character === '#') break; + if (character === '"' || character === "'") quote = character; + else if (character === '[' || character === '{') depth++; + else if (character === ']' || character === '}') depth--; + } + return depth; +} +/** The items of an array, split at its own commas, not at those inside strings or inline tables. */ +function arrayItems(inner: string): string[] { + const items: string[] = []; + let depth = 0, quote: string | undefined, start = 0; + for (let index = 0; index < inner.length; index++) { + const character = inner[index]; + if (quote) { if (character === '\\' && quote === '"') index++; else if (character === quote) quote = undefined; continue; } + if (character === '"' || character === "'") quote = character; + else if (character === '[' || character === '{') depth++; + else if (character === ']' || character === '}') depth--; + else if (character === ',' && depth === 0) { items.push(inner.slice(start, index)); start = index + 1; } + } + return [...items, inner.slice(start)].map(item => item.trim()).filter(Boolean); +} + +/** TOML: the keys before the first table, each `[table]` with its keys, each `[[table]]` as a list + * item. An array may run over several lines ("dependencies = [" with one item per line). */ function tomlTree(text: string): YamlValue | undefined { const root: [string, YamlValue][] = []; let current = root, quoted: string | undefined; + const unquote = (item: string) => item.replace(/^(["'])(.*)\1$/, '$2'); const value = (raw: string): YamlValue => { const plain = raw.replace(/\s+#[^"']*$/, '').trim(); - if (/^\[.*\]$/.test(plain)) return { kind: 'list', items: plain.slice(1, -1).split(',').map(item => item.trim().replace(/^(["'])(.*)\1$/, '$2')).filter(Boolean).map(item => ({ kind: 'scalar', value: item })) }; - return { kind: 'scalar', value: plain.replace(/^(["'])(.*)\1$/, '$2') }; + if (/^\[[\s\S]*\]$/.test(plain)) return { kind: 'list', items: arrayItems(plain.slice(1, -1)).map(item => ({ kind: 'scalar', value: unquote(item) })) }; + return { kind: 'scalar', value: unquote(plain) }; }; - for (const raw of text.split(/\r?\n/)) { - const line = raw.trim(); + const lines = text.split(/\r?\n/); + for (let at = 0; at < lines.length; at++) { + const line = lines[at].trim(); if (quoted) { if (line.split(quoted).length % 2 === 0) quoted = undefined; continue; } if (!line || line.startsWith('#')) continue; const table = /^(\[\[?)\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line); @@ -57,7 +88,10 @@ function tomlTree(text: string): YamlValue | undefined { if (!key) continue; const delimiter = ['"""', "'''"].find(mark => line.split(mark).length === 2); if (delimiter) quoted = delimiter; - current.push([key[1], delimiter ? { kind: 'scalar', value: '' } : value(key[2])]); + let raw = key[2]; + // The lines an open array or inline table runs over, without their comments. + while (!delimiter && openBrackets(raw) > 0 && at + 1 < lines.length) raw += ` ${lines[++at].trim().replace(/\s+#[^"']*$/, '')}`; + current.push([key[1], delimiter ? { kind: 'scalar', value: '' } : value(raw)]); } return root.length ? { kind: 'map', entries: root } : undefined; }