From 3da09f8aba137102bb76ee83fe41580682e319c5 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:29:40 +0200 Subject: [PATCH] tui(viewer): ST keyword/type/comment/string syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokenize(line) splits a single line into typed tokens: keyword (cyan) PROGRAM, FUNCTION_BLOCK, IF/THEN/ELSE/END_IF, FOR/TO/DO, VAR/END_VAR, CASE/OF, AND/OR/XOR/NOT, TRUE/FALSE, EXTENDS, IMPLEMENTS, etc. type (magenta) BOOL, INT, DINT, REAL, LREAL, TIME, STRING, ANY_*, ARRAY, POINTER, REFERENCE, etc. comment (gray) (* ... *) inline and // line-to-end string (yellow) 'single' and "double" quoted text (none) everything else Identifier matching is case-sensitive uppercase only — matches typical IEC 61131-3 convention and avoids false positives on lower- case identifiers like if_x. Multi-line (* ... *) comments are not joined across lines (out-of-scope for v0.2). --- src/tui/browser/Viewer.tsx | 25 +++++++- src/tui/browser/highlight.ts | 109 +++++++++++++++++++++++++++++++++++ tests/tui/highlight.test.ts | 57 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/tui/browser/highlight.ts create mode 100644 tests/tui/highlight.test.ts diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index a2cc331..657d121 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -1,6 +1,28 @@ import React from 'react'; import { Box, Text } from 'ink'; import { POU } from '../shared/types.js'; +import { tokenize, TokenKind } from './highlight.js'; + +const COLORS: Record = { + keyword: 'cyan', + type: 'magenta', + comment: 'gray', + string: 'yellow', + text: undefined, +}; + +function HighlightedLine({ line }: { line: string }): React.ReactElement { + const tokens = React.useMemo(() => tokenize(line), [line]); + return ( + + {tokens.map((t, i) => ( + + {t.text} + + ))} + + ); +} export interface ViewerProps { pou: POU | null; @@ -26,7 +48,8 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac {slice.map((l, i) => ( - {String(scrollTop + i + 1).padStart(4, ' ')} {l} + {String(scrollTop + i + 1).padStart(4, ' ')} + ))} diff --git a/src/tui/browser/highlight.ts b/src/tui/browser/highlight.ts new file mode 100644 index 0000000..61dccb6 --- /dev/null +++ b/src/tui/browser/highlight.ts @@ -0,0 +1,109 @@ +export type TokenKind = 'keyword' | 'type' | 'comment' | 'string' | 'text'; + +export interface Token { + kind: TokenKind; + text: string; +} + +const KEYWORDS = new Set([ + 'PROGRAM', 'END_PROGRAM', + 'FUNCTION', 'END_FUNCTION', + 'FUNCTION_BLOCK', 'END_FUNCTION_BLOCK', + 'METHOD', 'END_METHOD', + 'PROPERTY', 'END_PROPERTY', + 'INTERFACE', 'END_INTERFACE', + 'STRUCT', 'END_STRUCT', + 'TYPE', 'END_TYPE', + 'ACTION', 'END_ACTION', + 'VAR', 'VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONFIG', 'VAR_EXTERNAL', 'VAR_STAT', 'END_VAR', + 'IF', 'THEN', 'ELSIF', 'ELSE', 'END_IF', + 'CASE', 'OF', 'END_CASE', + 'FOR', 'TO', 'BY', 'DO', 'END_FOR', + 'WHILE', 'END_WHILE', + 'REPEAT', 'UNTIL', 'END_REPEAT', + 'RETURN', 'EXIT', 'CONTINUE', + 'AND', 'OR', 'XOR', 'NOT', 'MOD', + 'TRUE', 'FALSE', + 'CONSTANT', 'RETAIN', 'PERSISTENT', 'ABSTRACT', 'FINAL', 'PUBLIC', 'PRIVATE', 'PROTECTED', 'INTERNAL', + 'EXTENDS', 'IMPLEMENTS', 'GET', 'SET', 'REFERENCE', 'POINTER', 'ARRAY', + 'WITH', 'AT', + 'SUPER', 'THIS', +]); + +const TYPES = new Set([ + 'BOOL', 'BYTE', 'WORD', 'DWORD', 'LWORD', + 'SINT', 'INT', 'DINT', 'LINT', + 'USINT', 'UINT', 'UDINT', 'ULINT', + 'REAL', 'LREAL', + 'TIME', 'LTIME', 'DATE', 'TIME_OF_DAY', 'TOD', 'LTOD', 'DATE_AND_TIME', 'DT', 'LDT', + 'STRING', 'WSTRING', 'CHAR', 'WCHAR', + 'ANY', 'ANY_BIT', 'ANY_INT', 'ANY_NUM', 'ANY_REAL', 'ANY_STRING', +]); + +const IDENT_RE = /[A-Z_][A-Z0-9_]*/; + +export function tokenize(line: string): Token[] { + const out: Token[] = []; + let i = 0; + 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; + while ((m = re.exec(pending)) !== null) { + const word = m[0]; + const start = m.index; + if (start > last) out.push({ kind: 'text', text: pending.slice(last, start) }); + const kind: TokenKind = KEYWORDS.has(word) ? 'keyword' : TYPES.has(word) ? 'type' : 'text'; + out.push({ kind, text: word }); + last = start + word.length; + } + if (last < pending.length) out.push({ kind: 'text', text: pending.slice(last) }); + pending = ''; + }; + + while (i < line.length) { + // (* ... *) inline comment + 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; + } + continue; + } + // // line comment + if (line[i] === '/' && line[i + 1] === '/') { + flushPending(); + out.push({ kind: 'comment', text: line.slice(i) }); + i = line.length; + continue; + } + // 'single' or "double" strings (no escape handling beyond doubled quotes) + if (line[i] === "'" || line[i] === '"') { + flushPending(); + const quote = line[i]; + let end = i + 1; + while (end < line.length && line[end] !== quote) end++; + const close = end < line.length ? end + 1 : end; + out.push({ kind: 'string', text: line.slice(i, close) }); + i = close; + continue; + } + pending += line[i]; + i++; + } + flushPending(); + // Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved. + return out; +} diff --git a/tests/tui/highlight.test.ts b/tests/tui/highlight.test.ts new file mode 100644 index 0000000..b0f7b73 --- /dev/null +++ b/tests/tui/highlight.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { tokenize, TokenKind } from '../../src/tui/browser/highlight'; + +function kinds(line: string): TokenKind[] { + return tokenize(line).map((t) => t.kind); +} + +function texts(line: string): string[] { + return tokenize(line).map((t) => t.text); +} + +describe('tokenize', () => { + it('returns a single text token for plain identifiers', () => { + expect(tokenize('foo bar baz')).toEqual([{ kind: 'text', text: 'foo bar baz' }]); + }); + + it('flags ST keywords (uppercase)', () => { + const ts = tokenize('PROGRAM PLC_PRG'); + expect(ts.find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword'); + expect(ts.find((t) => t.text === 'PLC_PRG')?.kind).toBe('text'); + }); + + it('flags END_VAR / END_IF compound keywords', () => { + const ts = tokenize('END_VAR END_IF END_FOR'); + expect(ts.filter((t) => t.kind === 'keyword').map((t) => t.text)).toEqual([ + 'END_VAR', + 'END_IF', + 'END_FOR', + ]); + }); + + it('flags IEC types', () => { + const ts = tokenize('counter : INT := 0;'); + expect(ts.find((t) => t.text === 'INT')?.kind).toBe('type'); + }); + + it('does not flag lowercase variants of keywords', () => { + const ts = tokenize('program plc_prg'); + expect(ts.every((t) => t.kind !== 'keyword')).toBe(true); + }); + + it('captures (* ... *) comments inline', () => { + expect(kinds('x := 1; (* a comment *) y := 2;')).toContain('comment'); + expect(texts('x := 1; (* a comment *) y := 2;')).toContain('(* a comment *)'); + }); + + it('captures // line comments to end of line', () => { + const ts = tokenize('x := 1; // trailing'); + expect(ts.at(-1)?.kind).toBe('comment'); + expect(ts.at(-1)?.text).toBe('// trailing'); + }); + + it("captures 'single-quoted' and \"double-quoted\" strings", () => { + expect(texts("s := 'hello';")).toContain("'hello'"); + expect(texts('s := "hi";')).toContain('"hi"'); + }); +});