From 4df88c1c9059faa849371c1bf93bc3ed2a74f52d Mon Sep 17 00:00:00 2001 From: panFD Date: Sun, 21 Jun 2026 00:13:42 +0800 Subject: [PATCH] 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). --- .../__tests__/tsConfigFile.test.ts | 101 ++++++++++++++++ .../src/io/TsConfigFile.ts | 110 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts create mode 100644 extensions/pixelhero-config-editor/src/io/TsConfigFile.ts diff --git a/extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts b/extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts new file mode 100644 index 00000000..555da535 --- /dev/null +++ b/extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts @@ -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); +}); diff --git a/extensions/pixelhero-config-editor/src/io/TsConfigFile.ts b/extensions/pixelhero-config-editor/src/io/TsConfigFile.ts new file mode 100644 index 00000000..b8ee5215 --- /dev/null +++ b/extensions/pixelhero-config-editor/src/io/TsConfigFile.ts @@ -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 : Record = {...}` 配置文件。 + * 保留符号表达式(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); + } +}