Merge branch 'main' of https://github.com/phobicdotno/Codesys-MCP-SP21-plus
This commit is contained in:
commit
e0a4685c11
6 changed files with 256 additions and 30 deletions
|
|
@ -70,16 +70,16 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i
|
|||
|
||||
| # | Tool | Vitest | Live SP22 | Notes |
|
||||
|---|-------------------------------|--------|-----------|-------|
|
||||
| 1 | `find_symbol_config` | OK | pending | Template-prep test asserts marker-walk + path serialiser |
|
||||
| 2 | `list_all_signatures` | OK | pending | `compile=true/false` both rendered |
|
||||
| 3 | `list_all_datatypes` | OK | pending | Mirror of #2 over `get_all_datatypes()` |
|
||||
| 4 | `list_configured_symbols` | OK | pending | Walks signatures + datatypes; serialises `configured_access` / `maximal_access` / `effective_access` per variable |
|
||||
| 5 | `get_symbol_config_settings` | OK | pending | All 6 knobs + obstacle explanations + available layout calculators |
|
||||
| 6 | `create_symbol_config` | OK | pending | Idempotent (refuse-with-success if already present); `application.create_symbol_config(exp, opc, guid)` |
|
||||
| 7 | `set_symbol_config_settings` | OK | pending | Partial-update via per-field APPLY_* flags; refuses direct I/O if obstacles |
|
||||
| 8 | `set_symbol_access` | OK | pending | Per-variable `configured_access` with enum probe + int-literal fallback |
|
||||
| 9 | `set_signature_access_bulk` | OK | pending | Iterates `sig.variables`, reports `changed` + `skipped` |
|
||||
|10 | `export_symbol_xsd` | OK | pending | `get_symbol_configuration_xsd()` bytes -> file (UTF-8) |
|
||||
| 1 | `find_symbol_config` | OK | OK (2026-04-29) | Returned `count=0` on virgin MCPTest2, then `count=1` (`CodesysRpi/Plc Logic/Application/Symbols`) after create. |
|
||||
| 2 | `list_all_signatures` | OK | partial | `compile=true` returned `count=0` on the empty-PLC_PRG MCPTest2 -- script ran cleanly, no crash, but the build short-circuited. Re-run on a project with real signatures (e.g. X33). |
|
||||
| 3 | `list_all_datatypes` | OK | not run | Skipped after #8/#9/#10 destabilised the watcher. |
|
||||
| 4 | `list_configured_symbols` | OK | OK (2026-04-29) | Returned `signature_count=0, datatype_count=0` on freshly-created Symbol Configuration -- expected. |
|
||||
| 5 | `get_symbol_config_settings` | OK | OK (2026-04-29) | Pre-set: `SupportOPCUA, XmlIncludeComments` (= 0x20001 default for SP22). Comment filter default is `None`, **not** `Both` as the plan predicted -- plan was wrong. |
|
||||
| 6 | `create_symbol_config` | OK | OK (2026-04-29) | Created `Application/Symbols`. (One CODESYS crash *immediately after* create on the first run; restart-and-retry succeeded -- see "Caveats" below.) |
|
||||
| 7 | `set_symbol_config_settings` | OK | OK (2026-04-29) | Wrote `[SupportOPCUA, IncludeComments, IncludeExecutables]`; re-read showed `content_feature_flags_int=19` (configured) / `effective=3` (IncludeExecutables masked off in `effective` -- normal SP22 runtime clamp). |
|
||||
| 8 | `set_symbol_access` | OK | not run | Skipped after #9 timed out the watcher. Calls `get_all_signatures(True)` on the not-yet-configured fallback path -- same instability as #9. |
|
||||
| 9 | `set_signature_access_bulk` | OK | crash | `Application.PLC_PRG ReadWrite` -> 60s timeout -> CODESYS exited with 0xFFFFFFFF. Suspected: the `get_all_signatures(True)` second-fallback re-builds, and the build crashes on this project (likely related to the Pi-only IoDrvGPIO managed library). Needs a project with a real, build-clean PLC_PRG. |
|
||||
|10 | `export_symbol_xsd` | OK | crash | Same failure mode as #9. `get_symbol_configuration_xsd()` may also trigger an internal build. |
|
||||
|
||||
**Vitest column**: `tests/integration/e2e.test.ts` -- 10 new template-prep assertions added; full suite 107/107 passing (excluding the orphan `.worktrees/phobics-tui` suite). Each test renders the script with realistic placeholders and asserts:
|
||||
- no leftover `{PLACEHOLDER}` in the rendered output,
|
||||
|
|
@ -87,7 +87,9 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i
|
|||
- the helper functions are pulled in (`find_symbol_config_object`, `ensure_symbol_config`, `symbol_config_path`).
|
||||
- additionally Python 3 `ast.parse` was run against every script to catch any IronPython 2.7 syntax that Py3 would also flag.
|
||||
|
||||
**Live SP22 column**: deferred -- the Claude Code session's MCP tool list was negotiated at session start and doesn't refresh when the MCP server registers new tools mid-session. To run the live cycle, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan:
|
||||
**Live SP22 column** (updated 2026-04-29 morning): partial pass -- 5/10 verified end-to-end, 2/10 crashed, 3/10 skipped after the crashes. Symbol-Configuration *creation, settings persistence, and read-only inspection* all work cleanly on SP22 P1. The build-triggering tools (`set_signature_access_bulk`, `set_symbol_access` via fallback, `export_symbol_xsd`) need a project that builds cleanly headless -- MCPTest2's empty PLC_PRG body + IoDrvGPIO managed library cause `application.build()` (called by `get_all_signatures(True)` and likely `get_symbol_configuration_xsd()`) to abort the process with exit 0xFFFFFFFF. Plan to re-run on a project with a real, build-clean POU body (e.g. a fresh `Standard project` template under SP22 with PLC_PRG containing one assignment).
|
||||
|
||||
To run the live cycle from scratch on a different project, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan:
|
||||
|
||||
```
|
||||
1. mcp__codesys__open_project MCPTest2.project
|
||||
|
|
@ -110,3 +112,12 @@ The plan also calls out the `SymbolAccess` enum-value probe risk: the SP22 stub
|
|||
---
|
||||
|
||||
*Function test executed against this fork @ HEAD `f34d002` on 2026-04-28; symbol-config tools added in `db688c2` and verified via vitest the same evening.*
|
||||
|
||||
---
|
||||
|
||||
## Symbol-config live re-run notes (2026-04-29 morning, HEAD `e948922`)
|
||||
|
||||
- **Crash pattern.** Three of the build-triggering tools (`get_all_signatures(True)` on a not-yet-configured Symbol Configuration, `set_signature_access_bulk` via the same fallback, and `export_symbol_xsd`) caused CODESYS to exit with 0xFFFFFFFF (NTSTATUS `STATUS_INVALID_HANDLE` / generic crash) on MCPTest2. The MCP launcher correctly reported state=error; restart-and-retry was always sufficient to recover. The crashes were *project-specific* -- `set_symbol_config_settings` flag-write succeeded on the *second* attempt with no code change between the failed and successful runs, suggesting a transient build-state race rather than a tool defect.
|
||||
- **Wrapper message fix landed in `e948922`.** `add_library` no longer renders "Library 'X' added" on the dedup no-op path -- it now reads the script's `Library Already Present:` marker and renders "already referenced". Cosmetic todo from this doc closed.
|
||||
- **Session ledger** (per `feedback_session_ledger.md` user rule): every CODESYS launch / shutdown / kill during this run logged to `~/.claude/projects/<id>/state/session_ledger.jsonl` so a next session knows which CODESYS.exe PIDs are mine to reclaim.
|
||||
- **Symbol-config side effect on MCPTest2**: a `Symbols` object was created under `CodesysRpi/Plc Logic/Application` and `content_feature_flags=19` was persisted. `git restore` the `.project` binary, or `delete_object Application/Symbols` from a fresh session, to revert.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
# phobiCS-tui v0.3 — inline live values
|
||||
|
||||
Status: **scoped, ready to implement.** The original blocker (`connect_to_device` script bug) was fixed in the SP21+ fork; see `src/scripts/connect_to_device.py:30-93`.
|
||||
|
||||
## Goal
|
||||
|
||||
When the user is browsing a POU in `phobiCS-tui` and the runtime is online, overlay each declared variable's live value inline next to its declaration in the Viewer:
|
||||
|
||||
```
|
||||
3 counter : INT := 0; ◀ live: 47
|
||||
4 bRunning : BOOL; ◀ live: TRUE
|
||||
```
|
||||
|
||||
Updates every 500 ms while the runtime is online. Off when no runtime, no overlay.
|
||||
|
||||
## Architecture (one-way pump)
|
||||
|
||||
The TUI is the user-facing process; the MCP server is the agent-facing process. They already communicate one-way through `tui-state.json` (TUI writes, server reads via `get_user_selection`).
|
||||
|
||||
This adds the **other** direction:
|
||||
|
||||
- **Server** writes a `tui-live-values.json` snapshot next to `tui-state.json`.
|
||||
- **TUI** Viewer reads it on a 500 ms timer and overlays values for the currently-displayed POU.
|
||||
|
||||
The server pump only runs when:
|
||||
1. `--live-values` CLI flag is set on the server, AND
|
||||
2. `tui-state.json` exists and is fresh (60 s window, same as `get_user_selection`), AND
|
||||
3. The runtime for that POU's project is online.
|
||||
|
||||
## State-file shape
|
||||
|
||||
`tui-live-values.json` (atomic write, same `.tmp` + rename pattern as `tui-state.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"updated_at": "2026-04-29T08:14:55.123Z",
|
||||
"project_dir": "/abs/project/dir",
|
||||
"device": "CodesysRpi",
|
||||
"pou_name": "PLC_PRG",
|
||||
"values": {
|
||||
"counter": { "value": "47", "type": "INT", "ts": 1745916895100 },
|
||||
"bRunning": { "value": "TRUE", "type": "BOOL", "ts": 1745916895100 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`values` is keyed by the bare variable name (no `PLC_PRG.` prefix). The TUI matches by exact string against tokens emitted by the highlighter.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: shared types + path helper
|
||||
|
||||
- Create `src/tui/shared/live-values.ts`: `LiveValuesPayload` interface.
|
||||
- Add `liveValuesFilePath()` to `src/tui/shared/state-paths.ts` — same dir as `stateFilePath()`, filename `tui-live-values.json`.
|
||||
- Tests: 4 cases mirroring the `state-paths` tests.
|
||||
|
||||
### Task 2: TUI atomic reader
|
||||
|
||||
- Create `src/tui/shared/live-values-read.ts`: `readLiveValues(filePath)` returns `{ status: 'ok' | 'missing' | 'stale' | 'invalid' }` with same `FRESHNESS_MS = 5_000` (tighter than the selection file — values stop being interesting fast).
|
||||
- Tests: 4 cases (fresh / missing / stale / malformed).
|
||||
|
||||
### Task 3: Viewer overlay
|
||||
|
||||
- Add `liveValues?: Record<string, { value: string }>` prop to `<Viewer>`.
|
||||
- Walk the highlighted token rows; if a row contains a `text` token whose trimmed text is a key in `liveValues`, append ` ◀ live: <value>` (in green) at end of row.
|
||||
- Tests: 3 cases — no liveValues prop = no overlay; with prop = overlay appears for matching var; non-matching vars unchanged.
|
||||
|
||||
### Task 4: TUI live-values hook + Browser wiring
|
||||
|
||||
- `useLiveValues(filePath, pouName)` hook in `src/tui/browser/useLiveValues.ts`: polls every 500 ms, returns `Record<string, {value: string}> | null`. Returns `null` when payload is missing/stale or `pou_name` doesn't match the requested `pouName`.
|
||||
- Browser passes `liveValues={...}` to `<Viewer>` when cursor is on a POU.
|
||||
- Tests: 2 cases — match returns map; mismatch returns null.
|
||||
|
||||
### Task 5: server-side reader for live-values write
|
||||
|
||||
- Create `src/live-values-write.ts`: `writeLiveValues(filePath, payload)` mirrors `state-write.ts` (atomic `.tmp` + rename).
|
||||
- Tests: 3 cases (envelope, parent dir auto-create, no .tmp residue).
|
||||
|
||||
### Task 6: server-side pump
|
||||
|
||||
- Create `src/live-values-pump.ts`:
|
||||
- `class LiveValuesPump` — owns a `setInterval` (default 500 ms).
|
||||
- On each tick: read `tui-state.json` via existing `readSelection`. If status != ok or selection is older than freshness, skip.
|
||||
- Read the POU's declaration from the mirror (already exposed via `mcp-mirror/<device>/.../<POU>.st`).
|
||||
- Parse the `VAR ... END_VAR` block to get var names. Use the existing parsing helpers if any; otherwise a minimal regex `\b([A-Za-z_]\w*)\s*:` is fine (skip lines starting with `(*` or `//`).
|
||||
- For each var, call into the existing `read_variable` script via `executor.executeScript()`. Collect `{ name -> value }`.
|
||||
- `writeLiveValues(...)` the payload.
|
||||
- Errors are silent (debug-log only) — pump must never crash.
|
||||
- `start()` / `stop()` lifecycle.
|
||||
- Tests: parse VAR block from canned input; pump tick writes file with correct structure (mock executor + fs).
|
||||
|
||||
### Task 7: server-side wiring
|
||||
|
||||
- `ServerConfig.liveValues?: boolean` in `src/types.ts`.
|
||||
- `bin.ts`: `--live-values` flag, plumb into config, banner `Live values: ENABLED (poll 500ms)`.
|
||||
- In `startMcpServer`: if `config.liveValues`, instantiate `LiveValuesPump` and `start()` it; `stop()` in shutdown handler.
|
||||
- Tests: pump starts/stops in a unit test using a fake interval.
|
||||
|
||||
### Task 8: README + commit
|
||||
|
||||
- Document `--live-values` and the inline-value display in the `## phobiCS-tui` section.
|
||||
- Commit + open PR.
|
||||
|
||||
## Out of scope for v0.3
|
||||
|
||||
- Sub-property values (`PLC_PRG.fbX.bSomething`) — only top-level vars on the displayed POU.
|
||||
- Write-from-TUI (changing values from the keyboard). Stays in `write_variable` MCP tool.
|
||||
- Custom poll interval CLI flag — fixed at 500 ms in v0.3.
|
||||
- ARRAY / STRUCT pretty-printing — show whatever `read_variable` returns as a string.
|
||||
|
||||
## Risk / known unknowns
|
||||
|
||||
- `read_variable` per var is N round-trips per tick. 500 ms × 10 vars = 50 ms/var headroom. Existing `read_variable` script might be slow enough to make this infeasible for large POUs. If so: switch to a single-script-per-tick that reads N vars in one CODESYS call.
|
||||
- The CODESYS UI may pop a credential dialog mid-pump. Pump must catch and back off (longer interval after first failure, e.g. 5 s).
|
||||
- TUI 500 ms file poll on Windows may cause noticeable disk I/O. Mitigate: only poll while `pou_name` is set (i.e., user is on a POU row).
|
||||
|
|
@ -1776,6 +1776,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
},
|
||||
['ensure_project_open', 'ensure_online_connection']
|
||||
);
|
||||
const blocked = await gateOpForTool({
|
||||
enabled: !!config.approveEdits,
|
||||
slug: `write-${args.variablePath.replace(/[^A-Za-z0-9._-]+/g, '_')}`,
|
||||
oldText: `(* live variable write *)\nvariable: ${args.variablePath}\nproject: ${args.projectFilePath}\n`,
|
||||
newText: `(* live variable write *)\nvariable: ${args.variablePath}\nvalue: ${args.value}\nproject: ${args.projectFilePath}\n`,
|
||||
});
|
||||
if (blocked) return blocked;
|
||||
const result = await executor.executeScript(script);
|
||||
return formatToolResponse(
|
||||
result,
|
||||
|
|
@ -1808,6 +1815,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
},
|
||||
['register_device_credentials', 'ensure_project_open', 'ensure_online_connection']
|
||||
);
|
||||
const blocked = await gateOpForTool({
|
||||
enabled: !!config.approveEdits,
|
||||
slug: `download-${args.projectFilePath.replace(/[^A-Za-z0-9._-]+/g, '_').slice(-40)}`,
|
||||
oldText: '',
|
||||
newText: `(* DOWNLOAD TO DEVICE *)\nproject: ${args.projectFilePath}\nWARNING: pushes the compiled application to the live PLC.\n`,
|
||||
});
|
||||
if (blocked) return blocked;
|
||||
// Tool-side timeout = wait window + 120s headroom for the actual download
|
||||
const ipcTimeoutMs = (waitSec + 120) * 1000;
|
||||
const result = await executor.executeScript(script, ipcTimeoutMs);
|
||||
|
|
@ -1832,6 +1846,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
},
|
||||
['ensure_project_open', 'ensure_online_connection']
|
||||
);
|
||||
const blocked = await gateOpForTool({
|
||||
enabled: !!config.approveEdits,
|
||||
slug: `app-${args.action}`,
|
||||
oldText: '',
|
||||
newText: `(* PLC application ${args.action.toUpperCase()} *)\nproject: ${args.projectFilePath}\n`,
|
||||
});
|
||||
if (blocked) return blocked;
|
||||
const result = await executor.executeScript(script);
|
||||
return formatToolResponse(
|
||||
result,
|
||||
|
|
@ -2889,6 +2910,15 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
livenessPort?: number;
|
||||
connectTimeoutMs?: number;
|
||||
}) => {
|
||||
const host = args.host ?? 'codesys-pi.local';
|
||||
const service = args.service ?? 'codesyscontrol';
|
||||
const blocked = await gateOpForTool({
|
||||
enabled: !!config.approveEdits,
|
||||
slug: `restart-${host.replace(/[^A-Za-z0-9._-]+/g, '_')}`,
|
||||
oldText: '',
|
||||
newText: `(* RESTART RUNTIME via SSH *)\nhost: ${host}\nservice: ${service}\nWARNING: kicks the live PLC runtime; running app stops momentarily.\n`,
|
||||
});
|
||||
if (blocked) return blocked;
|
||||
try {
|
||||
const res = await restartCodesysRuntime(args);
|
||||
const ok = res.restartExitCode === 0 && (res.listening === true || res.listening === null);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { POU } from '../shared/types.js';
|
||||
import { tokenize, TokenKind } from './highlight.js';
|
||||
import { Token, tokenizeLines, TokenKind } from './highlight.js';
|
||||
|
||||
const COLORS: Record<TokenKind, string | undefined> = {
|
||||
keyword: 'cyan',
|
||||
|
|
@ -11,8 +11,7 @@ const COLORS: Record<TokenKind, string | undefined> = {
|
|||
text: undefined,
|
||||
};
|
||||
|
||||
function HighlightedLine({ line }: { line: string }): React.ReactElement {
|
||||
const tokens = React.useMemo(() => tokenize(line), [line]);
|
||||
function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement {
|
||||
return (
|
||||
<Text>
|
||||
{tokens.map((t, i) => (
|
||||
|
|
@ -40,16 +39,19 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
|
|||
);
|
||||
}
|
||||
const lines = text.split(/\r?\n/);
|
||||
const slice = lines.slice(scrollTop, scrollTop + visibleRows);
|
||||
// Tokenize from line 0 so multi-line (* ... *) state is correct, then
|
||||
// slice the visible window. Cheap; line count is bounded by file size.
|
||||
const allTokens = React.useMemo(() => tokenizeLines(lines), [text]);
|
||||
const sliceTokens = allTokens.slice(scrollTop, scrollTop + visibleRows);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
{pou.name}.st ({pou.kind}, {pou.loc} L)
|
||||
</Text>
|
||||
{slice.map((l, i) => (
|
||||
{sliceTokens.map((tokens, i) => (
|
||||
<Text key={scrollTop + i}>
|
||||
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
|
||||
<HighlightedLine line={l} />
|
||||
<HighlightedTokens tokens={tokens} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -42,17 +42,25 @@ const TYPES = new Set([
|
|||
|
||||
const IDENT_RE = /[A-Z_][A-Z0-9_]*/;
|
||||
|
||||
export function tokenize(line: string): Token[] {
|
||||
/**
|
||||
* Tokenize a single line. If `openComment` is true, the line is treated as
|
||||
* starting inside a (* ... *) block; output includes a `commentLeftOpen`
|
||||
* flag so callers can continue the state across lines.
|
||||
*/
|
||||
export interface TokenizeResult {
|
||||
tokens: Token[];
|
||||
/** True if the line ended without closing a (* block. */
|
||||
commentLeftOpen: boolean;
|
||||
}
|
||||
|
||||
export function tokenizeWithState(line: string, openComment: boolean): TokenizeResult {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
let inComment = openComment;
|
||||
let pending = '';
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pending) return;
|
||||
// Walk pending and split at every uppercase identifier boundary, so
|
||||
// identifiers (whether keyword/type or plain) come out as their own
|
||||
// tokens. Anything else (whitespace, punctuation, lowercase ids) is
|
||||
// emitted as 'text'.
|
||||
const re = /[A-Z_][A-Z0-9_]*/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
|
|
@ -68,18 +76,29 @@ export function tokenize(line: string): Token[] {
|
|||
pending = '';
|
||||
};
|
||||
|
||||
// If line started inside a comment, eat up to "*)" or end-of-line.
|
||||
if (inComment) {
|
||||
const end = line.indexOf('*)');
|
||||
if (end < 0) {
|
||||
out.push({ kind: 'comment', text: line });
|
||||
return { tokens: out, commentLeftOpen: true };
|
||||
}
|
||||
out.push({ kind: 'comment', text: line.slice(0, end + 2) });
|
||||
i = end + 2;
|
||||
inComment = false;
|
||||
}
|
||||
|
||||
while (i < line.length) {
|
||||
// (* ... *) inline comment
|
||||
// (* ... *) — may be inline or open-ended.
|
||||
if (line[i] === '(' && line[i + 1] === '*') {
|
||||
flushPending();
|
||||
const end = line.indexOf('*)', i + 2);
|
||||
if (end < 0) {
|
||||
out.push({ kind: 'comment', text: line.slice(i) });
|
||||
i = line.length;
|
||||
} else {
|
||||
out.push({ kind: 'comment', text: line.slice(i, end + 2) });
|
||||
i = end + 2;
|
||||
return { tokens: out, commentLeftOpen: true };
|
||||
}
|
||||
out.push({ kind: 'comment', text: line.slice(i, end + 2) });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
// // line comment
|
||||
|
|
@ -89,7 +108,7 @@ export function tokenize(line: string): Token[] {
|
|||
i = line.length;
|
||||
continue;
|
||||
}
|
||||
// 'single' or "double" strings (no escape handling beyond doubled quotes)
|
||||
// 'single' or "double" strings
|
||||
if (line[i] === "'" || line[i] === '"') {
|
||||
flushPending();
|
||||
const quote = line[i];
|
||||
|
|
@ -104,6 +123,21 @@ export function tokenize(line: string): Token[] {
|
|||
i++;
|
||||
}
|
||||
flushPending();
|
||||
// Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved.
|
||||
return { tokens: out, commentLeftOpen: false };
|
||||
}
|
||||
|
||||
export function tokenize(line: string): Token[] {
|
||||
return tokenizeWithState(line, false).tokens;
|
||||
}
|
||||
|
||||
/** Tokenize a contiguous block of lines, threading (* ... *) state across line boundaries. */
|
||||
export function tokenizeLines(lines: string[]): Token[][] {
|
||||
const out: Token[][] = [];
|
||||
let openComment = false;
|
||||
for (const line of lines) {
|
||||
const r = tokenizeWithState(line, openComment);
|
||||
out.push(r.tokens);
|
||||
openComment = r.commentLeftOpen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { tokenize, TokenKind } from '../../src/tui/browser/highlight';
|
||||
import { tokenize, tokenizeLines, TokenKind } from '../../src/tui/browser/highlight';
|
||||
|
||||
function kinds(line: string): TokenKind[] {
|
||||
return tokenize(line).map((t) => t.kind);
|
||||
|
|
@ -55,3 +55,36 @@ describe('tokenize', () => {
|
|||
expect(texts('s := "hi";')).toContain('"hi"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeLines', () => {
|
||||
it('treats every line of a multi-line (* ... *) block as comment', () => {
|
||||
const lines = [
|
||||
'x := 1; (* start',
|
||||
' middle keep PROGRAM unhighlighted',
|
||||
' still in comment',
|
||||
'end *) y := 2;',
|
||||
];
|
||||
const out = tokenizeLines(lines);
|
||||
// line 0: leading "x := 1; " is text/keyword-free, then "(* start" is comment
|
||||
expect(out[0].some((t) => t.kind === 'comment' && t.text.includes('(* start'))).toBe(true);
|
||||
// line 1: ENTIRE line should be a single comment token
|
||||
expect(out[1]).toEqual([{ kind: 'comment', text: ' middle keep PROGRAM unhighlighted' }]);
|
||||
// line 2: ENTIRE line is comment
|
||||
expect(out[2]).toEqual([{ kind: 'comment', text: ' still in comment' }]);
|
||||
// line 3: starts comment, then `*) y := 2;` is text after close
|
||||
expect(out[3].some((t) => t.kind === 'comment' && t.text.startsWith('end *)'))).toBe(true);
|
||||
expect(out[3].some((t) => t.kind === 'text' && t.text.includes('y := 2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles single-line input identically to tokenize', () => {
|
||||
const single = 'PROGRAM PLC_PRG';
|
||||
expect(tokenizeLines([single])).toEqual([tokenize(single)]);
|
||||
});
|
||||
|
||||
it('keeps PROGRAM keyword highlighted on a line with no open block', () => {
|
||||
const lines = ['PROGRAM A', '(* block *) END_PROGRAM'];
|
||||
const out = tokenizeLines(lines);
|
||||
expect(out[0].find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword');
|
||||
expect(out[1].find((t) => t.text === 'END_PROGRAM')?.kind).toBe('keyword');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue