tui(viewer): ST keyword/type/comment/string syntax highlighting
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).
This commit is contained in:
parent
e6d833ea1a
commit
3da09f8aba
3 changed files with 190 additions and 1 deletions
|
|
@ -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<TokenKind, string | undefined> = {
|
||||
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 (
|
||||
<Text>
|
||||
{tokens.map((t, i) => (
|
||||
<Text key={i} color={COLORS[t.kind]}>
|
||||
{t.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ViewerProps {
|
||||
pou: POU | null;
|
||||
|
|
@ -26,7 +48,8 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
|
|||
</Text>
|
||||
{slice.map((l, i) => (
|
||||
<Text key={scrollTop + i}>
|
||||
{String(scrollTop + i + 1).padStart(4, ' ')} {l}
|
||||
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
|
||||
<HighlightedLine line={l} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
|
|
|||
109
src/tui/browser/highlight.ts
Normal file
109
src/tui/browser/highlight.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
57
tests/tui/highlight.test.ts
Normal file
57
tests/tui/highlight.test.ts
Normal file
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue