//! PLCopen XML → Structured Text POUs. //! //! A PLCopen project stores each POU as `` with an //! `` (typed variable sections) and a `` in one of the IEC //! 61131-3 languages. We reconstruct an equivalent Structured-Text source for //! each POU (a `VAR` block from the interface + statements from the body) and run //! it through the ST parser, so raw `.st` files and PLCopen projects — textual or //! graphical — flow through one analysis path. //! //! Body languages: //! - **ST** — taken verbatim. //! - **FBD / LD** — the graphical network is translated to synthetic ST: blocks //! become calls (`TypeName(pin := arg, …)`), out-variables / coils become //! assignments, with input pins resolved by tracing connections. This lets the //! semantic rules see comm calls, hardcoded arguments and safety writes that //! live in graphical logic, not just in text. //! - **SFC** — the step/transition graph itself is skipped; the ST/FBD/LD bodies //! embedded in its actions and transitions are still translated. use std::collections::HashMap; use roxmltree::Node; use super::ast::Pou; use super::parser; /// Parse every POU out of a PLCopen XML document (ST, FBD or LD bodies). pub fn parse_plcopen(xml: &str) -> Vec { let doc = match roxmltree::Document::parse(xml) { Ok(d) => d, Err(_) => return Vec::new(), }; let mut pous = Vec::new(); for pou in doc.descendants().filter(|n| n.has_tag_name("pou")) { let name = pou.attribute("name").unwrap_or("pou").to_string(); let pou_type = pou.attribute("pouType").unwrap_or("program"); let Some(body) = reconstruct_body(pou) else { continue; }; if body.trim().is_empty() { continue; } let var_block = build_var_block(pou); let kw = match pou_type.to_ascii_lowercase().as_str() { "function" => "FUNCTION", "functionblock" | "functionblocktype" => "FUNCTION_BLOCK", _ => "PROGRAM", }; let synthetic = format!("{kw} {name}\n{var_block}{body}\nEND_{kw}\n"); pous.extend(parser::parse(&synthetic)); } pous } /// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary). fn tag_is(n: &Node, name: &str) -> bool { n.tag_name().name().eq_ignore_ascii_case(name) } /// Reconstruct a POU's body as Structured Text, whatever language it is written /// in. Concatenates every language body found under `` (SFC actions and /// transitions carry their own ST/FBD/LD sub-bodies). fn reconstruct_body(pou: Node) -> Option { let mut out = String::new(); for body in pou.descendants().filter(|n| tag_is(n, "body")) { for lang in body.children().filter(|n| n.is_element()) { let piece = match lang.tag_name().name().to_ascii_uppercase().as_str() { "ST" | "IL" => collect_text(lang), "FBD" | "LD" => translate_network(lang), _ => continue, }; if !piece.trim().is_empty() { out.push_str(&piece); if !piece.ends_with('\n') { out.push('\n'); } } } } if out.trim().is_empty() { None } else { Some(out) } } // ── graphical (FBD / LD) → synthetic ST ──────────────────────────────── /// Translate one FBD/LD network into ST statements: blocks → calls, /// out-variables and coils → assignments. fn translate_network(net: Node) -> String { let by_id = index_local_ids(net); let mut out = String::new(); for el in net.children().filter(|n| n.is_element()) { let stmt = match el.tag_name().name().to_ascii_lowercase().as_str() { "block" => block_call(el, &by_id).map(|c| format!("{c};")), "outvariable" => out_assignment(el, &by_id), "coil" => coil_assignment(el, &by_id), _ => None, }; if let Some(s) = stmt { out.push_str(&s); out.push('\n'); } } out } /// Index every element in a network by its `localId` so connections resolve. fn index_local_ids<'a, 'input>(net: Node<'a, 'input>) -> HashMap> { net.descendants() .filter(|n| n.is_element()) .filter_map(|n| n.attribute("localId").map(|id| (id.to_string(), n))) .collect() } /// Build a call expression for a block: `TypeName(pin := arg, …)`. fn block_call(block: Node, by_id: &HashMap) -> Option { let ty = block.attribute("typeName")?; let mut args = Vec::new(); if let Some(inputs) = block.children().find(|n| tag_is(n, "inputVariables")) { for v in inputs.children().filter(|n| tag_is(n, "variable")) { let Some(expr) = input_expr(v, by_id, 0) else { continue; }; match v.attribute("formalParameter") { Some(pin) if !pin.is_empty() => args.push(format!("{pin} := {expr}")), _ => args.push(expr), } } } Some(format!("{ty}({})", args.join(", "))) } /// `target := ;` for an FBD out-variable. fn out_assignment(outvar: Node, by_id: &HashMap) -> Option { let target = expression_text(outvar)?; let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string()); Some(format!("{target} := {value};")) } /// `coil := ;` for an LD coil (negated → `NOT (…)`). fn coil_assignment(coil: Node, by_id: &HashMap) -> Option { let target = child_text(coil, "variable")?; let rung = input_expr(coil, by_id, 0).unwrap_or_else(|| "TRUE".to_string()); let negated = matches!(coil.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true")); let rhs = if negated { format!("NOT ({rung})") } else { rung }; Some(format!("{target} := {rhs};")) } /// Resolve the expression feeding `node`'s single input connection. fn input_expr(node: Node, by_id: &HashMap, depth: u8) -> Option { let refid = ref_local_id(node)?; Some(expr_for(&refid, by_id, depth)) } /// Build the ST expression produced by the element with this `localId`. fn expr_for(local_id: &str, by_id: &HashMap, depth: u8) -> String { if depth > 24 { return "0".to_string(); } let Some(node) = by_id.get(local_id) else { return format!("__net{local_id}"); }; match node.tag_name().name().to_ascii_lowercase().as_str() { "invariable" | "inoutvariable" => { expression_text(*node).unwrap_or_else(|| format!("__net{local_id}")) } // A block feeding another element: reference it by a synthetic result // name; the block is emitted as its own call statement, so we neither // duplicate the call nor lose it. "block" => format!("__blk{local_id}"), "contact" => { let var = child_text(*node, "variable").unwrap_or_else(|| "TRUE".to_string()); let negated = matches!(node.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true")); let term = if negated { format!("NOT {var}") } else { var }; match ref_local_id(*node) { Some(up) => { let upstream = expr_for(&up, by_id, depth + 1); if upstream == "TRUE" { term } else { format!("({upstream} AND {term})") } } None => term, } } "leftpowerrail" => "TRUE".to_string(), _ => format!("__net{local_id}"), } } /// The `refLocalId` of `node`'s first input connection, if any. fn ref_local_id(node: Node) -> Option { node.descendants() .find(|n| tag_is(n, "connectionPointIn")) .and_then(|cpi| cpi.descendants().find(|n| tag_is(n, "connection"))) .and_then(|c| c.attribute("refLocalId")) .map(|s| s.to_string()) } /// Text of a node's `` child (variable name or literal). fn expression_text(node: Node) -> Option { let e = node.children().find(|n| tag_is(n, "expression"))?; let t = collect_text(e).trim().to_string(); if t.is_empty() { None } else { Some(t) } } /// Text of a named child element (e.g. `` of a contact/coil). fn child_text(node: Node, name: &str) -> Option { let c = node.children().find(|n| tag_is(n, name))?; let t = collect_text(c).trim().to_string(); if t.is_empty() { None } else { Some(t) } } /// Concatenate the text of a node's descendant text nodes (bodies are often /// wrapped in `` and may contain multiple text runs). Only text nodes are /// gathered: an element's `.text()` would re-yield its first child's text, which /// (with the text node itself) would duplicate every value. fn collect_text(node: Node) -> String { node.descendants() .filter(|n| n.is_text()) .filter_map(|n| n.text()) .collect::() } /// Build an ST `VAR … END_VAR` block from a POU's `` variable /// sections, so declarations (types, initial values) reach the rules. fn build_var_block(pou: Node) -> String { let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else { return String::new(); }; let mut out = String::from("VAR\n"); let mut any = false; for container in interface.children().filter(|n| n.is_element()) { // localVars / inputVars / outputVars / inOutVars / tempVars / globalVars / externalVars if !container.tag_name().name().ends_with("Vars") { continue; } for var in container.children().filter(|n| n.has_tag_name("variable")) { let Some(vname) = var.attribute("name") else { continue; }; let ty = var .children() .find(|n| n.has_tag_name("type")) .map(type_name) .unwrap_or_else(|| "BOOL".to_string()); let init = var .children() .find(|n| n.has_tag_name("initialValue")) .and_then(initial_value); match init { Some(v) => out.push_str(&format!(" {vname} : {ty} := {v};\n")), None => out.push_str(&format!(" {vname} : {ty};\n")), } any = true; } } out.push_str("END_VAR\n"); if any { out } else { String::new() } } /// Render a PLCopen `` element as an ST type string. fn type_name(type_node: Node) -> String { let Some(inner) = type_node.children().find(|n| n.is_element()) else { return "BOOL".to_string(); }; let tag = inner.tag_name().name(); match tag { "derived" => inner.attribute("name").unwrap_or("DERIVED").to_string(), "array" => { let dim = inner.children().find(|n| n.has_tag_name("dimension")); let (lo, hi) = dim .map(|d| { ( d.attribute("lower").unwrap_or("0").to_string(), d.attribute("upper").unwrap_or("0").to_string(), ) }) .unwrap_or_else(|| ("0".to_string(), "0".to_string())); let base = inner .children() .find(|n| n.has_tag_name("baseType")) .map(type_name) .unwrap_or_else(|| "INT".to_string()); format!("ARRAY[{lo}..{hi}] OF {base}") } "string" | "wstring" => "STRING".to_string(), // BOOL, INT, DINT, REAL, TIME, ... — the tag name is the ST type. other => other.to_ascii_uppercase(), } } /// Extract an initial value as an ST literal (quoting strings). fn initial_value(iv: Node) -> Option { let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?; let raw = simple.attribute("value")?.trim().to_string(); if raw.is_empty() { return None; } // Numbers / booleans / time literals pass through; everything else is a // string literal. let is_scalar = raw.eq_ignore_ascii_case("true") || raw.eq_ignore_ascii_case("false") || raw.starts_with(['T', 't', 'D', 'd']) && raw.contains('#') || raw .chars() .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+'); if is_scalar || raw.starts_with('\'') || raw.starts_with('"') { Some(raw) } else { Some(format!("'{}'", raw.replace('\'', "''"))) } } #[cfg(test)] mod tests { use super::parse_plcopen; use crate::pipeline::plc::rules; use std::collections::HashSet; fn rule_ids(xml: &str) -> HashSet<&'static str> { parse_plcopen(xml) .iter() .flat_map(rules::analyze) .map(|h| h.rule_id) .collect() } /// A Ladder Diagram network: a rung (power rail → contact → coil) plus an /// insecure comm block. Coils/contacts translate to assignments; the block /// translates to a call so the port rule fires. #[test] fn ld_coil_and_block_translate_and_are_analysed() { let xml = r#" Start Motor 21 FALSE "#; let ids = rule_ids(xml); // Ftp_Send(PORT := 21, ENCRYPT := FALSE) — port 21 is an insecure protocol. assert!( ids.contains("plc-insecure-protocol-port"), "LD block should flag port 21; got {ids:?}" ); } /// Doubled-text regression: a graphical expression must be extracted once, /// so literals like `502` and `FALSE` stay intact (not `502502`/`FALSEFALSE`). #[test] fn graphical_expression_text_is_not_duplicated() { let xml = r#" 502 FALSE "#; let ids = rule_ids(xml); assert!(ids.contains("plc-insecure-protocol-port")); // PORT := 502 (not 502502) assert!(ids.contains("plc-insecure-comm")); // AUTH := FALSE (not FALSEFALSE) } }