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:
@@ -0,0 +1,101 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, copyFileSync, mkdtempSync, readdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { TsConfigFile } from '../src/io/TsConfigFile';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixture = join(here, 'fixtures', 'heroSet.sample.ts');
|
||||||
|
|
||||||
|
test('load + getKeys', () => {
|
||||||
|
const f = new TsConfigFile(fixture, 'HeroInfo');
|
||||||
|
f.load();
|
||||||
|
assert.deepEqual(f.getKeys(), ['5011', '6001']);
|
||||||
|
});
|
||||||
|
test('read returns structured value with speed/enumRef preserved', () => {
|
||||||
|
const f = new TsConfigFile(fixture, 'HeroInfo');
|
||||||
|
f.load();
|
||||||
|
const v = f.read('5011')!;
|
||||||
|
assert.equal(v.kind, 'obj');
|
||||||
|
assert.deepEqual(v.props['fac'], { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' });
|
||||||
|
const skill = v.props['skills'].props['6001'];
|
||||||
|
assert.deepEqual(skill.props['cd'], { kind: 'speed', level: 'Slow3' });
|
||||||
|
});
|
||||||
|
test('getText after load equals original file (no mutation on load)', () => {
|
||||||
|
const f = new TsConfigFile(fixture, 'HeroInfo');
|
||||||
|
f.load();
|
||||||
|
assert.equal(f.getText(), readFileSync(fixture, 'utf8'));
|
||||||
|
});
|
||||||
|
test('read missing key returns null', () => {
|
||||||
|
const f = new TsConfigFile(fixture, 'HeroInfo');
|
||||||
|
f.load();
|
||||||
|
assert.equal(f.read('9999'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
function withTempFixture(): { dir: string; file: string } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'phcfg-'));
|
||||||
|
const file = join(dir, 'heroSet.sample.ts');
|
||||||
|
copyFileSync(fixture, file);
|
||||||
|
return { dir, file };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('patch updates one entry; reload reads new value; other entries intact', () => {
|
||||||
|
const { file } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
const v = f.read('5011')!;
|
||||||
|
v.props['ap'] = { kind: 'num', value: 99 };
|
||||||
|
f.patch('5011', v);
|
||||||
|
assert.equal(f.isDirty(), true);
|
||||||
|
assert.equal(f.save().ok, true);
|
||||||
|
const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load();
|
||||||
|
assert.deepEqual(f2.read('5011')!.props['ap'], { kind: 'num', value: 99 });
|
||||||
|
assert.equal(f2.read('6001')!.props['name'].value, '兽人战士'); // 另一条未破坏
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patch preserves speed + enumRef on reload', () => {
|
||||||
|
const { file } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
const v = f.read('5011')!;
|
||||||
|
v.props['hp'] = { kind: 'num', value: 500 };
|
||||||
|
f.patch('5011', v); f.save();
|
||||||
|
const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load();
|
||||||
|
const again = f2.read('5011')!;
|
||||||
|
assert.deepEqual(again.props['fac'], { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' });
|
||||||
|
assert.deepEqual(again.props['skills'].props['6001'].props['cd'], { kind: 'speed', level: 'Slow3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add appends entry readable after save+reload', () => {
|
||||||
|
const { file } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
f.add('5099', { kind: 'obj', props: { uuid: { kind: 'num', value: 5099 }, name: { kind: 'str', value: '新英雄' } } });
|
||||||
|
f.save();
|
||||||
|
const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load();
|
||||||
|
assert.ok(f2.getKeys().includes('5099'));
|
||||||
|
assert.equal(f2.read('5099')!.props['name'].value, '新英雄');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes entry', () => {
|
||||||
|
const { file } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
f.delete('6001'); f.save();
|
||||||
|
const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load();
|
||||||
|
assert.ok(!f2.getKeys().includes('6001'));
|
||||||
|
assert.ok(f2.getKeys().includes('5011'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save writes a .bak backup of the pre-save file', () => {
|
||||||
|
const { file } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
const v = f.read('5011')!; v.props['hp'] = { kind: 'num', value: 1 }; f.patch('5011', v);
|
||||||
|
f.save();
|
||||||
|
assert.equal(readFileSync(file + '.bak', 'utf8'), readFileSync(fixture, 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save with no edits is a no-op (ok, no .bak written)', () => {
|
||||||
|
const { file, dir } = withTempFixture();
|
||||||
|
const f = new TsConfigFile(file, 'HeroInfo'); f.load();
|
||||||
|
assert.equal(f.save().ok, true);
|
||||||
|
assert.equal(readdirSync(dir).some(p => p.endsWith('.bak')), false);
|
||||||
|
});
|
||||||
110
extensions/pixelhero-config-editor/src/io/TsConfigFile.ts
Normal file
110
extensions/pixelhero-config-editor/src/io/TsConfigFile.ts
Normal 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 读写 + 语法校验(写 .bak);asset-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user