Skip to content

feat(lang): TwinCAT / CODESYS (IEC 61131-3 Structured Text) support - #1810

Open
ysfsmet wants to merge 13 commits into
DeusData:mainfrom
ysfsmet:feat/twincat-iec61131-st
Open

feat(lang): TwinCAT / CODESYS (IEC 61131-3 Structured Text) support#1810
ysfsmet wants to merge 13 commits into
DeusData:mainfrom
ysfsmet:feat/twincat-iec61131-st

Conversation

@ysfsmet

@ysfsmet ysfsmet commented Aug 23, 2026

Copy link
Copy Markdown

Closes #1805.

Industrial-automation repositories built with Beckhoff TwinCAT 3 (and CODESYS-family IDEs) were entirely invisible to the indexer: PLC sources (.TcPOU, .TcDUT, .TcGVL, .TcIO) and project files (.plcproj, .tsproj) matched nothing in the extension table and were silently skipped. This adds first-class IEC 61131-3 Structured Text support, following the patterns the codebase already sanctions.

Everything below was validated against a real 130-file / 66-POU TwinCAT solution, not only fixtures — that is where most of the interesting defects came from.

What lands

Three new languages.

  • CBM_LANG_IEC_ST — the grammar language, for plain .st/.iecst sources.
  • CBM_LANG_TWINCAT — transform-only container for .TcPOU/.TcDUT/.TcGVL/.TcIO, mirroring CBM_LANG_OBJECTSCRIPT_EXPORT: internal/cbm/twincat_xml.c recomposes the <Declaration>/<Implementation><ST> CDATA into textual ST, newline-padded so definition lines still point into the physical file, and the generated units are re-extracted as CBM_LANG_IEC_ST.
  • CBM_LANG_PLCOPEN_XML — transform-only container for CODESYS/PLCopen TC6 exports, detected by a content sniff on the plcopen.org/xml namespace. PLCopen declares variables as structured XML rather than CDATA, so internal/cbm/plcopen_xml.c synthesizes the declaration text from <inputVars>/<outputVars>/<variable>.

A dedicated project-file pass (src/pipeline/pass_tcproj.c): each .plcproj becomes a Package node with DEPENDS_ON edges to its library references (Tc2_Standard, Tc3_Module, …) and CONTAINS_FILE edges to its members; a .tsproj gets DEPENDS_ON to the PLC projects it references and CONFIGURES to their .xti device descriptions. Both are registered as incremental control files.

Graph mapping. FUNCTION_BLOCK/PROGRAMClass; FUNCTIONFunction; METHOD/PROPERTYMethod with DEFINES_METHOD; INTERFACEInterface; DUT TYPEType; GVL entries → Variable; EXTENDSINHERITS; IMPLEMENTSIMPLEMENTS.

The grammar is a fork, and that was necessary

The upstream grammar (HeytalePazguato/tree-sitter-iec61131-3-st, MIT, ABI 15) is standard-only, and standard-only does not parse production TwinCAT: 49 of 130 files parsed partially, and every partial parse cost symbols — whole function blocks reached the graph without their methods.

It is forked into tools/tree-sitter-iec-st/ (grammar.js plus generated parser, same layout as tools/tree-sitter-form). Each rule was derived from a real failing construct and verified against the same corpus: modifiers after the POU keyword (FUNCTION_BLOCK PUBLIC FINAL FB_X), wildcard located addresses (AT %I*, spaced or glued, on VAR entries and DUT fields), bit-in-word access (wError.0 := TRUE), a base type after an enumerator list ((Idle, Run) UINT;), REFERENCE TO beside REF_TO, function-block instance argument lists (fb : FB_T(), ByteBuffer(ADR(x), SIZEOF(x))), the terminator TwinCAT omits after a block-shaped TYPE, attribute pragmas above members, __TRY/__CATCH/__FINALLY/__ENDTRY, ;-terminated interface prototypes, and tolerance for a stray ;.

Result on the same solution: 49 partially-parsed files → 8, and 98 more methods reach the graph. ABI stays 15; generation is reproducible (tree-sitter generate, CLI 0.26.x).

Defects found and fixed along the way

Three of these are worth calling out because they were silent — no error, no failing test, just wrong or missing data:

  • POU names became PUBLIC. The grammar bound the access modifier to the declaration's name field and dropped the real identifier into an ERROR node: 13 function blocks named PUBLIC on the real solution. Recovered structurally (header line only, so a body-level parse error can never rename its POU), which serves all three mis-bound POU kinds without needing the source buffer.
  • Project files minted one Class per XML element. Mapping .plcproj/.tsproj to CBM_LANG_XML so they would get File nodes also ran the XML grammar over their markup: 120 nodes named Project, PropertyGroup, Name, BitSize buried the 64 genuine POU classes. The definition passes now skip grammar extraction for them — in both the sequential and parallel dispatch.
  • A stale grammar object linked silently. grammar_*.c is a one-line wrapper that #includes its vendored parser, and make cannot see through an include. Harmless for grammars that never change; for a fork it meant the rebuilt binary kept the old grammar while the source tree showed the new one. Makefile.cbm now names the vendored parser as an explicit prerequisite (prod/test/tsan).

Also fixed: pass_semantic_edges passed NULL to qsort when a project has Class/Method nodes but no Function node — formal UB that UBSan flags, and a TwinCAT solution is exactly that shape.

Testing

Every registry gate is updated: the capability ledger and its partition counts, the call-argument matrix, the call-node manifest, label goldens, vendored-checksums.txt, and the MANIFEST provenance record (fork noted, so a future re-vendor re-applies the dialect rules instead of reverting them).

New tests cover the transcoders (multi-POU, declaration synthesis, graphical-body tolerance, BOM, UTF-16 rejection, entity decoding, prefix near-misses), the access-modifier recovery, the project-markup exclusion, and an end-to-end pipeline_twincat_project_graph asserting the Package/DEPENDS_ON/CONTAINS_FILE shape plus a transcoded POU's DEFINES_METHOD chain.

All suites pass; make -f Makefile.cbm cbm links.

Two notes for reviewers:

  1. tests/test_venue_parity_contract.sh hangs on any machine with ImageMagick installed — it probes scripts/ci/generate-sbom.py with bash, so import datetime runs ImageMagick's screen grabber. That is scripts/test.sh hangs indefinitely at the venue-parity contract when ImageMagick is installed (bash runs generate-sbom.py, 'import' becomes ImageMagick) #1384, not this branch; the suites here were run with DISPLAY unset to get past it.
  2. On this machine tests/test_cli.c:1748 (cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index) fails identically on pristine main — verified in a worktree at the merge base. Not introduced here.

Known limitations (documented in README)

Cross-file GVL/DUT references resolve by name rather than declared type; a function-block instance call resolves on the instance identifier rather than the block's type; inside a TwinCAT container the POU's own body line numbers can land after its methods (declaration lines are exact); PLCopen array/pointer-typed variables are dropped rather than approximated, and resource-level <globalVars> outside a POU interface are not transcoded. Eight of the 130 files still parse partially — the remaining constructs are listed in the fork's notes.

🤖 Generated with Claude Code

https://claude.ai/code/session_011BDg22SxMEYCXxGQicWgAc

ysfsmet and others added 13 commits August 23, 2026 08:46
Industrial automation repositories built with Beckhoff TwinCAT 3 were
entirely invisible to the indexer: PLC sources (.TcPOU/.TcDUT/.TcGVL/.TcIO)
and project files (.plcproj/.tsproj) matched nothing in the extension table
and were silently skipped.

Three layers, each following an existing pattern:

1. CBM_LANG_IEC_ST — vendored HeytalePazguato/tree-sitter-iec61131-3-st
   (MIT, ABI 15, pinned 00e24f50f8de) for bare .st/.iecst sources.
   FUNCTION_BLOCK/PROGRAM/INTERFACE/TYPE are class-like nodes whose
   METHOD/ACTION members are direct children, so find_class_body and
   get_module_parents get IEC_ST branches; VAR_GLOBAL blocks mint one
   Variable per declared name, and EXTENDS/IMPLEMENTS fill base_classes
   (the edge type is decided downstream from the target's label). VAR
   entries are binding sites, not usages, so declaring a variable no
   longer makes it look used.

2. CBM_LANG_TWINCAT — transform-only XML container mirroring
   CBM_LANG_OBJECTSCRIPT_EXPORT: twincat_xml.c recomposes the
   <Declaration>/<Implementation><ST> CDATA of a TcPlcObject into textual
   ST (members before the body, sniffed END_* terminator, the semicolon
   TwinCAT omits after END_STRUCT, ACTION as a parameterless METHOD),
   newline-padded so definition lines still point into the physical file.
   UTF-8 BOM is stripped; UTF-16 is rejected rather than garbled.

3. pass_tcproj.c — .plcproj becomes a Package that CONTAINS_FILE its
   Compile members and DEPENDS_ON its library placeholders; .tsproj
   DEPENDS_ON the PLC projects it references and CONFIGURES their .xti
   device descriptions. Both are incremental control files.

Registry gates updated accordingly (capability ledger, call-argument
matrix B, call-node manifest, label goldens, vendored checksums,
grammar MANIFEST provenance).

Refs: DeusData#1805

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
Add CBM_LANG_PLCOPEN_XML, a transform-only language for CODESYS/PLCopen
TC6 XML exports (<project xmlns=".../plcopen.org/xml/tc6_...">), detected
by content sniff and transcoded to textual IEC 61131-3 ST before being
extracted as CBM_LANG_IEC_ST -- the same lifecycle CBM_LANG_TWINCAT uses.
This is the last code phase of TwinCAT/CODESYS support (phases 1-3 at
c828659e: the IEC ST grammar, the TwinCAT XML transcoder, and the
.plcproj/.tsproj project pass).

Unlike TwinCAT's CDATA-wrapped ST, PLCopen declares variables as
structured XML (<inputVars>/<outputVars>/.../<variable><type>...), so
internal/cbm/plcopen_xml.c synthesizes the ST declaration text from the
XML rather than copying it, then appends the <body><ST> text verbatim
(entity-decoded). One export file holds many <pou> elements, so the
transcoder returns one ST unit per POU (plus one per <dataType> alias/
struct, which "fell out" of the same variable-list scanner). Two ST
grammar keywords (ARRAY, POINTER) can't be used as bare identifiers, so a
variable typed that way is skipped rather than emitted as text the
grammar would reject; a FUNCTION POU with no <returnType> falls back to
ANY so its header still parses.

Wiring follows the TwinCAT/Studio-Export precedent arm for arm: enum
entry, discover.c content sniff (widened from 256 bytes to 4KB so the
namespace declaration is reliably in view), LANG_NAMES/LANG_NAME_TABLE
rows, cbm_pipeline_extract_plcopen() next to cbm_pipeline_extract_twincat
reusing its append helpers, and a third dispatch arm in both the
sequential and parallel pipeline passes. No spec row is added --
cbm_lang_spec(CBM_LANG_PLCOPEN_XML) returns NULL, matching the registry
repro's transform-only ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
`git clang-format` over the branch's own changed lines only; upstream lines in
tests/test_extraction.c (which was already non-conforming before this branch)
are deliberately left alone so the diff stays reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
Update README.md's language count (158 -> 159, one new grammar: iec_st) and
add a compact subsection covering indexed file types, POU-to-graph-node
mapping, the .plcproj/.tsproj project pass, and the honest limitations of
name-based cross-file resolution and TwinCAT/PLCopen transcoding. Add a
"Transform-Only Container Languages" subsection to CONTRIBUTING.md for the
third language-support shape the branch introduced (enum value with no
grammar, transcoded to another registered language and re-extracted). Add
the missing iec_st row to the MANIFEST.md custom extraction handling table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
The iec_st grammar has no ACTION node type (iec_st_func_types/iec_st_class_types
in lang_specs.c carry nothing action-related); ACTION only reaches the graph as
a Method through the TwinCAT/PLCopen container transcoders, which synthesize it
as a parameterless METHOD before parsing (twincat_xml.c:310-314,
plcopen_xml.c:442-457). The README table and MANIFEST.md's iec_st row
overstated this as true of the grammar itself. Also fix PROPERTY: it is one
Method per property_declaration, not one per Get/Set accessor (no separate
accessor func-type in iec_st_func_types).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
Five blocking findings from final code review:

- twincat_xml.c: convert tc_elem's element-name locate step from tail
  recursion to an iterative loop (mirrors px_next_elem in
  plcopen_xml.c), so a file full of prefix near-miss tokens (e.g.
  <Declarationz>) cannot blow the stack at -O1, where GCC does not
  sibling-call-optimize the tail call away.
- twincat_xml.c: tb_pad_to now breaks out if tb_app fails to advance
  the buffer position, instead of looping forever once the output
  buffer is exhausted.
- pass_tcproj.c: resolve_ref_path uses strtok_r with a local saveptr
  instead of strtok, matching every other tokenizer call site and the
  .clang-tidy rationale that only readdir() retains global state.
- plcopen_xml.c: correct px_emit_pou's doc comment, which claimed a
  NULL-name contract the code does not implement (it always emits a
  unit, substituting "PlcopenPOU" for an empty name).
- lang_specs.c: fix the IEC ST comment's ACTION overstatement (no
  grammar row exists; <Action> is synthesized as METHOD text by the
  container transcoders) and add the missing CBM_LANG_TWINCAT
  "no spec row" comment alongside its OBJECTSCRIPT_EXPORT/PLCOPEN_XML
  siblings, per CONTRIBUTING.md.

Adds a regression test (twincat_xml_prefix_near_miss_bounded_recursion)
that feeds cbm_twincat_to_st a few hundred <Declarationz>/<STx>
near-miss tokens to prove the locate loop is now depth-bounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
scripts/vendored-checksums.txt covers every file under internal/cbm/vendored/,
including grammars/MANIFEST.md itself. The manifest was regenerated when the
iec_st grammar was vendored, but the later documentation commit added the
iec_st row to MANIFEST.md's custom-extraction table without refreshing it, so
tests/test_cli.c's vendored-integrity probe failed on a stale digest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
…edes it

TwinCAT writes access modifiers on POU headers (`FUNCTION_BLOCK PUBLIC FB_X`,
and `PUBLIC FINAL` in combination). The vendored grammar has no rule for them,
so it binds the first modifier to the declaration's `name` field and drops the
remaining tokens — including the real identifier — into an ERROR node beside it.
The graph then shows a function block literally named "PUBLIC": 13 of them on
the real 66-POU solution this was found on, with the actual block names absent.

Recover the name structurally rather than by text: the ERROR node that starts on
the header line after `name` holds the remaining identifiers, and the last of
them is the POU name. Restricting the search to the header line keeps a
body-level parse error from ever renaming its POU, and needing no source buffer
lets the same helper serve cbm_resolve_func_name (FUNCTION) and the class-name
path (FUNCTION_BLOCK/PROGRAM), all three of which the grammar mis-binds.

The class-name hook deliberately sits after the fallback switch: that switch only
runs when no name was found at all, whereas here the field is populated with the
wrong token. The parse error itself still surfaces as a partial-parse range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
.plcproj/.tsproj/.xti were mapped to CBM_LANG_XML so they would be discovered
and get File nodes for pass_tcproj to attach to. The side effect: the XML grammar
also ran over their markup, and every element became a Class node. On the real
solution this was found on that produced 120 nodes named "Project",
"PropertyGroup", "FileVersion", "Name", "Comment", "BitSize" — burying the 64
genuine POU classes and polluting both search results and the graph UI.

pass_tcproj already owns what these files mean (Package nodes, DEPENDS_ON to
library references, CONTAINS_FILE to members, CONFIGURES to .xti), so the
definition passes now skip grammar extraction for them. File nodes come from
pass_structure and are unaffected, so the project pass still finds every one.

The skip is added to BOTH the sequential and the parallel dispatch: the chains
must stay symmetric, and an omission in the parallel one would show up only on
repositories above the parallel threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
pass_semantic_edges sorts its collected Function nodes, but func_count == 0
leaves node_ptrs NULL and qsort declares its base pointer non-null, so the call
is formal UB that UBSan flags. Reachable from any project whose graph holds
Class/Method nodes but no Function node — a TwinCAT PLC solution is exactly that
shape, which is how the new pipeline_twincat_project_graph fixture surfaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
The vendored upstream grammar is standard-only, and standard-only does not parse
production TwinCAT: on a 130-file solution, 49 files parsed partially. Every
partial parse costs symbols — whole function blocks reached the graph without
their methods.

Forked into tools/tree-sitter-iec-st/ (grammar.js plus the generated parser,
same layout as tools/tree-sitter-form) rather than waiting on upstream. Each
rule below was derived from a failing construct in that corpus and verified
against it:

- access/inheritance modifiers AFTER the POU keyword, which is where TwinCAT
  writes them (`FUNCTION_BLOCK PUBLIC FINAL FB_X`)
- wildcard located addresses (`AT %I*`), spaced or glued (`AT%I*`), on VAR
  entries and on DUT fields
- bit-in-word member access (`wError.0 := TRUE`)
- a base type after an enumerator list (`(Idle, Run) UINT;`)
- `REFERENCE TO` beside the standard `REF_TO`
- function-block instance argument lists (`fb : FB_T()`,
  `ByteBuffer(ADR(x), SIZEOF(x))`), restricted to user-defined type names so
  `s : STRING(255)` stays a string length
- an optional terminator after a block-shaped TYPE definition, which TwinCAT
  omits (`END_STRUCT` straight to `END_TYPE`)
- attribute pragmas above POU members
- TwinCAT `__TRY/__CATCH/__FINALLY/__ENDTRY`
- interface method prototypes terminated by `;`
- tolerance for a stray `;` in a VAR block, so one typo cannot cost a POU's
  whole symbol set

Two GLR conflicts are declared deliberately: a pragma is ambiguous between a
member prefix and a body statement, and `__CATCH(e)` looks like a call until the
keyword is consumed.

Result on the same solution: 49 partially-parsed files down to 8, with no
regression across the existing fixtures. ABI stays 15 (CLI 0.26.x), and the
generated parser is reproducible — regenerate in tools/tree-sitter-iec-st and
copy src/parser.c + src/scanner.c into the vendored path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
…nges

grammar_*.c is a one-line wrapper that #includes its vendored parser.c and
scanner.c, and make cannot see through that include. For every other grammar the
vendored bytes never change after vendoring, so the pattern rule is enough.
iec_st is different: it is a self-maintained fork under tools/tree-sitter-iec-st,
so regenerating it really does change the vendored parser.

Without the explicit dependency the stale object is silently relinked. That is
exactly what happened during testing — the source tree carried the forked
grammar, the rebuilt binary did not, and the indexer kept reporting the old
partial-parse count with no error anywhere to explain it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
The grammar is no longer a straight upstream vendor, so the docs had to say so:
README now states that `iec_st` is a self-maintained fork, lists the dialect
forms it adds, and carries the measurement that justifies the fork (partial
parses on a 130-file production solution: 49 files down to 8). MANIFEST's
category counts move one grammar from vendored-from-upstream to
first-party/self-maintained.

CONTRIBUTING gains a "Self-Maintained Grammar Forks" section with the
regenerate-and-vendor loop, the ABI-15 ceiling check, and the reason
Makefile.cbm names the vendored parser as an explicit prerequisite: make cannot
see through the #include in grammar_*.c, so without it a regenerated grammar
links a stale object and the binary silently keeps the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yusuf S. Demirci <ysf-samet@outlook.com>
@ysfsmet
ysfsmet requested a review from DeusData as a code owner August 23, 2026 21:07
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: TwinCAT / CODESYS (IEC 61131-3 Structured Text) language support

1 participant