0
0
Fork 0

Merge pull request #16 from phobicdotno/feature/v0.3-sub-property-paths

feat(live-values): depth-1 sub-property descent
This commit is contained in:
phobicdotno 2026-04-29 13:53:33 +02:00 committed by GitHub
commit b639913dd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 263 additions and 26 deletions

View file

@ -5,26 +5,37 @@ import { readSelection } from './state-read';
const VAR_OPEN_RE = /^\s*(?:VAR(?:_INPUT|_OUTPUT|_IN_OUT|_GLOBAL|_TEMP|_CONFIG|_EXTERNAL|_STAT)?)\b/i;
const VAR_CLOSE_RE = /^\s*END_VAR\b/i;
const VAR_DECL_RE = /^\s*([A-Za-z_]\w*)\b/;
// `name [AT %loc] : <type>` -- captures the type name (first identifier after ':').
// Type may be a primitive (INT/BOOL), an ARRAY, a POINTER, a REFERENCE, or a
// user-defined identifier. We only return the leaf identifier; ARRAY / POINTER
// / REFERENCE-OF wrappers don't contribute a sub-property scan.
const VAR_TYPE_RE = /:\s*(?:ARRAY\s*\[[^\]]+\]\s*OF\s+|POINTER\s+TO\s+|REFERENCE\s+TO\s+)?([A-Za-z_]\w*)/i;
export interface VarDecl {
name: string;
/** Declared type as written. null if we couldn't extract a type identifier. */
type: string | null;
}
/**
* Parse all variable names declared in any VAR / VAR_INPUT / VAR_OUTPUT /
* VAR_GLOBAL / etc. block. One name per declaration line; skips lines that
* are entirely inside (* ... *) blocks (joined across lines) or are
* `// ...` comments.
* Parse all variable declarations in any VAR / VAR_INPUT / VAR_OUTPUT /
* VAR_GLOBAL / etc. block. Returns name + declared type per decl. Skips
* (* ... *) comments (joined across lines) and `// ...` line comments.
*
* Recognises the IEC declaration grammar enough for typical PLC code:
* `name [AT %loc] : type [:= init];`. Doesn't try to parse multi-name
* shorthand (`a, b : INT;`) that's vanishingly rare in practice and
* the cost of getting it wrong is just a missing overlay.
* Recognises `name [AT %loc] : [ARRAY [...] OF | POINTER TO | REFERENCE TO]
* <type> [:= init];`. Wrapper modifiers (ARRAY/POINTER/REFERENCE) are
* stripped the inner type is what we'd descend into for sub-property
* paths, and a containing ARRAY of struct doesn't get pretty-printed in
* v0.3+ anyway. Multi-name shorthand 'a, b : INT;' yields only the first
* name (vanishingly rare in practice).
*/
export function parseVarNames(text: string): string[] {
const names: string[] = [];
export function parseVarDecls(text: string): VarDecl[] {
const decls: VarDecl[] = [];
const lines = text.split(/\r?\n/);
let inVarBlock = false;
let inMultilineComment = false;
for (let raw of lines) {
// Strip block comments first, threading the open-state across lines.
for (const raw of lines) {
let scrubbed = '';
let i = 0;
while (i < raw.length) {
@ -51,7 +62,6 @@ export function parseVarNames(text: string): string[] {
scrubbed += raw[i];
i++;
}
// Strip line comments.
const slash = scrubbed.indexOf('//');
if (slash >= 0) scrubbed = scrubbed.slice(0, slash);
@ -63,10 +73,17 @@ export function parseVarNames(text: string): string[] {
inVarBlock = false;
continue;
}
const m = VAR_DECL_RE.exec(scrubbed);
if (m) names.push(m[1]);
const nameM = VAR_DECL_RE.exec(scrubbed);
if (!nameM) continue;
const typeM = VAR_TYPE_RE.exec(scrubbed);
decls.push({ name: nameM[1], type: typeM ? typeM[1] : null });
}
return names;
return decls;
}
/** Backward-compat: name-only view used by callers that don't need types yet. */
export function parseVarNames(text: string): string[] {
return parseVarDecls(text).map((d) => d.name);
}
// ─── Pump ────────────────────────────────────────────────────────────────
@ -81,6 +98,13 @@ export interface PumpDeps {
readSelection?: typeof readSelection;
/** Read the POU's mirror file from disk. */
readPouFile?: (absPath: string) => Promise<string>;
/**
* Resolve a user-defined type name to its mirror .st file content,
* or return null if no such mirror file exists (built-in types,
* library types we can't introspect, etc.). Used for sub-property
* descent.
*/
resolveTypeMirror?: (typeName: string, deviceRoot: string) => Promise<string | null>;
/** Read a single PLC variable. Returns its current value as a string. */
readVariable?: (projectFilePath: string, variablePath: string) => Promise<string>;
/** Write the live-values snapshot. Defaults to writeLiveValues. */
@ -100,7 +124,19 @@ export class LiveValuesPump {
private timer: ReturnType<typeof setInterval> | null = null;
private busy = false;
constructor(private cfg: PumpConfig, private deps: Required<PumpDeps>) {}
private deps: Required<PumpDeps>;
constructor(private cfg: PumpConfig, deps: PumpDeps) {
this.deps = {
readSelection: deps.readSelection ?? readSelection,
readPouFile: deps.readPouFile ?? (() => Promise.reject(new Error('readPouFile not provided'))),
// No resolver provided -> never descend (no sub-property paths). Pump
// still works for top-level vars only, matching v0.3 behaviour.
resolveTypeMirror: deps.resolveTypeMirror ?? (async () => null),
readVariable: deps.readVariable ?? (() => Promise.reject(new Error('readVariable not provided'))),
writeLiveValues: deps.writeLiveValues ?? writeLiveValues,
};
}
start(): void {
if (this.timer) return;
@ -124,17 +160,40 @@ export class LiveValuesPump {
if (sel.status !== 'ok') return;
const pou = sel.payload.selection;
const text = await this.deps.readPouFile(pou.abs_path);
const names = parseVarNames(text);
if (names.length === 0) return;
const decls = parseVarDecls(text);
if (decls.length === 0) return;
// Build the read-list: top-level vars + depth-1 sub-properties when
// the type resolves to another mirror .st file.
// Each entry maps the dotted PLC path (PLC_PRG.fb.x) to the snapshot
// key the TUI overlay matches against (the leaf name -- TUI matches
// identifiers in the line text, so `x` is what the line says).
const reads: Array<{ pouPath: string; key: string }> = [];
const deviceRoot = this.deviceRootFor(pou.abs_path);
for (const d of decls) {
reads.push({ pouPath: `${pou.name}.${d.name}`, key: d.name });
if (!d.type) continue;
const childText = await this.deps.resolveTypeMirror(d.type, deviceRoot);
if (!childText) continue;
for (const cd of parseVarDecls(childText)) {
reads.push({
pouPath: `${pou.name}.${d.name}.${cd.name}`,
// Overlay key is the LEAF name -- the TUI scans line text for
// identifiers and matches against this map. Sub-property names
// win over top-level when they collide; that's harmless because
// a struct's member would only appear inside the struct's own
// declaration, not the parent POU's source.
key: cd.name,
});
}
}
const values: Record<string, LiveValueSnapshotIn> = {};
const ts = Date.now();
for (const name of names) {
for (const r of reads) {
try {
const v = await this.deps.readVariable(
sel.payload.project_dir,
`${pou.name}.${name}`
);
values[name] = { value: v, ts };
const v = await this.deps.readVariable(sel.payload.project_dir, r.pouPath);
values[r.key] = { value: v, ts };
} catch {
// single-var failure: skip silently, others may succeed
}
@ -150,4 +209,31 @@ export class LiveValuesPump {
this.busy = false;
}
}
/**
* Given a POU's abs path under a mirror tree, return the device root
* (the directory where its sibling .st files live). Used by
* resolveTypeMirror to look up `<TypeName>.st` next to the current POU.
*
* Example:
* /abs/proj/mcp-mirror/CodesysRpi/Plc Logic/Application/PLC_PRG.st
* -> /abs/proj/mcp-mirror/CodesysRpi
*
* The mirror layout always has a `mcp-mirror/<deviceName>/...` shape, so
* we walk up until we find that segment. If we can't, return the parent
* dir as a best-effort (resolveTypeMirror still works for same-folder
* lookups, just won't find types declared in sibling folders).
*/
private deviceRootFor(absPath: string): string {
const norm = absPath.replace(/\\/g, '/');
const idx = norm.lastIndexOf('/mcp-mirror/');
if (idx < 0) {
const p = norm.split('/');
p.pop();
return p.join('/');
}
const after = norm.slice(idx + '/mcp-mirror/'.length);
const deviceName = after.split('/')[0];
return `${norm.slice(0, idx)}/mcp-mirror/${deviceName}`;
}
}

View file

@ -3205,6 +3205,39 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
{
readSelection,
readPouFile: (absPath) => fs.promises.readFile(absPath, 'utf8'),
// Best-effort recursive walk of the device root looking for
// <typeName>.st. The mirror layout puts every code-bearing object
// (POU, FB, DUT) at a stable filename matching its declared name,
// so this is a clean lookup even though the directory tree is
// arbitrary nesting under "Plc Logic/Application/...".
resolveTypeMirror: async (typeName, deviceRoot) => {
const target = `${typeName}.st`;
const walk = async (dir: string): Promise<string | null> => {
let entries: import('fs').Dirent[];
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return null;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isFile() && e.name === target) {
try {
return await fs.promises.readFile(full, 'utf8');
} catch {
return null;
}
}
}
for (const e of entries) {
if (!e.isDirectory()) continue;
const found = await walk(path.join(dir, e.name));
if (found !== null) return found;
}
return null;
};
return walk(deviceRoot);
},
readVariable: async (projectFilePath, variablePath) => {
// Reuse the existing read_variable script. Returns the value as
// a string captured from script stdout. Errors throw.

View file

@ -103,4 +103,75 @@ describe('LiveValuesPump.tick', () => {
await expect(pump.tick()).resolves.toBeUndefined();
expect(writeLiveValues).not.toHaveBeenCalled();
});
it('descends one level for vars whose type resolves to a mirror file', async () => {
const parent = [
'PROGRAM PLC_PRG',
'VAR',
' fb : FB_Test;',
'END_VAR',
].join('\n');
const child = [
'FUNCTION_BLOCK FB_Test',
'VAR',
' inner : INT;',
' bAlive : BOOL;',
'END_VAR',
].join('\n');
const writeLiveValues = vi.fn(async () => {});
const reads: string[] = [];
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => okSelection()),
readPouFile: vi.fn(async () => parent),
resolveTypeMirror: vi.fn(async (typeName: string) =>
typeName === 'FB_Test' ? child : null
),
readVariable: vi.fn(async (_proj: string, varPath: string) => {
reads.push(varPath);
if (varPath === 'PLC_PRG.fb') return '<FB_Test>';
if (varPath === 'PLC_PRG.fb.inner') return '7';
if (varPath === 'PLC_PRG.fb.bAlive') return 'TRUE';
throw new Error('unknown');
}),
writeLiveValues,
}
);
await pump.tick();
expect(reads).toContain('PLC_PRG.fb.inner');
expect(reads).toContain('PLC_PRG.fb.bAlive');
const payload = writeLiveValues.mock.calls[0][2];
expect(payload.values.inner.value).toBe('7');
expect(payload.values.bAlive.value).toBe('TRUE');
});
it('does not descend into vars with primitive types (no mirror file)', async () => {
const parent = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT;',
' bFlag : BOOL;',
'END_VAR',
].join('\n');
const writeLiveValues = vi.fn(async () => {});
const resolveTypeMirror = vi.fn(async () => null);
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => okSelection()),
readPouFile: vi.fn(async () => parent),
resolveTypeMirror,
readVariable: vi.fn(async () => '1'),
writeLiveValues,
}
);
await pump.tick();
// resolveTypeMirror still gets ASKED for primitives; the resolver returns
// null for each, so no descent. That's fine — caller decides what's
// resolvable.
expect(resolveTypeMirror).toHaveBeenCalled();
const payload = writeLiveValues.mock.calls[0][2];
expect(Object.keys(payload.values).sort()).toEqual(['bFlag', 'counter']);
});
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseVarNames } from '../../src/live-values-pump';
import { parseVarNames, parseVarDecls } from '../../src/live-values-pump';
describe('parseVarNames', () => {
it('extracts vars from a single VAR block', () => {
@ -55,3 +55,50 @@ describe('parseVarNames', () => {
expect(parseVarNames(text)).toEqual(['bRelay', 'iCount']);
});
});
describe('parseVarDecls', () => {
it('returns name + declared type per decl', () => {
const text = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
' bRunning : BOOL;',
' fb : FB_Test;',
'END_VAR',
].join('\n');
expect(parseVarDecls(text)).toEqual([
{ name: 'counter', type: 'INT' },
{ name: 'bRunning', type: 'BOOL' },
{ name: 'fb', type: 'FB_Test' },
]);
});
it('strips ARRAY [...] OF wrapper', () => {
const text = ['VAR', ' buf : ARRAY [0..9] OF INT;', 'END_VAR'].join('\n');
expect(parseVarDecls(text)).toEqual([{ name: 'buf', type: 'INT' }]);
});
it('strips POINTER TO and REFERENCE TO', () => {
const text = [
'VAR',
' p : POINTER TO BOOL;',
' r : REFERENCE TO MyStruct;',
'END_VAR',
].join('\n');
expect(parseVarDecls(text)).toEqual([
{ name: 'p', type: 'BOOL' },
{ name: 'r', type: 'MyStruct' },
]);
});
it('handles AT %loc prefix', () => {
const text = ['VAR', ' bRelay AT %QX0.1 : BOOL := FALSE;', 'END_VAR'].join('\n');
expect(parseVarDecls(text)).toEqual([{ name: 'bRelay', type: 'BOOL' }]);
});
it('returns type=null when there is no `: <type>` clause on the line', () => {
// Pathological: missing type. Don't crash; just emit name with null type.
const text = ['VAR', ' weird;', 'END_VAR'].join('\n');
expect(parseVarDecls(text)).toEqual([{ name: 'weird', type: null }]);
});
});