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