|
|
@@ -7,13 +7,14 @@
|
|
|
*
|
|
|
* 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. Everything outside that block is
|
|
|
- * preserved verbatim, byte-for-byte.
|
|
|
+ * `[...]` / `[[...]]` 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.
|
|
|
*
|
|
|
* Limitations (acceptable for our narrow use):
|
|
|
- * - Only handles top-level table headers; not array-of-tables or
|
|
|
- * subtables nested inside `[mcp_servers]` itself (we always write
|
|
|
- * the full dotted key `[mcp_servers.codegraph]`).
|
|
|
+ * - Only writes a top-level table header. Array-of-tables and sibling
|
|
|
+ * subtables are preserved as opaque blocks (we always write the full
|
|
|
+ * dotted key `[mcp_servers.codegraph]`).
|
|
|
* - 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 `"`.
|
|
|
@@ -79,7 +80,7 @@ export function upsertTomlTable(
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- // Find the end of this block: next `[...]` header (at line start) or EOF.
|
|
|
+ // 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+$/, '');
|
|
|
|
|
|
@@ -133,22 +134,121 @@ function findHeaderIndex(content: string, headerLine: string): number {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Find the byte index of the next top-level `[...]` table header
|
|
|
- * (excluding array-of-tables `[[...]]`) starting from `from`, or
|
|
|
- * return content length when none.
|
|
|
+ * Find the byte index of the next `[...]` or `[[...]]` table header
|
|
|
+ * starting from `from`, or return content length when none.
|
|
|
*/
|
|
|
function findNextTableHeader(content: string, from: number): number {
|
|
|
- // Look for "\n[" but skip "\n[[" (array of tables).
|
|
|
- let i = from;
|
|
|
- while (i < content.length) {
|
|
|
- const nlIdx = content.indexOf('\n[', i);
|
|
|
- if (nlIdx === -1) return content.length;
|
|
|
- if (content[nlIdx + 2] === '[') {
|
|
|
- // [[...]] — keep searching past it.
|
|
|
- i = nlIdx + 2;
|
|
|
- continue;
|
|
|
+ const state: TomlLexState = { multilineString: null, arrayDepth: 0, inlineTableDepth: 0 };
|
|
|
+ let lineStart = from;
|
|
|
+ let isHeaderRemainder = true;
|
|
|
+
|
|
|
+ while (lineStart < content.length) {
|
|
|
+ const newlineIdx = content.indexOf('\n', lineStart);
|
|
|
+ const lineEnd = newlineIdx === -1 ? content.length : newlineIdx;
|
|
|
+ const line = content.slice(lineStart, lineEnd);
|
|
|
+
|
|
|
+ if (
|
|
|
+ !isHeaderRemainder &&
|
|
|
+ state.multilineString === null &&
|
|
|
+ state.arrayDepth === 0 &&
|
|
|
+ state.inlineTableDepth === 0 &&
|
|
|
+ isTomlTableHeader(line)
|
|
|
+ ) {
|
|
|
+ return lineStart;
|
|
|
}
|
|
|
- return nlIdx + 1;
|
|
|
+
|
|
|
+ scanTomlLine(line, state);
|
|
|
+ if (newlineIdx === -1) break;
|
|
|
+ lineStart = newlineIdx + 1;
|
|
|
+ isHeaderRemainder = false;
|
|
|
}
|
|
|
+
|
|
|
return content.length;
|
|
|
}
|
|
|
+
|
|
|
+type MultilineStringDelimiter = '"""' | "'''";
|
|
|
+
|
|
|
+interface TomlLexState {
|
|
|
+ multilineString: MultilineStringDelimiter | null;
|
|
|
+ arrayDepth: number;
|
|
|
+ inlineTableDepth: number;
|
|
|
+}
|
|
|
+
|
|
|
+const TOML_KEY_PART = String.raw`(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])*"|'[^']*')`;
|
|
|
+const TOML_DOTTED_KEY = String.raw`${TOML_KEY_PART}(?:[ \t]*\.[ \t]*${TOML_KEY_PART})*`;
|
|
|
+const TOML_TABLE = String.raw`\[[ \t]*${TOML_DOTTED_KEY}[ \t]*\]`;
|
|
|
+const TOML_ARRAY_TABLE = String.raw`\[\[[ \t]*${TOML_DOTTED_KEY}[ \t]*\]\]`;
|
|
|
+const TOML_TABLE_HEADER = new RegExp(
|
|
|
+ String.raw`^[ \t]*(?:${TOML_TABLE}|${TOML_ARRAY_TABLE})[ \t]*(?:#.*)?\r?$`
|
|
|
+);
|
|
|
+
|
|
|
+function isTomlTableHeader(line: string): boolean {
|
|
|
+ return TOML_TABLE_HEADER.test(line);
|
|
|
+}
|
|
|
+
|
|
|
+/** Track value constructs that may legally span lines so bracket-shaped string
|
|
|
+ * content and nested arrays cannot be mistaken for sibling table headers. */
|
|
|
+function scanTomlLine(line: string, state: TomlLexState): void {
|
|
|
+ for (let i = 0; i < line.length;) {
|
|
|
+ if (state.multilineString !== null) {
|
|
|
+ const end = findMultilineStringEnd(line, i, state.multilineString);
|
|
|
+ if (end === -1) return;
|
|
|
+ i = end + state.multilineString.length;
|
|
|
+ state.multilineString = null;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (line[i] === '#') return;
|
|
|
+
|
|
|
+ const multiline = line.startsWith('"""', i)
|
|
|
+ ? '"""'
|
|
|
+ : line.startsWith("'''", i)
|
|
|
+ ? "'''"
|
|
|
+ : null;
|
|
|
+ if (multiline !== null) {
|
|
|
+ state.multilineString = multiline;
|
|
|
+ i += multiline.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const ch = line[i]!;
|
|
|
+ if (ch === '"' || ch === "'") {
|
|
|
+ i = skipSingleLineString(line, i, ch);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (ch === '[') state.arrayDepth++;
|
|
|
+ else if (ch === ']' && state.arrayDepth > 0) state.arrayDepth--;
|
|
|
+ else if (ch === '{') state.inlineTableDepth++;
|
|
|
+ else if (ch === '}' && state.inlineTableDepth > 0) state.inlineTableDepth--;
|
|
|
+ i++;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function findMultilineStringEnd(
|
|
|
+ line: string,
|
|
|
+ from: number,
|
|
|
+ delimiter: MultilineStringDelimiter,
|
|
|
+): number {
|
|
|
+ let end = line.indexOf(delimiter, from);
|
|
|
+ while (delimiter === '"""' && end !== -1 && isBackslashEscaped(line, end)) {
|
|
|
+ end = line.indexOf(delimiter, end + 1);
|
|
|
+ }
|
|
|
+ return end;
|
|
|
+}
|
|
|
+
|
|
|
+function isBackslashEscaped(line: string, index: number): boolean {
|
|
|
+ let backslashes = 0;
|
|
|
+ for (let i = index - 1; i >= 0 && line[i] === '\\'; i--) backslashes++;
|
|
|
+ return backslashes % 2 === 1;
|
|
|
+}
|
|
|
+
|
|
|
+function skipSingleLineString(line: string, start: number, quote: '"' | "'"): number {
|
|
|
+ for (let i = start + 1; i < line.length; i++) {
|
|
|
+ if (quote === '"' && line[i] === '\\') {
|
|
|
+ i++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (line[i] === quote) return i + 1;
|
|
|
+ }
|
|
|
+ return line.length;
|
|
|
+}
|