feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) (#162)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
This commit was merged in pull request #162.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//! Lexer for IEC 61131-3 Structured Text.
|
||||
//!
|
||||
//! Tokenizes ST source into a flat token stream with 1-based line numbers.
|
||||
//! Keywords are case-insensitive. Handles `(* *)` and `//` comments, `'..'` and
|
||||
//! `".."` strings (with `''`/`""` escapes), based integers (`16#FF`, `2#1010`),
|
||||
//! and duration/date literals (`T#5s`, `DT#...`) kept as raw text.
|
||||
|
||||
/// A lexed token with its source line.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub kind: Tok,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Tok {
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Str(String),
|
||||
Time(String),
|
||||
Bool(bool),
|
||||
Ident(String),
|
||||
Kw(Keyword),
|
||||
Assign, // :=
|
||||
Plus, // +
|
||||
Minus, // -
|
||||
Star, // *
|
||||
Slash, // /
|
||||
Power, // **
|
||||
LParen, // (
|
||||
RParen, // )
|
||||
LBrack, // [
|
||||
RBrack, // ]
|
||||
Dot, // .
|
||||
DotDot, // ..
|
||||
Comma, // ,
|
||||
Semi, // ;
|
||||
Colon, // :
|
||||
Lt, // <
|
||||
Le, // <=
|
||||
Gt, // >
|
||||
Ge, // >=
|
||||
Eq, // =
|
||||
Ne, // <>
|
||||
Amp, // &
|
||||
Eof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Keyword {
|
||||
Program,
|
||||
EndProgram,
|
||||
Function,
|
||||
EndFunction,
|
||||
FunctionBlock,
|
||||
EndFunctionBlock,
|
||||
Var,
|
||||
VarInput,
|
||||
VarOutput,
|
||||
VarInOut,
|
||||
VarGlobal,
|
||||
VarTemp,
|
||||
VarExternal,
|
||||
Constant,
|
||||
EndVar,
|
||||
Array,
|
||||
Of,
|
||||
If,
|
||||
Then,
|
||||
Elsif,
|
||||
Else,
|
||||
EndIf,
|
||||
Case,
|
||||
EndCase,
|
||||
For,
|
||||
To,
|
||||
By,
|
||||
Do,
|
||||
EndFor,
|
||||
While,
|
||||
EndWhile,
|
||||
Repeat,
|
||||
Until,
|
||||
EndRepeat,
|
||||
Return,
|
||||
Exit,
|
||||
Jmp,
|
||||
Not,
|
||||
And,
|
||||
Or,
|
||||
Xor,
|
||||
Mod,
|
||||
Type,
|
||||
EndType,
|
||||
Struct,
|
||||
EndStruct,
|
||||
}
|
||||
|
||||
fn keyword_from(word: &str) -> Option<Keyword> {
|
||||
use Keyword::*;
|
||||
Some(match word.to_ascii_uppercase().as_str() {
|
||||
"PROGRAM" => Program,
|
||||
"END_PROGRAM" => EndProgram,
|
||||
"FUNCTION" => Function,
|
||||
"END_FUNCTION" => EndFunction,
|
||||
"FUNCTION_BLOCK" => FunctionBlock,
|
||||
"END_FUNCTION_BLOCK" => EndFunctionBlock,
|
||||
"VAR" => Var,
|
||||
"VAR_INPUT" => VarInput,
|
||||
"VAR_OUTPUT" => VarOutput,
|
||||
"VAR_IN_OUT" => VarInOut,
|
||||
"VAR_GLOBAL" => VarGlobal,
|
||||
"VAR_TEMP" => VarTemp,
|
||||
"VAR_EXTERNAL" => VarExternal,
|
||||
"CONSTANT" => Constant,
|
||||
"END_VAR" => EndVar,
|
||||
"ARRAY" => Array,
|
||||
"OF" => Of,
|
||||
"IF" => If,
|
||||
"THEN" => Then,
|
||||
"ELSIF" => Elsif,
|
||||
"ELSE" => Else,
|
||||
"END_IF" => EndIf,
|
||||
"CASE" => Case,
|
||||
"END_CASE" => EndCase,
|
||||
"FOR" => For,
|
||||
"TO" => To,
|
||||
"BY" => By,
|
||||
"DO" => Do,
|
||||
"END_FOR" => EndFor,
|
||||
"WHILE" => While,
|
||||
"END_WHILE" => EndWhile,
|
||||
"REPEAT" => Repeat,
|
||||
"UNTIL" => Until,
|
||||
"END_REPEAT" => EndRepeat,
|
||||
"RETURN" => Return,
|
||||
"EXIT" => Exit,
|
||||
"JMP" => Jmp,
|
||||
"NOT" => Not,
|
||||
"AND" => And,
|
||||
"OR" => Or,
|
||||
"XOR" => Xor,
|
||||
"MOD" => Mod,
|
||||
"TYPE" => Type,
|
||||
"END_TYPE" => EndType,
|
||||
"STRUCT" => Struct,
|
||||
"END_STRUCT" => EndStruct,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tokenize `src`. Unknown characters are skipped (best-effort — a scanner must
|
||||
/// not die on odd input).
|
||||
pub fn lex(src: &str) -> Vec<Token> {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut i = 0usize;
|
||||
let mut line = 1u32;
|
||||
let mut out = Vec::new();
|
||||
|
||||
let bump_line = |c: char, line: &mut u32| {
|
||||
if c == '\n' {
|
||||
*line += 1;
|
||||
}
|
||||
};
|
||||
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
|
||||
// Whitespace.
|
||||
if c.is_whitespace() {
|
||||
bump_line(c, &mut line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line comment: //
|
||||
if c == '/' && i + 1 < chars.len() && chars[i + 1] == '/' {
|
||||
while i < chars.len() && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block comment: (* ... *)
|
||||
if c == '(' && i + 1 < chars.len() && chars[i + 1] == '*' {
|
||||
i += 2;
|
||||
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == ')') {
|
||||
bump_line(chars[i], &mut line);
|
||||
i += 1;
|
||||
}
|
||||
i = (i + 2).min(chars.len());
|
||||
continue;
|
||||
}
|
||||
|
||||
let tok_line = line;
|
||||
|
||||
// String literal: '...' or "..."
|
||||
if c == '\'' || c == '"' {
|
||||
let quote = c;
|
||||
i += 1;
|
||||
let mut s = String::new();
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
if ch == quote {
|
||||
// Doubled quote is an escaped quote.
|
||||
if i + 1 < chars.len() && chars[i + 1] == quote {
|
||||
s.push(quote);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
bump_line(ch, &mut line);
|
||||
s.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
out.push(Token {
|
||||
kind: Tok::Str(s),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword / time literal / boolean.
|
||||
if c.is_ascii_alphabetic() || c == '_' {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
|
||||
// Duration/date/time literal prefix: T#, TIME#, DT#, D#, TOD#, LT# ...
|
||||
if i < chars.len() && chars[i] == '#' {
|
||||
let up = word.to_ascii_uppercase();
|
||||
if matches!(
|
||||
up.as_str(),
|
||||
"T" | "TIME" | "DT" | "D" | "TOD" | "LT" | "DATE"
|
||||
) {
|
||||
let lit_start = start;
|
||||
i += 1; // consume '#'
|
||||
while i < chars.len()
|
||||
&& (chars[i].is_ascii_alphanumeric()
|
||||
|| chars[i] == '.'
|
||||
|| chars[i] == '_'
|
||||
|| chars[i] == ':')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
let lit: String = chars[lit_start..i].iter().collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Time(lit),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let kind = match word.to_ascii_uppercase().as_str() {
|
||||
"TRUE" => Tok::Bool(true),
|
||||
"FALSE" => Tok::Bool(false),
|
||||
_ => match keyword_from(&word) {
|
||||
Some(kw) => Tok::Kw(kw),
|
||||
None => Tok::Ident(word),
|
||||
},
|
||||
};
|
||||
out.push(Token {
|
||||
kind,
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number: decimal, real, or based (16#..., 2#...).
|
||||
if c.is_ascii_digit() {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
// Based literal: <base>#<digits>
|
||||
if i < chars.len() && chars[i] == '#' {
|
||||
let base_str: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
i += 1;
|
||||
let dstart = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let digits: String = chars[dstart..i].iter().filter(|c| **c != '_').collect();
|
||||
let radix = base_str.parse::<u32>().unwrap_or(10);
|
||||
let val = i64::from_str_radix(&digits, radix.clamp(2, 36)).unwrap_or(0);
|
||||
out.push(Token {
|
||||
kind: Tok::Int(val),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Real: has a '.' (not '..') or exponent.
|
||||
let is_real =
|
||||
i < chars.len() && chars[i] == '.' && !(i + 1 < chars.len() && chars[i + 1] == '.');
|
||||
if is_real {
|
||||
i += 1;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Real(raw.parse().unwrap_or(0.0)),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Int(raw.parse().unwrap_or(0)),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators / punctuation (longest match first).
|
||||
let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
|
||||
let kind = match two.as_str() {
|
||||
":=" => Some(Tok::Assign),
|
||||
"<=" => Some(Tok::Le),
|
||||
">=" => Some(Tok::Ge),
|
||||
"<>" => Some(Tok::Ne),
|
||||
".." => Some(Tok::DotDot),
|
||||
"**" => Some(Tok::Power),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = kind {
|
||||
out.push(Token {
|
||||
kind: k,
|
||||
line: tok_line,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let one = match c {
|
||||
'+' => Some(Tok::Plus),
|
||||
'-' => Some(Tok::Minus),
|
||||
'*' => Some(Tok::Star),
|
||||
'/' => Some(Tok::Slash),
|
||||
'(' => Some(Tok::LParen),
|
||||
')' => Some(Tok::RParen),
|
||||
'[' => Some(Tok::LBrack),
|
||||
']' => Some(Tok::RBrack),
|
||||
'.' => Some(Tok::Dot),
|
||||
',' => Some(Tok::Comma),
|
||||
';' => Some(Tok::Semi),
|
||||
':' => Some(Tok::Colon),
|
||||
'<' => Some(Tok::Lt),
|
||||
'>' => Some(Tok::Gt),
|
||||
'=' => Some(Tok::Eq),
|
||||
'&' => Some(Tok::Amp),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = one {
|
||||
out.push(Token {
|
||||
kind: k,
|
||||
line: tok_line,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.push(Token {
|
||||
kind: Tok::Eof,
|
||||
line,
|
||||
});
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user