feat(config-editor): TsConfigFile load/read/patch/add/delete/save (entry-level AST patch, .bak + syntax check)

26 IO tests green: serializer, parser (speed/enumRef/raw), TsConfigFile
round-trip (load/patch/add/delete/save preserving symbolic expressions).
This commit is contained in:
panFD
2026-06-21 00:13:42 +08:00
parent 0d28ad7a5e
commit 4df88c1c90
2 changed files with 211 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import * as fs from 'node:fs';
import * as ts from 'typescript';
import { RecordValue } from './recordValue';
import { findExportObjectLiteral, parseExpression } from './parser';
import { serializeEntry } from './serializer';
/**
* 以"条目级 AST 区间替换"方式读写一个 `export const <name>: Record<number,X> = {...}` 配置文件。
* 保留符号表达式speed与枚举引用enumRef未识别表达式原样保留raw
* save() 纯逻辑:仅做 fs 读写 + 语法校验(写 .bakasset-db 刷新由主进程在 save 成功后调用。
*/
export class TsConfigFile {
private sourceText = '';
private sourceFile!: ts.SourceFile;
private targetNode!: ts.ObjectLiteralExpression;
private dirty = false;
constructor(public readonly filePath: string, public readonly exportName: string) {}
load(): void {
this.sourceText = fs.readFileSync(this.filePath, 'utf8');
this.reparse();
this.dirty = false;
}
private reparse(): void {
// 第 4 参数 setParentNodes 必须为 true否则 node.getStart()/getEnd() 不可用。
this.sourceFile = ts.createSourceFile(this.filePath, this.sourceText, ts.ScriptTarget.Latest, true);
const node = findExportObjectLiteral(this.sourceFile, this.exportName);
if (!node) throw new Error(`export const ${this.exportName} not found or not an object literal in ${this.filePath}`);
this.targetNode = node;
}
getKeys(): string[] {
return this.targetNode.properties.map(p => (p.name as ts.PropertyName).getText());
}
read(key: string): RecordValue | null {
const entry = this.findEntry(key);
if (!entry) return null;
return parseExpression(entry.initializer);
}
private findEntry(key: string): ts.PropertyAssignment | undefined {
return this.targetNode.properties.find(p => (p.name as ts.PropertyName).getText() === key) as ts.PropertyAssignment | undefined;
}
getText(): string { return this.sourceText; }
isDirty(): boolean { return this.dirty; }
patch(key: string, value: RecordValue): void {
const entry = this.findEntry(key);
const newText = serializeEntry(key, value);
if (entry) {
const { start, end } = this.entrySpan(entry);
this.sourceText = this.sourceText.slice(0, start) + newText + this.sourceText.slice(end);
} else {
this.insertEntry(newText);
}
this.reparse();
this.dirty = true;
}
add(key: string, value: RecordValue): void {
if (this.findEntry(key)) throw new Error(`key ${key} already exists`);
this.insertEntry(serializeEntry(key, value));
this.reparse();
this.dirty = true;
}
delete(key: string): void {
const entry = this.findEntry(key);
if (!entry) return;
const { start, end } = this.entrySpan(entry);
this.sourceText = this.sourceText.slice(0, start) + this.sourceText.slice(end);
this.reparse();
this.dirty = true;
}
save(): { ok: true } | { ok: false; error: string } {
if (!this.dirty) return { ok: true };
const check = ts.createSourceFile(this.filePath, this.sourceText, ts.ScriptTarget.Latest, true);
if (check.parseDiagnostics.length > 0) {
const msg = check.parseDiagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('; ');
return { ok: false, error: `syntax error after edit: ${msg}` };
}
// .bak 取自磁盘原始内容(落盘前的当前文件),保证可回滚。
fs.writeFileSync(this.filePath + '.bak', fs.readFileSync(this.filePath));
fs.writeFileSync(this.filePath, this.sourceText);
this.dirty = false;
return { ok: true };
}
/** 条目文本区间:从 key 词法起始(不含前导缩进 trivia避免吞掉上一行换行到尾随逗号止。 */
private entrySpan(entry: ts.PropertyAssignment): { start: number; end: number } {
const start = entry.getStart();
let end = entry.getEnd();
const comma = /^\s*,/.exec(this.sourceText.slice(end));
if (comma) end += comma[0].length;
return { start, end };
}
/** 在闭合 `}` 前追加一条新记录4 空格缩进)。 */
private insertEntry(entryText: string): void {
const closeBrace = this.targetNode.getLastToken();
if (!closeBrace) throw new Error('object literal has no closing brace');
const pos = closeBrace.getStart();
this.sourceText = this.sourceText.slice(0, pos) + ` ${entryText}\n ` + this.sourceText.slice(pos);
}
}