mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +08:00
fix(installer): the Codex table is found however its config.toml writes it (#2771)
Root cause: the TOML helpers behind Codex's install, refresh and uninstall
found CodeGraph's table with two literal searches, `[mcp_servers.codegraph]`
at the start of the file or right after a newline, and detection used a
plain substring check. Some valid ways of writing that table went unseen:
- an indented header, which formatters that indent sub-tables write;
- spaces inside the brackets or around the dot;
- quoted or escaped keys.
Install then appended a second `[mcp_servers.codegraph]`. A table defined
twice is invalid TOML, so Codex loaded none of its config. Install and
refresh also appended a table after a definition written with dotted keys
or as an inline table, or under an `mcp_servers` that is an inline table or
an array of tables (both invalid too). The literal search also matched
header-shaped text inside a multiline string and spliced into the string.
Uninstall left the user's original table, a `[mcp_servers.codegraph.env]`
sub-table (a server with no command), and dotted or inline definitions in
place. CRLF files came back with mixed line endings.
Fix: a small structure scan of the file finds every statement (table
header, array-of-tables header, key/value). It decodes key paths the way
TOML reads them (bare, "basic" with escapes, 'literal', spaces around
dots), tracks the table each key sits in, and folds the lines of a value
that spans several into the key that opened it. On top of that scan:
- The `[mcp_servers.codegraph]` table is found however its header line is
written. Install and refresh replace the first one in place and remove
any further copies, so a file the old bug broke parses again.
Sub-tables such as `.env` are kept.
- A block ends at its last key, so a comment heading the next table stays
with that table.
- Uninstall removes the table, every copy, its sub-tables, and
definitions written with dotted keys or as an inline table, as whole
statements.
- An entry the user wrote with dotted keys or as an inline table is left as
written, with a note.
- Where a table can't be added (`mcp_servers` set as an inline table, a
value or an array of tables), install refuses with a clear error and
changes nothing.
- An entry inside an inline `mcp_servers` table is reported by uninstall,
never edited. The uninstaller now prints such notes.
- Everything is written in the file's own line breaks, and the existing
block is compared regardless of them.
Validation:
- 25 new tests in installer-targets.test.ts:
- 20 on the TOML helpers: 8 header forms, CRLF, the old bug's duplicate,
look-alikes (other names, header-shaped text inside strings and
arrays), sub-tables, comments, 4 dotted/inline/array definitions, 2
shapes where a table can't be added, an entry inside an inline table;
- 5 on Codex install, re-install, refresh and uninstall end to end.
With main's toml.ts, codex.ts and installer index.ts put back, all 25
fail: 24 on the behavior itself, and one, a header with a trailing
comment, only on the new detection helper, since main already handled
that form. The 13 existing TOML tests pass on both.
- The real CLI on this Windows machine against private homes: `install
--yes`, a second `install --yes`, `install --refresh` and `uninstall
--yes --keep-cli`, for 23 config.toml shapes, each result parsed with
Python's strict tomllib.
- main (0abae119): 46 of 92 results were invalid TOML (15 of 23 right
after install), the entry was still there after uninstall in 16
shapes, and 8 results had mixed line endings.
- The fix: 0 invalid and no mixed line endings. The entry remains after
uninstall only inside an inline `mcp_servers` table, which uninstall
reports. Install refuses with exit 1 and an unchanged file for the two
shapes where a table can't be added.
Fixes #2562
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6bd217a263
commit
0de94978fc
@@ -37,6 +37,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- A file is no longer saved with no symbols when its language parser can't be loaded, which is what happened to every file a background server re-indexed after an upgrade removed its install: the file keeps what it had and is indexed again once the parser loads, and files an earlier version emptied this way are re-indexed by the next sync. A background server also exits on its own once its install is upgraded or removed, so the next session starts one from the current install. Thanks @lipchey for the report. (#2335)
|
||||
- `codegraph upgrade` no longer wipes your agent instructions file, such as a global `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, when it refreshes CodeGraph's section in it: a stray `<!-- CODEGRAPH_START -->` line above that section, like one left behind when part of an older section was deleted by hand, made the refresh replace everything in between, often the whole file. CodeGraph now rewrites only its own section, in `codegraph install` and `codegraph uninstall` too, and keeps everything around it exactly as it was, Windows line endings included. Thanks @pzaj2 for the report. (#2506)
|
||||
- `codegraph install`, `upgrade` and `uninstall` now write through an instructions or config file that is a symbolic link, such as a `~/.codex/AGENTS.md` or `~/.claude.json` linked from your dotfiles, instead of replacing the link with a copy: the shared file gets CodeGraph's settings, uninstall takes them out again, and the link stays in place. A link CodeGraph can't write through, like one to a read-only file or into a folder that doesn't exist, is now reported by name and left alone, and an upgrade still refreshes your other agents. Thanks @jguerra6 for the report and @danusha2345 for the first fix. (#2721)
|
||||
- `codegraph install` and `upgrade` no longer break Codex's `config.toml` when CodeGraph's `[mcp_servers.codegraph]` entry is written differently from how CodeGraph writes it, such as indented, with spaces or quotes inside the brackets, or with Windows line endings: the entry is now updated in place, where before a second copy was added and Codex could load none of the file. A file already broken that way is repaired on the next install or upgrade, an example of the entry inside a multiline setting such as `developer_instructions` is no longer mistaken for it, uninstall removes the entry however it is written, and an entry you wrote with dotted keys or as an inline table is left as you wrote it. When your `mcp_servers` settings are one inline table, CodeGraph now says it can't add itself instead of breaking the file. Thanks @dencoseca for the report, and @danusha2345 and @rudycelekli for the first fixes. (#2562)
|
||||
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
|
||||
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
|
||||
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
|
||||
|
||||
@@ -22,7 +22,7 @@ import { spawnSync } from 'child_process';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
|
||||
import { uninstallTargets, refreshTargets } from '../src/installer';
|
||||
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
|
||||
import { upsertTomlTable, removeTomlTable, buildTomlTable, tomlDefinesTable } from '../src/installer/targets/toml';
|
||||
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
|
||||
import { replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
|
||||
import {
|
||||
@@ -1619,6 +1619,237 @@ describe('Installer targets — TOML serializer (Codex backbone)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Codex table is found however it is written (#2562)
|
||||
//
|
||||
// The header search matched only a column-0 `[mcp_servers.codegraph]`, so an
|
||||
// indented one (valid TOML, and what formatters that indent sub-tables
|
||||
// write) was taken for absent and a second table was appended: a table
|
||||
// defined twice is invalid TOML, and Codex then loaded none of its config.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Installer — the Codex table is found however it is written (#2562)', () => {
|
||||
const HEADER = 'mcp_servers.codegraph';
|
||||
const BLOCK = buildTomlTable(HEADER, { command: 'codegraph', args: ['serve', '--mcp'] });
|
||||
const OLD_BODY = 'command = "codegraph"\nargs = ["serve"]';
|
||||
const BS = String.fromCharCode(92);
|
||||
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
|
||||
const top = 'model = "o3"';
|
||||
const other = '[mcp_servers.other]\ncommand = "other-server"';
|
||||
|
||||
it.each([
|
||||
['indented with spaces (the report)', ' [mcp_servers.codegraph]', ' command = "codegraph"\n args = ["serve"]'],
|
||||
['indented with a tab', '\t[mcp_servers.codegraph]', OLD_BODY],
|
||||
['spaces inside the brackets', '[ mcp_servers.codegraph ]', OLD_BODY],
|
||||
['spaces around the dot', '[mcp_servers . codegraph]', OLD_BODY],
|
||||
['a quoted key', '[mcp_servers."codegraph"]', OLD_BODY],
|
||||
['a literal-quoted key', "['mcp_servers'.codegraph]", OLD_BODY],
|
||||
['an escape in a quoted key', `[mcp_servers."code${BS}u0067raph"]`, OLD_BODY],
|
||||
['a comment after it', '[mcp_servers.codegraph] # added by hand', OLD_BODY],
|
||||
])('a header written with %s is replaced in place, once, and removed', (_form, header, body) => {
|
||||
const input = `${top}\n\n[mcp_servers]\n\n${header}\n${body}\n\n${other}\n`;
|
||||
|
||||
const up = upsertTomlTable(input, HEADER, BLOCK);
|
||||
expect(up.action).toBe('replaced');
|
||||
expect(up.content).toBe(`${top}\n\n[mcp_servers]\n\n${BLOCK}\n\n${other}\n`);
|
||||
expect(upsertTomlTable(up.content, HEADER, BLOCK).action).toBe('unchanged');
|
||||
|
||||
const rm = removeTomlTable(input, HEADER);
|
||||
expect(rm.action).toBe('removed');
|
||||
expect(rm.content).toBe(`${top}\n\n[mcp_servers]\n\n${other}\n`);
|
||||
expect(tomlDefinesTable(input, HEADER)).toBe(true);
|
||||
});
|
||||
|
||||
it('a CRLF file stays CRLF, and an up-to-date CRLF table is unchanged', () => {
|
||||
const input = crlf(`${top}\n\n[mcp_servers.codegraph]\n${OLD_BODY}\n\n${other}\n`);
|
||||
const up = upsertTomlTable(input, HEADER, BLOCK);
|
||||
expect(up.content).toBe(crlf(`${top}\n\n${BLOCK}\n\n${other}\n`));
|
||||
expect(upsertTomlTable(up.content, HEADER, BLOCK).action).toBe('unchanged');
|
||||
expect(removeTomlTable(up.content, HEADER).content).toBe(crlf(`${top}\n\n${other}\n`));
|
||||
// Inserting into a CRLF file uses CRLF too.
|
||||
expect(upsertTomlTable(crlf(`${top}\n`), HEADER, BLOCK).content).toBe(crlf(`${top}\n\n${BLOCK}\n`));
|
||||
});
|
||||
|
||||
it('a second table, as the old bug appended, is removed: the file parses again', () => {
|
||||
const input = `${top}\n\n [mcp_servers.codegraph]\n command = "codegraph"\n args = ["serve"]\n\n${other}\n\n${BLOCK}\n`;
|
||||
const up = upsertTomlTable(input, HEADER, BLOCK);
|
||||
expect(up.action).toBe('replaced');
|
||||
expect(up.content).toBe(`${top}\n\n${BLOCK}\n\n${other}\n`);
|
||||
expect(removeTomlTable(input, HEADER).content).toBe(`${top}\n\n${other}\n`);
|
||||
});
|
||||
|
||||
it('look-alikes are never taken for the table: other names, and header-shaped text inside values', () => {
|
||||
const lookalikes = [
|
||||
top,
|
||||
'',
|
||||
'[mcp_servers.codegraph2]',
|
||||
'command = "x"',
|
||||
'',
|
||||
'[mcp_servers.other]',
|
||||
'note = """',
|
||||
'[mcp_servers.codegraph]',
|
||||
'"""',
|
||||
"literal = '''",
|
||||
'[mcp_servers.codegraph]',
|
||||
"'''",
|
||||
'args = [',
|
||||
' "[mcp_servers.codegraph]",',
|
||||
']',
|
||||
'',
|
||||
].join('\n');
|
||||
const up = upsertTomlTable(lookalikes, HEADER, BLOCK);
|
||||
expect(up.action).toBe('inserted');
|
||||
expect(up.content).toBe(`${lookalikes.trimEnd()}\n\n${BLOCK}\n`);
|
||||
expect(removeTomlTable(lookalikes, HEADER)).toEqual({ content: lookalikes, action: 'not-found' });
|
||||
expect(tomlDefinesTable(lookalikes, HEADER)).toBe(false);
|
||||
});
|
||||
|
||||
it('a sub-table such as [mcp_servers.codegraph.env] is kept by install and removed with the table', () => {
|
||||
const env = '[mcp_servers.codegraph.env]\nCODEGRAPH_MCP_TOOLS = "all"';
|
||||
const input = `${top}\n\n[mcp_servers.codegraph]\n${OLD_BODY}\n\n${env}\n\n${other}\n`;
|
||||
expect(upsertTomlTable(input, HEADER, BLOCK).content).toBe(`${top}\n\n${BLOCK}\n\n${env}\n\n${other}\n`);
|
||||
expect(removeTomlTable(input, HEADER).content).toBe(`${top}\n\n${other}\n`);
|
||||
// Only the sub-table left (a server with no command): install adds the table.
|
||||
expect(tomlDefinesTable(`${top}\n\n${env}\n`, HEADER)).toBe(true);
|
||||
expect(upsertTomlTable(`${top}\n\n${env}\n`, HEADER, BLOCK).content).toBe(`${top}\n\n${env}\n\n${BLOCK}\n`);
|
||||
});
|
||||
|
||||
it('a comment heading the next table stays with it', () => {
|
||||
const input = `${top}\n\n[mcp_servers.codegraph]\n${OLD_BODY}\n\n# the other server, set up by hand\n${other}\n`;
|
||||
expect(upsertTomlTable(input, HEADER, BLOCK).content).toBe(`${top}\n\n${BLOCK}\n\n# the other server, set up by hand\n${other}\n`);
|
||||
expect(removeTomlTable(input, HEADER).content).toBe(`${top}\n\n# the other server, set up by hand\n${other}\n`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dotted keys at the top level', `mcp_servers.codegraph.command = "codegraph"\nmcp_servers.codegraph.args = ["serve", "--mcp"]\n${top}\n\n${other}\n`, 1, 'dotted keys', `${top}\n\n${other}\n`],
|
||||
['dotted keys under [mcp_servers]', `${top}\n\n[mcp_servers]\ncodegraph.command = "codegraph"\ncodegraph.args = [\n "serve",\n "--mcp",\n]\nother.command = "x"\n`, 4, 'dotted keys', `${top}\n\n[mcp_servers]\nother.command = "x"\n`],
|
||||
['an inline table under [mcp_servers]', `${top}\n\n[mcp_servers]\ncodegraph = { command = "codegraph", args = ["serve", "--mcp"] }\n`, 4, 'an inline table', `${top}\n\n[mcp_servers]\n`],
|
||||
['[[mcp_servers.codegraph]]', `${top}\n\n[[mcp_servers.codegraph]]\ncommand = "codegraph"\n`, 3, 'an array of tables', `${top}\n`],
|
||||
])('written with %s: install leaves it as is (no second table), uninstall removes it', (_form, input, line, form, removed) => {
|
||||
expect(upsertTomlTable(input, HEADER, BLOCK)).toEqual({
|
||||
content: input,
|
||||
action: 'conflict',
|
||||
conflict: expect.objectContaining({ kind: 'defined', line, form }),
|
||||
});
|
||||
const rm = removeTomlTable(input, HEADER);
|
||||
expect(rm.action).toBe('removed');
|
||||
expect(rm.content).toBe(removed);
|
||||
expect(tomlDefinesTable(input, HEADER)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mcp_servers as an inline table', `${top}\nmcp_servers = { other = { command = "x" } }\n`, 'an inline table'],
|
||||
['mcp_servers as an array of tables', `${top}\n\n[[mcp_servers]]\nname = "other"\n`, 'an array of tables'],
|
||||
])('%s: a [mcp_servers.codegraph] table can\'t be added, so nothing is', (_form, input, form) => {
|
||||
expect(upsertTomlTable(input, HEADER, BLOCK)).toEqual({
|
||||
content: input,
|
||||
action: 'conflict',
|
||||
conflict: expect.objectContaining({ kind: 'blocked', key: 'mcp_servers', form }),
|
||||
});
|
||||
expect(removeTomlTable(input, HEADER).action).toBe('not-found');
|
||||
expect(tomlDefinesTable(input, HEADER)).toBe(false);
|
||||
});
|
||||
|
||||
it('codegraph inside an inline mcp_servers table is reported, never edited', () => {
|
||||
const input = `${top}\nmcp_servers = { codegraph = { command = "codegraph", args = ["serve", "--mcp"] } }\n`;
|
||||
expect(upsertTomlTable(input, HEADER, BLOCK).action).toBe('conflict');
|
||||
expect(removeTomlTable(input, HEADER)).toEqual({
|
||||
content: input,
|
||||
action: 'conflict',
|
||||
conflict: expect.objectContaining({ key: 'mcp_servers', form: 'an inline table', line: 2 }),
|
||||
});
|
||||
expect(tomlDefinesTable(input, HEADER)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer — Codex install, re-install, refresh and uninstall find the table however it is written (#2562)', () => {
|
||||
let tmpHome: string;
|
||||
let tmpCwd: string;
|
||||
let origCwd: string;
|
||||
let homeRestore: { restore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmpDir('codex2562-home');
|
||||
tmpCwd = mkTmpDir('codex2562-cwd');
|
||||
origCwd = process.cwd();
|
||||
process.chdir(tmpCwd);
|
||||
homeRestore = setHome(tmpHome);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeRestore.restore();
|
||||
process.chdir(origCwd);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const configToml = () => path.join(tmpHome, '.codex', 'config.toml');
|
||||
function writeConfig(text: string): void {
|
||||
fs.mkdirSync(path.dirname(configToml()), { recursive: true });
|
||||
fs.writeFileSync(configToml(), text);
|
||||
}
|
||||
const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve', '--mcp'] });
|
||||
|
||||
it('the reported indented table: detected, replaced once, unchanged after, refreshed in place, removed by uninstall', () => {
|
||||
const siblings = 'model = "o3"\n\n[mcp_servers]\n\n [mcp_servers.other]\n command = "other-server"\n';
|
||||
writeConfig(`${siblings}\n [mcp_servers.codegraph]\n command = "codegraph"\n args = ["serve", "--mcp"]\n`);
|
||||
const codex = getTarget('codex')!;
|
||||
expect(codex.detect('global').alreadyConfigured).toBe(true);
|
||||
|
||||
const first = codex.install('global', { autoAllow: false });
|
||||
expect(first.files.find((f) => f.path === configToml())!.action).toBe('updated');
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(`${siblings}\n${block}\n`);
|
||||
expect(codex.install('global', { autoAllow: false }).files.find((f) => f.path === configToml())!.action).toBe('unchanged');
|
||||
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'codex')!.status).toBe('unchanged');
|
||||
|
||||
codex.uninstall('global');
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(siblings);
|
||||
expect(codex.detect('global').alreadyConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('a CRLF config keeps CRLF through install and uninstall', () => {
|
||||
writeConfig('model = "o3"\r\n\r\n[mcp_servers.codegraph]\r\ncommand = "codegraph"\r\nargs = ["serve"]\r\n');
|
||||
const codex = getTarget('codex')!;
|
||||
codex.install('global', { autoAllow: false });
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(`model = "o3"\r\n\r\n${block.replace(/\n/g, '\r\n')}\r\n`);
|
||||
codex.uninstall('global');
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe('model = "o3"\r\n');
|
||||
});
|
||||
|
||||
it('an entry written with dotted keys is left as written, with a note; uninstall removes it', () => {
|
||||
const input = 'model = "o3"\n\n[mcp_servers]\ncodegraph.command = "codegraph"\ncodegraph.args = ["serve", "--mcp"]\n';
|
||||
writeConfig(input);
|
||||
const codex = getTarget('codex')!;
|
||||
expect(codex.detect('global').alreadyConfigured).toBe(true);
|
||||
|
||||
const result = codex.install('global', { autoAllow: false });
|
||||
expect(result.files.find((f) => f.path === configToml())!.action).toBe('unchanged');
|
||||
expect(result.notes?.join('\n')).toMatch(/Left the mcp_servers\.codegraph entry in .*config\.toml as you wrote it \(dotted keys, line 4\)/);
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(input);
|
||||
expect(fs.readFileSync(path.join(tmpHome, '.codex', 'AGENTS.md'), 'utf-8')).toContain('codegraph explore');
|
||||
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'codex')!.status).toBe('unchanged');
|
||||
|
||||
codex.uninstall('global');
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe('model = "o3"\n\n[mcp_servers]\n');
|
||||
});
|
||||
|
||||
it('mcp_servers as an inline table: install refuses with a clear error and changes nothing', () => {
|
||||
const input = 'model = "o3"\nmcp_servers = { other = { command = "x" } }\n';
|
||||
writeConfig(input);
|
||||
expect(() => getTarget('codex')!.install('global', { autoAllow: false })).toThrow(
|
||||
/config\.toml sets mcp_servers as an inline table \(line 2\), so a \[mcp_servers\.codegraph\] table can't be added without breaking the file\. Nothing was changed/,
|
||||
);
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(input);
|
||||
});
|
||||
|
||||
it('codegraph inside an inline mcp_servers table: uninstall says it can\'t remove it, and leaves the file alone', () => {
|
||||
const input = 'model = "o3"\nmcp_servers = { codegraph = { command = "codegraph", args = ["serve", "--mcp"] } }\n';
|
||||
writeConfig(input);
|
||||
const [report] = uninstallTargets([getTarget('codex')!], 'global');
|
||||
expect(report!.notes.join('\n')).toMatch(/sets mcp_servers\.codegraph inside mcp_servers \(an inline table, line 2\), which CodeGraph can't edit safely/);
|
||||
expect(fs.readFileSync(configToml(), 'utf-8')).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => {
|
||||
let tmpHome: string;
|
||||
let tmpCwd: string;
|
||||
|
||||
@@ -517,10 +517,14 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
|
||||
clack.log.success(`${r.displayName}: removed ${tildify(p)}`);
|
||||
}
|
||||
} else if (r.status === 'not-configured') {
|
||||
clack.log.info(`${r.displayName}: not configured — nothing to remove`);
|
||||
clack.log.info(r.notes.length > 0 ? `${r.displayName}: nothing removed` : `${r.displayName}: not configured — nothing to remove`);
|
||||
} else {
|
||||
clack.log.info(`${r.displayName}: skipped — ${r.notes[0] ?? 'unsupported location'}`);
|
||||
continue;
|
||||
}
|
||||
// What a target couldn't remove on its own (e.g. a Codex entry inside
|
||||
// an inline table, #2562).
|
||||
for (const note of r.notes) clack.log.warn(`${r.displayName}: ${note}`);
|
||||
}
|
||||
|
||||
// Step 4: for local uninstall, the index dir is separate — point at
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
CODEGRAPH_SECTION_END,
|
||||
CODEGRAPH_SECTION_START,
|
||||
} from '../instructions-template';
|
||||
import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml';
|
||||
import { buildTomlTable, removeTomlTable, tomlDefinesTable, tomlLineBreak, upsertTomlTable } from './toml';
|
||||
|
||||
const TOML_HEADER = 'mcp_servers.codegraph';
|
||||
|
||||
@@ -103,7 +103,9 @@ class CodexTarget implements AgentTarget {
|
||||
if (fs.existsSync(tomlPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(tomlPath, 'utf-8');
|
||||
alreadyConfigured = content.includes(`[${TOML_HEADER}]`);
|
||||
// However the table is written — an indented or quoted header,
|
||||
// dotted keys, an inline table (#2562).
|
||||
alreadyConfigured = tomlDefinesTable(content, TOML_HEADER);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Global: ~/.codex/ existing means Codex has run here. Local: the
|
||||
@@ -115,33 +117,42 @@ class CodexTarget implements AgentTarget {
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
const files: WriteResult['files'] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
files.push(writeMcpEntry(loc));
|
||||
const mcp = writeMcpEntry(loc);
|
||||
files.push(mcp.file);
|
||||
if (mcp.note) notes.push(mcp.note);
|
||||
|
||||
// AGENTS.md gets the short marker-fenced CodeGraph block (#704):
|
||||
// subagents and non-MCP harnesses read AGENTS.md but never the MCP
|
||||
// initialize instructions. Upsert self-heals a stale pre-#529 block.
|
||||
files.push(upsertInstructionsEntry(instructionsPath(loc)));
|
||||
|
||||
return loc === 'local' ? { files, notes: [trustNote()] } : { files };
|
||||
if (loc === 'local') notes.push(trustNote());
|
||||
return notes.length > 0 ? { files, notes } : { files };
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
const files: WriteResult['files'] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const tomlPath = tomlConfigPath(loc);
|
||||
if (fs.existsSync(tomlPath)) {
|
||||
const content = fs.readFileSync(tomlPath, 'utf-8');
|
||||
const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER);
|
||||
if (action === 'removed') {
|
||||
if (nextContent.trim() === '') {
|
||||
const result = removeTomlTable(content, TOML_HEADER);
|
||||
if (result.action === 'removed') {
|
||||
if (result.content.trim() === '') {
|
||||
removeFileUnlessLinked(tomlPath, '');
|
||||
} else {
|
||||
atomicWriteFileSync(tomlPath, nextContent.trimEnd() + '\n');
|
||||
atomicWriteFileSync(tomlPath, result.content.trimEnd() + tomlLineBreak(content));
|
||||
}
|
||||
files.push({ path: tomlPath, action: 'removed' });
|
||||
} else {
|
||||
files.push({ path: tomlPath, action: 'not-found' });
|
||||
if (result.action === 'conflict') {
|
||||
const { conflict } = result;
|
||||
notes.push(`${tomlPath} sets ${TOML_HEADER} inside ${conflict.key} (${conflict.form}, line ${conflict.line}), which CodeGraph can't edit safely. Remove it from there yourself.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
files.push({ path: tomlPath, action: 'not-found' });
|
||||
@@ -149,7 +160,7 @@ class CodexTarget implements AgentTarget {
|
||||
|
||||
files.push(removeInstructionsEntry(loc));
|
||||
|
||||
return { files };
|
||||
return notes.length > 0 ? { files, notes } : { files };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
@@ -170,7 +181,7 @@ function buildCodegraphBlock(): string {
|
||||
});
|
||||
}
|
||||
|
||||
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
|
||||
function writeMcpEntry(loc: Location): { file: WriteResult['files'][number]; note?: string } {
|
||||
const file = tomlConfigPath(loc);
|
||||
const dir = path.dirname(file);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -181,13 +192,30 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] {
|
||||
// between two `fs.existsSync` calls.
|
||||
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const created = existing.length === 0;
|
||||
const { content: nextContent, action } = upsertTomlTable(existing, TOML_HEADER, block);
|
||||
const result = upsertTomlTable(existing, TOML_HEADER, block);
|
||||
|
||||
if (action === 'unchanged') {
|
||||
return { path: file, action: 'unchanged' };
|
||||
if (result.action === 'conflict') {
|
||||
// Never append a second definition: a table defined twice makes the
|
||||
// whole file invalid TOML, and Codex then loads none of it (#2562).
|
||||
const { conflict } = result;
|
||||
if (conflict.kind === 'defined') {
|
||||
// The user's own entry, in a form CodeGraph doesn't rewrite: it
|
||||
// already configures Codex, so it is left exactly as written.
|
||||
return {
|
||||
file: { path: file, action: 'unchanged' },
|
||||
note: `Left the ${TOML_HEADER} entry in ${file} as you wrote it (${conflict.form}, line ${conflict.line}): CodeGraph only rewrites a [${TOML_HEADER}] table.`,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`${file} sets ${conflict.key} as ${conflict.form} (line ${conflict.line}), so a [${TOML_HEADER}] table can't be added without breaking the file. ` +
|
||||
`Nothing was changed: add CodeGraph's server to ${conflict.key} yourself, or rewrite it as [${conflict.key}.<name>] tables, then run this again.`,
|
||||
);
|
||||
}
|
||||
atomicWriteFileSync(file, nextContent);
|
||||
return { path: file, action: created ? 'created' : 'updated' };
|
||||
if (result.action === 'unchanged') {
|
||||
return { file: { path: file, action: 'unchanged' } };
|
||||
}
|
||||
atomicWriteFileSync(file, result.content);
|
||||
return { file: { path: file, action: created ? 'created' : 'updated' } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+336
-68
@@ -5,11 +5,13 @@
|
||||
* general TOML parser/serializer; that would mean pulling in a
|
||||
* dependency (~50KB) for ~6 lines of output.
|
||||
*
|
||||
* Strategy: treat the file as text. Find the `[mcp_servers.codegraph]`
|
||||
* header line, splice it (and the lines that follow it until the next
|
||||
* `[...]` / `[[...]]` header or EOF) in or out. A small lexical scan keeps
|
||||
* header-shaped text inside multiline values out of the boundary search.
|
||||
* Everything outside that block is preserved verbatim, byte-for-byte.
|
||||
* Strategy: treat the file as text. A small structural scan finds each
|
||||
* statement — table header, array-of-tables header, key/value — with its
|
||||
* key path decoded the way TOML reads it, and skips the lines of a value
|
||||
* that spans several (a multiline string, array or inline table). The
|
||||
* `[mcp_servers.codegraph]` block (its header line and the lines that follow
|
||||
* until the next header or EOF) is spliced in or out. Everything outside
|
||||
* that block is preserved verbatim, byte-for-byte.
|
||||
*
|
||||
* Limitations (acceptable for our narrow use):
|
||||
* - Only writes a top-level table header. Array-of-tables and sibling
|
||||
@@ -18,6 +20,8 @@
|
||||
* - Doesn't validate sibling TOML — if the file is malformed
|
||||
* elsewhere, our injection won't fix it but won't make it worse.
|
||||
* - Quotes string values with double quotes; escapes `\` and `"`.
|
||||
* - Never rewrites a table written in another form (dotted keys, an
|
||||
* inline table): those are reported as a conflict instead (#2562).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -54,116 +58,380 @@ export function buildTomlTable(header: string, values: Record<string, string | s
|
||||
return `[${header}]\n${serializeTomlTableBody(values)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a table can't be written the usual way. `defined`: the file already
|
||||
* defines the table in a form we don't rewrite (dotted keys, an inline
|
||||
* table, an array of tables). `blocked`: a table header can't be added at
|
||||
* all, because the parent is set as a value or an array of tables.
|
||||
*/
|
||||
export interface TomlConflict {
|
||||
kind: 'defined' | 'blocked';
|
||||
/** 1-based line of the statement that causes it. */
|
||||
line: number;
|
||||
/** How that statement is written, for the message. */
|
||||
form: 'dotted keys' | 'an inline table' | 'a value' | 'an array of tables';
|
||||
/** The dotted key that statement sets, as written in the message. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a top-level dotted-key TOML table block in the
|
||||
* given file content. Preserves all other content verbatim.
|
||||
*
|
||||
* The existing table is found however its header line is written —
|
||||
* indented, spaced inside the brackets or around the dots, keys quoted,
|
||||
* a comment after it, CRLF — and a header-shaped line inside a multiline
|
||||
* value is never taken for it (#2562: only a column-0 `[mcp_servers.codegraph]`
|
||||
* was found, so an indented one got a second table appended and the file
|
||||
* stopped parsing). Further tables of the same name, as that bug left,
|
||||
* are removed. The block is written with the file's own line breaks.
|
||||
*
|
||||
* Returns `'inserted'` when the table was newly added, `'replaced'`
|
||||
* when an existing one was rewritten, `'unchanged'` when the
|
||||
* existing block already matches `block` byte-for-byte.
|
||||
* existing block already matches `block`, and `'conflict'` (content
|
||||
* untouched) when the table is defined in another form or can't be added.
|
||||
*/
|
||||
export function upsertTomlTable(
|
||||
fileContent: string,
|
||||
header: string,
|
||||
block: string,
|
||||
): { content: string; action: 'inserted' | 'replaced' | 'unchanged' } {
|
||||
const headerLine = `[${header}]`;
|
||||
const headerIdx = findHeaderIndex(fileContent, headerLine);
|
||||
):
|
||||
| { content: string; action: 'inserted' | 'replaced' | 'unchanged' }
|
||||
| { content: string; action: 'conflict'; conflict: TomlConflict } {
|
||||
const layout = layoutOf(fileContent, header.split('.'));
|
||||
const conflict = layout.defined ?? layout.blocked;
|
||||
if (conflict) return { content: fileContent, action: 'conflict', conflict };
|
||||
|
||||
if (headerIdx === -1) {
|
||||
const eol = tomlLineBreak(fileContent);
|
||||
const ours = block.replace(/\r?\n/g, eol);
|
||||
const [first, ...duplicates] = layout.tables;
|
||||
|
||||
if (!first) {
|
||||
// Insert at end with separating blank line if there's existing content.
|
||||
const trimmed = fileContent.trimEnd();
|
||||
const sep = trimmed.length > 0 ? '\n\n' : '';
|
||||
return {
|
||||
content: trimmed + sep + block + '\n',
|
||||
action: 'inserted',
|
||||
};
|
||||
const sep = trimmed.length > 0 ? eol + eol : '';
|
||||
return { content: trimmed + sep + ours + eol, action: 'inserted' };
|
||||
}
|
||||
|
||||
// Find the end of this block: next table header or EOF.
|
||||
const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
|
||||
const existingBlock = fileContent.substring(headerIdx, blockEnd).replace(/\n+$/, '');
|
||||
|
||||
if (existingBlock === block) {
|
||||
const existingBlock = fileContent.substring(first.start, first.end).replace(/(?:\r?\n)+$/, '');
|
||||
if (duplicates.length === 0 && existingBlock === ours) {
|
||||
return { content: fileContent, action: 'unchanged' };
|
||||
}
|
||||
|
||||
const before = fileContent.substring(0, headerIdx);
|
||||
const after = fileContent.substring(blockEnd);
|
||||
// Trim trailing blank lines from `before` (we'll re-add one) and
|
||||
// leading blank lines from `after` so the file shape stays clean.
|
||||
const beforeClean = before.replace(/\n+$/, '');
|
||||
const afterClean = after.replace(/^\n+/, '');
|
||||
const sepBefore = beforeClean.length > 0 ? '\n\n' : '';
|
||||
const sepAfter = afterClean.length > 0 ? '\n\n' : '\n';
|
||||
return {
|
||||
content: beforeClean + sepBefore + block + sepAfter + afterClean,
|
||||
action: 'replaced',
|
||||
};
|
||||
let content = fileContent;
|
||||
for (const dup of [...duplicates].reverse()) content = spliceBlock(content, dup, null, eol);
|
||||
return { content: spliceBlock(keepFinalLineBreak(fileContent, content, eol), first, ours, eol), action: 'replaced' };
|
||||
}
|
||||
|
||||
/** `content` ending with a line break when `original` did (removing the last block drops it). */
|
||||
function keepFinalLineBreak(original: string, content: string, eol: string): string {
|
||||
return content.length > 0 && original.endsWith('\n') && !content.endsWith('\n') ? content + eol : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a top-level dotted-key TOML table block. Returns the
|
||||
* possibly-empty new content + an action flag.
|
||||
*
|
||||
* Removes every definition of the table: each `[header]` block however
|
||||
* its line is written, its sub-tables (`[header.env]` alone would leave a
|
||||
* server entry with no command), and keys that define it with dotted keys
|
||||
* or an inline table. `'conflict'` (content untouched) when it is defined
|
||||
* inside a parent's inline table, which can't be edited safely.
|
||||
*/
|
||||
export function removeTomlTable(
|
||||
fileContent: string,
|
||||
header: string,
|
||||
): { content: string; action: 'removed' | 'not-found' } {
|
||||
const headerLine = `[${header}]`;
|
||||
const headerIdx = findHeaderIndex(fileContent, headerLine);
|
||||
if (headerIdx === -1) return { content: fileContent, action: 'not-found' };
|
||||
):
|
||||
| { content: string; action: 'removed' | 'not-found' }
|
||||
| { content: string; action: 'conflict'; conflict: TomlConflict } {
|
||||
const layout = layoutOf(fileContent, header.split('.'));
|
||||
if (layout.inParentValue) return { content: fileContent, action: 'conflict', conflict: layout.inParentValue };
|
||||
|
||||
const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
|
||||
const before = fileContent.substring(0, headerIdx).replace(/\n+$/, '');
|
||||
const after = fileContent.substring(blockEnd).replace(/^\n+/, '');
|
||||
const joined = before + (before && after ? '\n\n' : '') + after;
|
||||
return { content: joined, action: 'removed' };
|
||||
const eol = tomlLineBreak(fileContent);
|
||||
const blocks = [...layout.tables, ...layout.subTables];
|
||||
const removals = [
|
||||
...blocks.map((b) => ({ ...b, isBlock: true })),
|
||||
...layout.keys.map((k) => ({ start: k.start, end: k.end, isBlock: false })),
|
||||
].sort((a, b) => b.start - a.start);
|
||||
if (removals.length === 0) return { content: fileContent, action: 'not-found' };
|
||||
|
||||
let content = fileContent;
|
||||
for (const r of removals) {
|
||||
content = r.isBlock ? spliceBlock(content, r, null, eol) : content.slice(0, r.start) + content.slice(r.end);
|
||||
}
|
||||
return { content: keepFinalLineBreak(fileContent, content, eol), action: 'removed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the byte index of a header line (`[foo.bar]`) when it
|
||||
* appears at the start of a line. Returns -1 if not found.
|
||||
* Whether the file defines the table in any form — a `[header]` block
|
||||
* however it is written, a sub-table of it, dotted keys, or an inline
|
||||
* table. What a Codex install counts as "already configured".
|
||||
*/
|
||||
function findHeaderIndex(content: string, headerLine: string): number {
|
||||
// Search BOL or right after a newline.
|
||||
if (content.startsWith(headerLine)) return 0;
|
||||
const needle = '\n' + headerLine;
|
||||
const idx = content.indexOf(needle);
|
||||
return idx === -1 ? -1 : idx + 1;
|
||||
export function tomlDefinesTable(fileContent: string, header: string): boolean {
|
||||
const layout = layoutOf(fileContent, header.split('.'));
|
||||
return layout.tables.length > 0 || layout.subTables.length > 0 || layout.defined !== null;
|
||||
}
|
||||
|
||||
/** The line break `content` uses (its first one); `\n` when it has none. */
|
||||
export function tomlLineBreak(content: string): '\n' | '\r\n' {
|
||||
const nl = content.indexOf('\n');
|
||||
return nl > 0 && content[nl - 1] === '\r' ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the byte index of the next `[...]` or `[[...]]` table header
|
||||
* starting from `from`, or return content length when none.
|
||||
* Replace (or, with `replacement` null, remove) the block at `range`,
|
||||
* rejoining what's around it with one blank line, in the file's line
|
||||
* breaks — the shape the installer has always left.
|
||||
*/
|
||||
function findNextTableHeader(content: string, from: number): number {
|
||||
function spliceBlock(content: string, range: { start: number; end: number }, replacement: string | null, eol: string): string {
|
||||
const before = content.substring(0, range.start).replace(/(?:\r?\n)+$/, '');
|
||||
const after = content.substring(range.end).replace(/^(?:\r?\n)+/, '');
|
||||
if (replacement === null) return before + (before && after ? eol + eol : '') + after;
|
||||
return before + (before ? eol + eol : '') + replacement + (after ? eol + eol : eol) + after;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TomlStatement {
|
||||
kind: 'table' | 'array-table' | 'key';
|
||||
/** The decoded key path; for a key, including the table it sits in. Null when unreadable. */
|
||||
path: string[] | null;
|
||||
/** The table a key sits in (for a header, its own path). */
|
||||
table: string[] | null;
|
||||
/** Offset of the statement's first line. */
|
||||
start: number;
|
||||
/** Offset just past the statement's last line (its line break included). */
|
||||
end: number;
|
||||
/** 1-based line of the statement's first line. */
|
||||
line: number;
|
||||
/** For a key: whether its value is an inline table (`{`). */
|
||||
inlineTable?: boolean;
|
||||
}
|
||||
|
||||
interface TableLayout {
|
||||
/** `[path]` blocks: from the header line to the next header or EOF. */
|
||||
tables: Array<{ start: number; end: number }>;
|
||||
/** Blocks of headers below `path` (`[path.env]`, `[[path.x]]`). */
|
||||
subTables: Array<{ start: number; end: number }>;
|
||||
/** Key statements that define `path` outside its own blocks. */
|
||||
keys: TomlStatement[];
|
||||
/** The first definition of `path` in a form other than a `[path]` block. */
|
||||
defined: TomlConflict | null;
|
||||
/** The first statement that makes a `[path]` header invalid. */
|
||||
blocked: TomlConflict | null;
|
||||
/** `path` set inside a parent's value (e.g. `mcp_servers = { codegraph = … }`). */
|
||||
inParentValue: TomlConflict | null;
|
||||
}
|
||||
|
||||
function layoutOf(content: string, path: string[]): TableLayout {
|
||||
const statements = scanTomlStatements(content);
|
||||
// A header's block runs to the end of its last key, so blank and comment
|
||||
// lines before the next header (a comment heading the next table) stay
|
||||
// with what follows.
|
||||
const blockOf = (i: number) => {
|
||||
let end = statements[i]!.end;
|
||||
for (let j = i + 1; j < statements.length && statements[j]!.kind === 'key'; j++) end = statements[j]!.end;
|
||||
return { start: statements[i]!.start, end };
|
||||
};
|
||||
const layout: TableLayout = { tables: [], subTables: [], keys: [], defined: null, blocked: null, inParentValue: null };
|
||||
const keyText = path.join('.');
|
||||
|
||||
statements.forEach((s, i) => {
|
||||
if (!s.path) return;
|
||||
if (s.kind !== 'key') {
|
||||
if (s.kind === 'table' && samePath(s.path, path)) {
|
||||
layout.tables.push(blockOf(i));
|
||||
} else if (startsWithPath(s.path, path)) {
|
||||
// `[path.env]`, `[[path.x]]` — and `[[path]]`, which also defines the
|
||||
// table as an array of tables we don't rewrite.
|
||||
layout.subTables.push(blockOf(i));
|
||||
if (samePath(s.path, path)) {
|
||||
const conflict: TomlConflict = { kind: 'defined', line: s.line, form: 'an array of tables', key: keyText };
|
||||
layout.defined ??= conflict;
|
||||
layout.blocked ??= { ...conflict, kind: 'blocked' };
|
||||
}
|
||||
} else if (s.kind === 'array-table' && startsWithPath(path, s.path)) {
|
||||
layout.blocked ??= { kind: 'blocked', line: s.line, form: 'an array of tables', key: s.path.join('.') };
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A key inside the table's own blocks is its body, not a definition.
|
||||
if (s.table && startsWithPath(s.table, path)) return;
|
||||
if (startsWithPath(s.path, path)) {
|
||||
layout.keys.push(s);
|
||||
const form = s.path.length > path.length ? 'dotted keys' : s.inlineTable ? 'an inline table' : 'a value';
|
||||
layout.defined ??= { kind: 'defined', line: s.line, form, key: s.path.join('.') };
|
||||
} else if (startsWithPath(path, s.path)) {
|
||||
// A parent set as a value: `mcp_servers = { … }`. No `[path]` header
|
||||
// can follow it, and anything of ours inside it can't be edited.
|
||||
const conflict: TomlConflict = {
|
||||
kind: 'blocked', line: s.line, form: s.inlineTable ? 'an inline table' : 'a value', key: s.path.join('.'),
|
||||
};
|
||||
layout.blocked ??= conflict;
|
||||
const text = content.substring(s.start, s.end);
|
||||
const last = path[path.length - 1]!;
|
||||
if (new RegExp(String.raw`(^|[{,.\s])["']?${escapeRegExp(last)}["']?\s*[=.]`).test(text.slice(text.indexOf('=') + 1))) {
|
||||
layout.inParentValue ??= { ...conflict, kind: 'defined' };
|
||||
layout.defined ??= { ...conflict, kind: 'defined' };
|
||||
}
|
||||
}
|
||||
});
|
||||
return layout;
|
||||
}
|
||||
|
||||
function samePath(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
function startsWithPath(a: readonly string[], prefix: readonly string[]): boolean {
|
||||
return prefix.length <= a.length && prefix.every((x, i) => x === a[i]);
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `content` into statements. A line counts only when it starts
|
||||
* outside a multiline value; the lines of a value that spans several are
|
||||
* folded into the key statement that opened it.
|
||||
*/
|
||||
function scanTomlStatements(content: string): TomlStatement[] {
|
||||
const statements: TomlStatement[] = [];
|
||||
const state: TomlLexState = { multilineString: null, arrayDepth: 0, inlineTableDepth: 0 };
|
||||
let lineStart = from;
|
||||
let isHeaderRemainder = true;
|
||||
let table: string[] | null = [];
|
||||
let open: TomlStatement | null = null;
|
||||
let lineStart = 0;
|
||||
let line = 1;
|
||||
|
||||
while (lineStart < content.length) {
|
||||
const newlineIdx = content.indexOf('\n', lineStart);
|
||||
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx;
|
||||
const line = content.slice(lineStart, lineEnd);
|
||||
const next = newlineIdx === -1 ? content.length : newlineIdx + 1;
|
||||
const text = content.slice(lineStart, lineEnd);
|
||||
const clean = state.multilineString === null && state.arrayDepth === 0 && state.inlineTableDepth === 0;
|
||||
|
||||
if (
|
||||
!isHeaderRemainder &&
|
||||
state.multilineString === null &&
|
||||
state.arrayDepth === 0 &&
|
||||
state.inlineTableDepth === 0 &&
|
||||
isTomlTableHeader(line)
|
||||
) {
|
||||
return lineStart;
|
||||
if (!clean) {
|
||||
if (open) open.end = next;
|
||||
} else {
|
||||
open = null;
|
||||
if (isTomlTableHeader(text)) {
|
||||
const parsed = parseTableHeader(text);
|
||||
table = parsed.path;
|
||||
statements.push({ kind: parsed.array ? 'array-table' : 'table', path: parsed.path, table, start: lineStart, end: next, line });
|
||||
} else {
|
||||
const key = parseKeyValueStart(text);
|
||||
if (key) {
|
||||
open = {
|
||||
kind: 'key',
|
||||
path: key.path && table ? [...table, ...key.path] : null,
|
||||
table,
|
||||
start: lineStart,
|
||||
end: next,
|
||||
line,
|
||||
inlineTable: key.inlineTable,
|
||||
};
|
||||
statements.push(open);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanTomlLine(line, state);
|
||||
if (newlineIdx === -1) break;
|
||||
lineStart = newlineIdx + 1;
|
||||
isHeaderRemainder = false;
|
||||
scanTomlLine(text, state);
|
||||
lineStart = next;
|
||||
line++;
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
return content.length;
|
||||
/** `[a.b]` / `[[a.b]]` (already known to be a header line) → its decoded path. */
|
||||
function parseTableHeader(text: string): { array: boolean; path: string[] | null } {
|
||||
let i = skipWs(text, 0) + 1;
|
||||
const array = text[i] === '[';
|
||||
if (array) i++;
|
||||
const key = parseDottedKey(text, skipWs(text, i));
|
||||
return { array, path: key ? key.path : null };
|
||||
}
|
||||
|
||||
/** `key = …` at the start of a line → the key's decoded path. */
|
||||
function parseKeyValueStart(text: string): { path: string[] | null; inlineTable: boolean } | null {
|
||||
const start = skipWs(text, 0);
|
||||
if (start >= text.length || text[start] === '#' || text[start] === '\r') return null;
|
||||
const key = parseDottedKey(text, start);
|
||||
if (!key) return null;
|
||||
const eq = skipWs(text, key.end);
|
||||
if (text[eq] !== '=') return null;
|
||||
return { path: key.path, inlineTable: text[skipWs(text, eq + 1)] === '{' };
|
||||
}
|
||||
|
||||
function skipWs(text: string, i: number): number {
|
||||
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dotted key at `i`: bare parts, `"basic"` parts (escapes decoded) and
|
||||
* `'literal'` parts, joined by dots with optional spaces around them.
|
||||
* Returns the decoded parts and the offset just past the key, or null.
|
||||
*/
|
||||
function parseDottedKey(text: string, i: number): { path: string[]; end: number } | null {
|
||||
const path: string[] = [];
|
||||
for (;;) {
|
||||
const part = parseKeyPart(text, i);
|
||||
if (!part) return null;
|
||||
path.push(part.value);
|
||||
const dot = skipWs(text, part.end);
|
||||
if (text[dot] !== '.') return { path, end: part.end };
|
||||
i = skipWs(text, dot + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyPart(text: string, i: number): { value: string; end: number } | null {
|
||||
const ch = text[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
||||
if (j >= text.length) return null;
|
||||
const value = decodeBasicString(text.slice(i + 1, j));
|
||||
return value === null ? null : { value, end: j + 1 };
|
||||
}
|
||||
if (ch === "'") {
|
||||
const j = text.indexOf("'", i + 1);
|
||||
return j === -1 ? null : { value: text.slice(i + 1, j), end: j + 1 };
|
||||
}
|
||||
let j = i;
|
||||
while (j < text.length && /[A-Za-z0-9_-]/.test(text[j]!)) j++;
|
||||
return j === i ? null : { value: text.slice(i, j), end: j };
|
||||
}
|
||||
|
||||
const SIMPLE_ESCAPES: Record<string, string> = {
|
||||
b: String.fromCharCode(8), t: String.fromCharCode(9), n: String.fromCharCode(10), f: String.fromCharCode(12),
|
||||
r: String.fromCharCode(13), e: String.fromCharCode(27), '"': '"', '\\': '\\',
|
||||
};
|
||||
|
||||
/** The value of a TOML basic string's body, or null for an invalid escape. */
|
||||
function decodeBasicString(raw: string): string | null {
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i] !== '\\') {
|
||||
out += raw[i];
|
||||
continue;
|
||||
}
|
||||
const esc = raw[++i] ?? '';
|
||||
if (esc in SIMPLE_ESCAPES) {
|
||||
out += SIMPLE_ESCAPES[esc];
|
||||
continue;
|
||||
}
|
||||
const width = esc === 'x' ? 2 : esc === 'u' ? 4 : esc === 'U' ? 8 : 0;
|
||||
const hex = raw.slice(i + 1, i + 1 + width);
|
||||
if (width === 0 || hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex)) return null;
|
||||
const codePoint = parseInt(hex, 16);
|
||||
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null;
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += width;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type MultilineStringDelimiter = '"""' | "'''";
|
||||
|
||||
Reference in New Issue
Block a user