323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import {
|
|
createTomlLineScanState,
|
|
getTomlTableHeader,
|
|
isTomlStructuralLine,
|
|
updateTomlLineScanState
|
|
} from './config-toml-line-scan'
|
|
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
|
|
|
const TUI_STRUCTURED_PREFIX = 'tui.'
|
|
|
|
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
|
|
// baseline/update entries can never collide with a top-level key of the same name.
|
|
export function tuiStructuredKey(key: string): string {
|
|
return `${TUI_STRUCTURED_PREFIX}${key}`
|
|
}
|
|
|
|
export function isTuiStructuredKey(structuredKey: string): boolean {
|
|
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
|
|
}
|
|
|
|
export function tuiKeyFromStructuredKey(structuredKey: string): string {
|
|
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
|
|
}
|
|
|
|
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
|
|
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
|
|
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
|
|
// entries wherever the [tui] placement rule puts them.
|
|
export function upsertPromotedSettingsInContent(
|
|
content: string,
|
|
updates: Map<string, string>
|
|
): string {
|
|
const topLevelUpdates = new Map<string, string>()
|
|
const tuiUpdates = new Map<string, string>()
|
|
for (const [key, raw] of updates) {
|
|
if (isTuiStructuredKey(key)) {
|
|
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
|
|
} else {
|
|
topLevelUpdates.set(key, raw)
|
|
}
|
|
}
|
|
let result = content
|
|
if (topLevelUpdates.size > 0) {
|
|
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
|
|
}
|
|
if (tuiUpdates.size > 0) {
|
|
result = upsertTuiSettingsInContent(result, tuiUpdates)
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function upsertTopLevelSettingsInContent(
|
|
content: string,
|
|
updates: Map<string, string>
|
|
): string {
|
|
const lines = content.split('\n')
|
|
let state = createTomlLineScanState()
|
|
let preambleEnd = lines.length
|
|
const keyLineIndexes = new Map<string, number>()
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? ''
|
|
if (isTomlStructuralLine(state)) {
|
|
if (getTomlTableHeader(line)) {
|
|
preambleEnd = index
|
|
break
|
|
}
|
|
const parsed = parseTomlKeyPath(line)
|
|
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
|
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
|
keyLineIndexes.set(key, index)
|
|
}
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
}
|
|
|
|
// Why: CRLF configs keep a trailing \r after the split; new lines must use
|
|
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
|
|
const usesCrlf = content.includes('\r\n')
|
|
const insertions: string[] = []
|
|
for (const [key, raw] of updates) {
|
|
const existingIndex = keyLineIndexes.get(key)
|
|
const rendered = `${key} = ${raw}`
|
|
if (existingIndex !== undefined) {
|
|
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
|
} else {
|
|
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
|
}
|
|
}
|
|
if (insertions.length > 0) {
|
|
let insertAt = preambleEnd
|
|
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
|
|
insertAt -= 1
|
|
}
|
|
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
|
insertions.push(usesCrlf ? '\r' : '')
|
|
}
|
|
lines.splice(insertAt, 0, ...insertions)
|
|
}
|
|
return joinPreservingTrailingNewline(lines, usesCrlf)
|
|
}
|
|
|
|
type TuiPlacementScan = {
|
|
bareKeyIndexes: Map<string, number>
|
|
dottedKeyIndexes: Map<string, number>
|
|
hasBareTuiTable: boolean
|
|
hasDottedTuiKey: boolean
|
|
blocksNewTuiTable: boolean
|
|
blockedAbsentKeys: Set<string>
|
|
bareBodyInsertIndex: number
|
|
lastDottedTuiIndex: number
|
|
}
|
|
|
|
/**
|
|
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
|
|
* placing each per the design's total placement rule: replace an existing key
|
|
* in place keeping its form; else insert bare into the first `[tui]` body; else
|
|
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
|
|
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
|
|
* the destination — bare inside a table, dotted in the preamble — so no `tui`
|
|
* table is ever defined twice.
|
|
*/
|
|
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
|
|
const lines = content.split('\n')
|
|
const scan = scanTuiPlacement(lines, updates)
|
|
const usesCrlf = content.includes('\r\n')
|
|
const bareBodyInserts: string[] = []
|
|
const dottedPreambleInserts: string[] = []
|
|
const newTableKeys: string[] = []
|
|
|
|
for (const [key, raw] of updates) {
|
|
const dottedIndex = scan.dottedKeyIndexes.get(key)
|
|
if (dottedIndex !== undefined) {
|
|
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
|
|
continue
|
|
}
|
|
const bareIndex = scan.bareKeyIndexes.get(key)
|
|
if (bareIndex !== undefined) {
|
|
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
|
|
continue
|
|
}
|
|
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
|
|
if (scan.blockedAbsentKeys.has(key)) {
|
|
continue
|
|
}
|
|
if (scan.hasBareTuiTable) {
|
|
bareBodyInserts.push(`${key} = ${raw}`)
|
|
} else if (scan.hasDottedTuiKey) {
|
|
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
|
|
} else if (!scan.blocksNewTuiTable) {
|
|
// Why: inline/array tui definitions block this branch because adding a
|
|
// plain [tui] beside either would make the config invalid.
|
|
newTableKeys.push(`${key} = ${raw}`)
|
|
}
|
|
}
|
|
|
|
// Why: the config shape routes every absent key to the same branch, so at most
|
|
// one insert group is non-empty; still apply EOF→body→preamble so a splice
|
|
// never shifts a lower index a later splice depends on.
|
|
if (newTableKeys.length > 0) {
|
|
appendNewTuiTable(lines, newTableKeys, usesCrlf)
|
|
}
|
|
if (bareBodyInserts.length > 0) {
|
|
lines.splice(
|
|
scan.bareBodyInsertIndex,
|
|
0,
|
|
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
|
|
)
|
|
}
|
|
if (dottedPreambleInserts.length > 0) {
|
|
lines.splice(
|
|
scan.lastDottedTuiIndex + 1,
|
|
0,
|
|
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
|
|
)
|
|
}
|
|
return joinPreservingTrailingNewline(lines, usesCrlf)
|
|
}
|
|
|
|
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
|
|
let state = createTomlLineScanState()
|
|
let inPreamble = true
|
|
let tuiTableSeen = false
|
|
let tuiBodyActive = false
|
|
let tuiBodyHeaderIndex = -1
|
|
let tuiBodyEndIndex = -1
|
|
let hasDottedTuiKey = false
|
|
let blocksNewTuiTable = false
|
|
let lastDottedTuiIndex = -1
|
|
const bareKeyIndexes = new Map<string, number>()
|
|
const dottedKeyIndexes = new Map<string, number>()
|
|
const blockedAbsentKeys = new Set<string>()
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? ''
|
|
if (isTomlStructuralLine(state)) {
|
|
const header = getTomlTableHeader(line)
|
|
if (header) {
|
|
if (tuiBodyActive) {
|
|
tuiBodyEndIndex = index
|
|
tuiBodyActive = false
|
|
}
|
|
const table = parseTomlTableHeaderPath(header)
|
|
if (
|
|
table &&
|
|
!table.isArray &&
|
|
table.segments.length === 1 &&
|
|
table.segments[0] === 'tui' &&
|
|
!tuiTableSeen
|
|
) {
|
|
tuiTableSeen = true
|
|
tuiBodyActive = true
|
|
tuiBodyHeaderIndex = index
|
|
}
|
|
// Why: a root [[tui]] is already an array, so appending [tui] would
|
|
// redefine it and make an otherwise valid config unparseable.
|
|
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
|
|
blocksNewTuiTable = true
|
|
}
|
|
const descendantKey =
|
|
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
|
|
if (descendantKey && updates.has(descendantKey)) {
|
|
blockedAbsentKeys.add(descendantKey)
|
|
}
|
|
inPreamble = false
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
if (inPreamble) {
|
|
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
|
|
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
|
|
const parsed = parseTomlKeyPath(line)
|
|
const isAssignment = parsed && line[parsed.end] === '='
|
|
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
|
|
hasDottedTuiKey = true
|
|
lastDottedTuiIndex = index
|
|
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
|
|
if (promotedKey && updates.has(promotedKey)) {
|
|
dottedKeyIndexes.set(promotedKey, index)
|
|
}
|
|
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
|
|
if (descendantKey && updates.has(descendantKey)) {
|
|
blockedAbsentKeys.add(descendantKey)
|
|
}
|
|
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
|
|
blocksNewTuiTable = true
|
|
}
|
|
} else if (tuiBodyActive) {
|
|
const parsed = parseTomlKeyPath(line)
|
|
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
|
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
|
bareKeyIndexes.set(key, index)
|
|
}
|
|
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
|
|
if (descendantKey && updates.has(descendantKey)) {
|
|
blockedAbsentKeys.add(descendantKey)
|
|
}
|
|
}
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
}
|
|
if (tuiBodyActive) {
|
|
tuiBodyEndIndex = lines.length
|
|
}
|
|
|
|
return {
|
|
bareKeyIndexes,
|
|
dottedKeyIndexes,
|
|
hasBareTuiTable: tuiTableSeen,
|
|
hasDottedTuiKey,
|
|
blocksNewTuiTable,
|
|
blockedAbsentKeys,
|
|
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
|
|
lastDottedTuiIndex
|
|
}
|
|
}
|
|
|
|
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
|
|
// so absent keys land at the body's end — before trailing blanks and before the
|
|
// next header — which is the only valid spot.
|
|
function computeBareBodyInsertIndex(
|
|
lines: string[],
|
|
headerIndex: number,
|
|
endIndex: number
|
|
): number {
|
|
if (headerIndex === -1) {
|
|
return -1
|
|
}
|
|
let insertAt = endIndex
|
|
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
|
|
insertAt -= 1
|
|
}
|
|
return insertAt
|
|
}
|
|
|
|
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
|
|
let appendAt = lines.length
|
|
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
|
|
appendAt -= 1
|
|
}
|
|
// Why: separate the new table from prior content with a blank line, unless the
|
|
// file was empty/blank, where a leading blank would be spurious.
|
|
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
|
|
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
|
|
}
|
|
|
|
function withTrailingCr(originalLine: string, rendered: string): string {
|
|
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
|
|
}
|
|
|
|
function withCrLine(rendered: string, usesCrlf: boolean): string {
|
|
return usesCrlf ? `${rendered}\r` : rendered
|
|
}
|
|
|
|
// Why: a missing trailing newline is restored in the file's own EOL so a
|
|
// preamble-only or table-appended rewrite matches the source's newline behavior.
|
|
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
|
|
const result = lines.join('\n')
|
|
if (result.endsWith('\n') || result.length === 0) {
|
|
return result
|
|
}
|
|
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
|
}
|