Compare commits

...
10 Commits
Author SHA1 Message Date
lazar bd50f7b459 fix: update tree-sitter parser URL to Gitea
Change the default ts_parser_url from the local file:// path to the
Gitea-hosted tia-lsp repository, so :TSInstall scl works out of the
box after the repo split.
2026-07-18 11:49:16 +02:00
lazar 8255db7f64 chore: adjust for plugin-only scope after split
After splitting from the original scl_lsp repo, update for plugin-only scope:

- README.md: focused on Neovim plugin installation, configuration,
  commands, keybindings, and editor features
- AGENTS.md: plugin-only project structure and conventions
- test/run_tests.lua: run only plugin tests (udt, db, fb, integration)
- test/test_integration.lua: remove plc_json dependency (server module)
- queries/: move from queries/*.scm to queries/scl/*.scm (nvim-treesitter
  per-language convention)
- ftdetect/scl.vim: add filetype detection for .scl/.udt/.db files
2026-07-18 11:46:18 +02:00
lazar 8c6a050efb refactor: rename scl_lsp → tia_lsp (project umbrella for future STL/LAD/FBD)
Rename the project from scl_lsp to tia_lsp to future-proof for additional
TIA Portal languages (STL/AWL, LAD, FBD). The 'scl' language name is kept
as-is for the SCL filetype and tree-sitter parser; future languages get
their own names (stl, lad, fbd).

Project-wide changes:
- lua/scl_lsp/ → lua/tia_lsp/  (plugin entry point)
- lua/scl/     → lua/tia/      (language modules, shared across TIA langs)
- require('scl.*')    → require('tia.*')
- require('scl_lsp')  → require('tia_lsp')
- LSP client name:    'scl_lsp' → 'tia_lsp'
- Diagnostic source:  'scl_lsp' → 'tia_lsp'  (src/diagnostics.lua)
- package.json name:  'scl-language-server' → 'tia-lsp'

lua/tia_lsp/init.lua:
- Add vim.fn.exepath('tia-lsp') detection so the plugin prefers the
  mason-installed executable and falls back to { 'lua', server_path }
  for manual installs.
- Add opts.ts_parser_url to configure the tree-sitter parser source.
- Remove hardcoded ~/dev/scl_lsp paths in favor of ~/dev/tia-lsp and
  the new ts_parser_url option.

Cleanup:
- Delete lua/tia/init.lua (dead code, unreferenced duplicate of tia_lsp).
- Delete scl_lsp.sh (replaced by the mason-installed bin/tia-lsp wrapper).
- Update README.md and AGENTS.md to reflect the two-repo split
  (tia-lsp server + tia-lsp.nvim plugin) and document Mason installation.

Unchanged (intentional):
- grammar.js, src/grammar.json, src/parser.c: tree-sitter language name
  remains 'scl' (it's the language, not the project).
- Filetype patterns ('scl', 'udt', 'db') in autocmds.
- :SCL* command names (per-filetype prefix; future :STL* etc.).

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
2026-07-18 11:30:55 +02:00
lazar 137eda3e77 fix: Attribute block expand/collapse, TIME literal diagnostics, and formatter bugs
- SCLFormat: Remove clear_buffer_state() call that was preventing
       expand after formatting (attr_toggle state now preserved)
     - Diagnostics: Fix false 'Undefined variable' for IEC literals
       like T#2s, D#1d, S5T#1s (TIME, DATE, S5TIME types)
     - Formatter: Fix block comments (* ... *) being misidentified as
       FB calls in VAR_IN_OUT/VAR_INPUT/VAR_OUTPUT sections
     - Formatter: Fix array-based FB calls like instTimers[idx].TON()
       not being properly formatted with aligned parameters
2026-02-24 15:27:58 +01:00
lazar 15b0e5360a docs: encourage adding comments; fix nested IF/ELSIF indentation
- Update AGENTS.md to encourage comments instead of forbidding them
- Add docstrings to all public functions in src/ modules
- Add module headers and function comments to lua/scl/ modules
- Fix formatter to properly indent multi-line IF/ELSIF conditions
- Fix FB call detection to not match IF statements with parentheses
2026-02-23 17:40:44 +01:00
lazar bfdec0c4b1 test: add formatter tests
- Add comprehensive formatter test suite with 38 tests
- Test multiline assignments, FB calls, control structures
- Test IF, FOR, WHILE, CASE, REPEAT statements
- Test regions, structs, var sections
- Update AGENTS.md with formatter test command
2026-02-23 14:16:46 +01:00
lazar 4471d9e1d9 Merge branch 'feat/testing_framework' into develop 2026-02-23 13:36:49 +01:00
lazar eb10b1c0df feat: add testing framework with reference project integration
- Add test/ directory with test harness and test files
- Add UDT, DB, FB parser tests
- Add PLC JSON and integration tests
- Test against reference project (239 UDTs, 12 DBs, 37 SCL, 13 XML)
- Fix attribute parsing to handle underscores (S7_SetPoint)
- Add ORGANIZATION_BLOCK support to FB parser
2026-02-23 13:29:38 +01:00
lazar 5ca5628d39 Merge branch 'feat/scl_formatter' into develop 2026-02-23 10:30:32 +01:00
lazar 3dfca6bba3 feat: improve formatter and attribute block handling
- Format multi-line assignments with proper alignment
- Format FB calls in multiline style with parameter alignment
- Auto-expand collapsed attribute blocks before save
- Rename command from LspSCLFormat to SCLFormat
- Condense AGENTS.md and add testing section with reference project
2026-02-23 10:29:05 +01:00
28 changed files with 1548 additions and 870 deletions
+59 -304
View File
@@ -1,41 +1,31 @@
# AGENTS.md - SCL Language Server
# AGENTS.md - tia-lsp.nvim
A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion.
Neovim plugin for Siemens TIA Portal languages. Currently supports **SCL**; planned support for STL/AWL, LAD, FBD. This repo is the plugin only (`lua/` + tree-sitter queries). The companion LSP server lives in `tia-lsp`.
## Build/Lint/Test Commands
```bash
# LSP Server
make start # Start LSP server
make test # Run LSP in test mode
lua src/main.lua --test # Direct test execution
# Tree-sitter Parser
tree-sitter generate # Generate parser from grammar.js
tree-sitter test # Run parser tests
tree-sitter parse <file.scl> # Parse single SCL file
# Lua Linting
luacheck src/ # Lint LSP server code
luacheck lua/scl/ # Lint plugin modules
luacheck lua/tia/ # Lint language modules
luacheck lua/tia_lsp/ # Lint plugin entry point
# Unit & Integration Tests
lua test/run_tests.lua # Run all plugin tests
lua test/test_udt.lua # Run UDT parser tests
lua test/test_db.lua # Run DB parser tests
lua test/test_fb.lua # Run FB parser tests
lua test/test_integration.lua # Run integration tests
# Reference Project
# Tests use files from: ~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/
# Override with: SCL_REF_PROJECT=/path/to/ref lua tests/run_tests.lua
```
## Project Structure
```
scl_lsp/
├── src/ # LSP server (uses dofile)
│ ├── main.lua # Entry point, JSON-RPC protocol
│ ├── parser.lua # SCL parser
│ ├── diagnostics.lua # Linter
│ ├── formatter.lua # Document formatter
│ ├── treesitter.lua # Tree-sitter integration
│ ├── plc_json.lua # External UDT/DB loading
│ ├── builtin_instructions.lua # TIA Portal built-in types/FBs/functions
│ └── json.lua # JSON encoder/decoder
├── lua/scl/ # Neovim plugin (uses require)
│ ├── init.lua # Main plugin setup
│ ├── blink_cmp_source.lua
tia-lsp.nvim/
├── lua/tia/ # Language modules (uses require)
│ ├── udt_parser.lua # UDT parser
│ ├── db_parser.lua # Global DB parser
│ ├── fb_parser.lua # Function Block parser
@@ -43,34 +33,36 @@ scl_lsp/
│ ├── workspace_types.lua # Workspace scanning
│ ├── multiline_params.lua
│ ├── auto_prefix.lua
│ └── attr_toggle.lua # Interactive attribute block toggle
├── queries/ # Tree-sitter queries
│ ├── highlights.scm
── indents.scm
│ └── folds.scm
└── grammar.js # Tree-sitter grammar
│ └── attr_toggle.lua
├── lua/tia_lsp/ # Plugin entry point
│ ├── init.lua # Main plugin setup (vim.lsp.start)
── plugins/scl.lua # LazyVim plugin spec
├── queries/scl/ # Tree-sitter queries (per-language)
└── ftdetect/scl.vim # Filetype detection
```
## Naming Convention
- **Project name** (repo, Lua module): `tia-lsp.nvim` / `tia_lsp` — umbrella for all TIA languages.
- **Language name** (filetype, tree-sitter parser): `scl` — stays as-is. Future languages (`stl`, `lad`, `fbd`) get their own names.
- **LSP client name**: `tia_lsp` (one client handles all TIA languages; dispatches by `filetype` / `languageId`).
- **Commands**: `:SCLFormat`, `:SCLShowVariables`, etc. — per-filetype prefix. Future: `:STLFormat`, etc.
## Filetype Detection
The LSP requires the `scl` filetype to be set. Add to your Neovim config (e.g., `ftdetect/scl.vim`):
`ftdetect/scl.vim` registers the following extensions as `scl` filetype:
```vim
au BufRead,BufNewFile *.scl set filetype=scl
au BufRead,BufNewFile *.udt set filetype=scl
au BufRead,BufNewFile *.db set filetype=scl
```
This enables LSP features (completion, goto definition, hover, etc.) for all SCL-related file types.
## Code Style Guidelines
### Module Pattern
```lua
-- File header comment
local M = {}
-- Private cache
local cache = {}
function M.public_function()
@@ -89,16 +81,18 @@ return M
- Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`)
### Imports
- `lua/scl/` modules: use `require("scl.module_name")`
- `src/` modules: use `dofile(script_path .. "/module.lua")`
- `lua/tia/` modules: `require("tia.module_name")`
- `lua/tia_lsp/` entry: `require("tia_lsp")`
- External dependencies: wrap in `pcall()` for safety
### Code Formatting
- `lua/scl/`: 2 spaces indentation
- `src/`: tab-based indentation
- `lua/tia/` and `lua/tia_lsp/`: 2 spaces indentation
- Max line length: 120 characters
- No trailing whitespace
### Comments
Add comments to explain non-obvious code sections, complex logic, and public API functions. Keep comments concise and relevant.
### Parser Module API
All parser modules must implement:
- `parse_*_file(filepath)` - Parse and cache
@@ -121,291 +115,52 @@ function M.clear_cache()
end
```
## Lua Reserved Keywords
Cannot use reserved keywords as table keys directly:
```lua
-- WRONG: syntax error
local range = { start = pos1, end = pos2 }
-- CORRECT: bracket notation
local range = { start = pos1, ["end"] = pos2 }
```
Critical for LSP range objects with `end` field.
## Error Handling
### Return Pattern
### Error Handling
```lua
function M.parse_file(filepath)
local file = io.open(filepath, "r")
if not file then
return nil, "Cannot open file: " .. filepath
end
-- ... parse logic
return result
end
```
### External Dependencies
```lua
-- External dependencies
local ok, module = pcall(require, "some_module")
if ok and module then
module.do_something()
end
```
## Diagnostics
The LSP validates data types and provides warnings for unknown types. It recognizes:
- **Elementary data types** - BOOL, BYTE, WORD, DWORD, LWORD, CHAR, WCHAR, STRING, WSTRING, INT, DINT, LINT, USINT, SINT, UINT, UDINT, ULINT, REAL, LREAL, TIME, DATE, TOD, TIME_OF_DAY, DATE_AND_TIME, DT, S5TIME, LTIME, DTL
- **Timer/counter types** - IEC_TIMER, TON_TIME, TOF_TIME, TONR_TIME, TP_TIME, CTU, CTD, CTUD
- **TIA Portal built-in instructions** - Functions and function blocks
- **Workspace types** - UDTs from `.udt` files and data blocks from `.db` files
## Completion Triggers
- `#` - Local variables (after BEGIN)
- `.` - Member access (UDT/DB)
- `"` - Global DB names
- `(` - FB/Function parameters
- ` ` (space) - General completion
- `:` - Type declaration (in VAR section)
### Completion Types
The LSP provides auto-completion for:
- **Local variables** - Variables declared in VAR sections
- **UDT types** - User-defined types from `.udt` files
- **Global DBs** - Data blocks from `.db` files
- **Elementary types** - BOOL, INT, DINT, REAL, TIME, etc.
- **TIA Portal built-in FBs** - TON, TOF, TP, CTU, CTD, CTUD, R_TRIG, etc.
- **TIA Portal built-in functions** - ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
## Formatter Options
The formatter supports collapsing verbose attribute blocks:
## Lua Reserved Keywords
Cannot use reserved keywords as table keys directly:
```lua
-- Before formatting:
statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int;
-- WRONG
local range = { start = pos1, end = pos2 }
-- After formatting:
statCntrPartsIn{...} : Int;
-- CORRECT
local range = { start = pos1, ["end"] = pos2 }
```
### Formatter Configuration
```lua
local options = {
insertSpaces = false,
tabSize = 1,
collapseAttributes = false, -- Disabled by default (see note below)
collapsePatterns = { -- Override default patterns
"^%s*{%s*EXTERNAL",
"^%s*{ S7_",
},
extendCollapsePatterns = { -- Add custom patterns
"^%s*{%s*CUSTOM",
},
}
```
Critical for LSP range objects with `end` field.
**Note:** `collapseAttributes` is disabled by default to preserve compatibility with the interactive toggle feature (`:SCLToggleAttrBlock`). When the formatter automatically collapses blocks, the original content is lost. To use both formatting and toggle:
1. Format with `:LspSCLFormat` (preserves original content)
2. Manually collapse with `:SCLCollapseAllAttrBlocks` (stores original content)
3. Now toggle/expand works correctly
## Type System
### Default Collapse Patterns
The `:SCLCollapseAllAttrBlocks` command uses case-insensitive patterns:
- `EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'}`
- `S7_` - Block attributes: `{S7_Optimized_Access := 'TRUE'}`
- `NonRetain` - NonRetain attribute: `{NonRetain}`
- `Retain` - Retain attribute: `{Retain}`
- `%w+ :=` - Generic attributes with assignments: `{key := 'value'}`
- `%w+` - Single word attributes: `{SomeAttribute}`
Works in `.scl`, `.udt`, and `.db` files.
### Per-Variable Collapse Rules
Control collapsing per variable using rules (evaluated before global patterns):
```lua
local options = {
collapseVariableRules = {
-- Collapse attributes for variables starting with "stat"
{
variablePattern = "^stat",
collapse = true,
},
-- Never collapse for "temp" prefix variables
{
variablePattern = "^temp",
collapse = false,
},
-- Collapse only if both variable AND attribute match
{
variablePattern = "^config",
attributePattern = "EXTERNAL",
collapse = true,
},
},
}
```
**Rule properties:**
- `variablePattern` - Lua pattern to match variable name (optional)
- `attributePattern` - Lua pattern to match attribute content (optional)
- `collapse` - `true` to collapse to `{...}`, `false` to keep expanded
Rules are checked in order; first match wins.
## Commands
| Command | Description |
|---------|-------------|
| `:SCLShowVariables` | Show local variables |
| `:SCLShowWorkspaceTypes` | Show workspace types count |
| `:SCLRescanWorkspaceTypes` | Rescan workspace |
| `:SCLPrefixWord` | Prefix word with `#` |
| `:SCLMultilineParams` | Fill FB parameters |
| `:LspSCLFormat` | Format SCL file |
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
## LSP Features
### Goto Declaration (`gD`)
The LSP supports goto declaration functionality for:
- **Functions** - Jump to function definition in `.scl` files
- **Function Blocks (FB)** - Jump to FB definition in `.scl` files
- **Data Blocks (DB)** - Jump to DB definition in `.scl` and `.db` files
- **User-Defined Types (UDT)** - Jump to type definition in `.scl` and `.udt` files
Usage: Place cursor on a variable or type name and press `gD` (or use LSP client command).
## Auto-Editing Features
### Auto-Uppercase Keywords
When typing SCL keywords followed by a space, they are automatically converted to uppercase:
- `if ``IF `
- `then ``THEN `
- `end_if``END_IF`
- etc.
Works in `.scl`, `.udt`, and `.db` files.
### Auto-Semicolon for Control Structures
When pressing Enter after an `END_*` control structure keyword, a semicolon is automatically added:
- `END_IF``END_IF;`
- `END_FOR``END_FOR;`
- `END_WHILE``END_WHILE;`
- `END_REPEAT``END_REPEAT;`
- `END_CASE``END_CASE;`
Block definitions (END_VAR, END_FUNCTION, etc.) are NOT affected.
### Auto-Prefix Local Variables
Local variables are automatically prefixed with `#` when typing space or semicolon after BEGIN:
- `myVar := ``#myVar := `
- `counter.in``#counter.in` (on dot)
Can be toggled with `:lua require("scl.auto_prefix").toggle()`.
## Keybindings (SCL/UDT files)
### Attribute Block Toggle
Interactive expand/collapse of individual `{...}` blocks (uses `<Leader>x` prefix):
| Key | Mode | Description |
|-----|------|-------------|
| `<Leader>xa` | Normal/Insert | Toggle block under cursor |
| `<Leader>xae` | Normal | Expand all collapsed blocks |
| `<Leader>xac` | Normal | Collapse all attribute blocks |
**How it works:**
1. Place cursor inside any `{...}` block
2. Press `<Leader>xa` to toggle between collapsed `{...}` and expanded content
3. Original content is stored per-buffer and persists until buffer is closed
4. Works on both formatter-collapsed blocks and manually collapsed ones
### Other Keybindings
| Key | Mode | Description |
|-----|------|-------------|
| `<Space>mp` | Insert | Fill multiline FB parameters |
| `<Tab>` | Insert | Jump to next parameter or regular Tab |
Note: `<Leader>` is typically `\` (backslash) or `<Space>` depending on your configuration.
### Troubleshooting
**If commands or keybindings don't work:**
1. Check if setup() was called:
```lua
:lua print(vim.inspect(require("scl_lsp")))
```
2. Verify commands exist:
```vim
:command SCLToggleAttrBlock
```
Should show the command definition.
3. Check for errors during setup:
```lua
:lua require("scl_lsp").setup({})
```
4. Verify leader key:
```vim
:echo mapleader
```
If empty, your leader is `\` (backslash).
5. Manual test keybinding:
```vim
:nmap <Leader>xa
```
Should show the mapping.
### Attribute Toggle After Formatting
**Note:** After using `:LspSCLFormat`, all collapsed attribute blocks will be expanded and the toggle state is reset. This is because formatting changes line numbers and positions. You can collapse blocks again after formatting.
The LSP validates data types and provides warnings for unknown types:
- **Elementary**: BOOL, BYTE, WORD, DWORD, INT, DINT, REAL, TIME, STRING, etc.
- **Timer/counter**: IEC_TIMER, TON_TIME, TOF_TIME, CTU, CTD, CTUD
- **Built-in FBs**: TON, TOF, TP, R_TRIG, F_TRIG, etc.
- **Built-in functions**: ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
- **Workspace types**: UDTs from `.udt` files, DBs from `.db` files
## Testing
Manual testing using: `~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project`
Workflow:
1. Open `.scl` file in Neovim
2. Test LSP features (hover, completion, go-to-definition)
3. Verify diagnostics
4. Test formatting
5. Check workspace scanning
## LSP Implementation Notes
### Method Name Conversion
```lua
local method_name = message.method:gsub("/", "_")
local handler = handlers[method_name]
Use the reference project for testing:
```
~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/
```
### Request vs Notification
```lua
if message.id then
-- Request: send response
return { id = message.id, result = result }
end
-- Notification: no response needed
```
### Script Path Pattern
```lua
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
if script_path == "" then
script_path = "."
end
package.path = package.path .. ";" .. script_path .. "/?.lua"
```
Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of:
- Plugin features (auto-prefix, workspace scanning, attribute toggle)
- Workspace type scanning
+126 -213
View File
@@ -1,49 +1,9 @@
# SCL Language Server - Unified Plugin for Siemens SCL
# tia-lsp.nvim - Neovim Plugin for Siemens TIA Portal
A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)**, **Linting**, **Formatting**, and **Syntax Highlighting** for Siemens SCL (Structured Control Language) used in Siemens TIA Portal.
A Neovim/LazyVim plugin that launches the [`tia-lsp`](https://gitea.l-tech.rs/lazar/tia-lsp) language server and adds editor-specific features for Siemens TIA Portal languages. Currently supports **SCL** (Structured Control Language), with planned support for **STL/AWL**, **LAD**, and **FBD**.
## Features
### LSP (Language Server Protocol)
| Feature | Description |
|---------|-------------|
| **Hover** | Press `K` to see variable type and struct fields |
| **Completion** | `#` for variables, `.` for struct fields, `(` for functions |
| **Go to Definition** | `gD` jump to function, FB, DB, or UDT definition |
| **Document Symbols** | Shows functions, variables, types |
| **Workspace Symbols** | Search across all `.scl` files with fuzzy matching |
| **Semantic Tokens** | Keywords, variables highlighted |
| **Inlay Hints** | Shows variable types after declaration |
| **External UDT** | Load types from `.plc.json` or `data_types/` folder |
### Linter (Diagnostics)
| Feature | Description |
|---------|-------------|
| **Undefined Variables** | Detects variables used without declaration |
| **Invalid # Prefix** | Detects `#` prefix in VAR sections |
| **Unknown Types** | Warns about unknown data types |
| **Type Validation** | Basic type checking |
### Formatter
| Feature | Description |
|---------|-------------|
| **Document Formatting** | Format entire SCL files |
| **Indentation** | Proper block indentation |
| **Keyword Casing** | Maintains SCL keyword casing |
### Syntax Highlighting
- Full SCL syntax support via tree-sitter
- Block definitions: ORGANIZATION_BLOCK, FUNCTION_BLOCK, FUNCTION
- Variable sections: VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_CONSTANT
- Control structures: IF/THEN/ELSIF/ELSE/END_IF, CASE/OF/END_CASE, FOR/TO/BY/DO/END_FOR, WHILE/END_WHILE, REPEAT/UNTIL/END_REPEAT
- Built-in data types: BOOL, INT, DINT, REAL, STRING, TIME, etc.
- Function block instances highlighted in calls
- FB parameter names (IN, PT, CU, etc.) highlighted as properties
- `#` prefix for local variables highlighted
- Operators: AND, OR, NOT, XOR, MOD
- Comments: line (`//`), block (`(* *)`), C-style (`/* */`)
### Additional Features
| Feature | Description |
|---------|-------------|
| **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) |
@@ -53,43 +13,80 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)
| **Workspace Global DB Scanner** | Scans project for global data blocks (.db files) |
| **blink.cmp Integration** | Smart completion source for blink.cmp |
| **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `<Leader>xa` |
| **Tree-sitter Highlighting** | Syntax highlighting via tree-sitter queries |
## Installation
### LazyVim
```lua
-- ~/.config/nvim/lua/plugins/scl.lua
return {
"lazar/scl_lsp",
ft = "scl",
lazy = true,
dependencies = {
"neovim/nvim-lspconfig",
"nvim-treesitter/nvim-treesitter",
"saghen/blink.cmp",
},
config = function()
require("scl_lsp").setup({
-- LSP options
lsp = {
on_attach = function(client, bufnr)
-- custom keymaps
end,
},
cmp = true, -- Enable blink.cmp integration
workspace_types = true, -- Enable workspace UDT scanning
auto_prefix = true, -- Enable auto-# prefixing
debug = false,
})
end,
}
```
The plugin auto-detects the mason-installed `tia-lsp` executable via `vim.fn.exepath("tia-lsp")` and falls back to `lua <server_path>` for manual installs.
### LazyVim + Mason (Recommended)
1. Add the mason registry as a lazy.nvim plugin:
```lua
-- ~/.config/nvim/lua/plugins/tia-mason-registry.lua
return {
url = "https://gitea.l-tech.rs/lazar/tia-mason-registry.git",
name = "tia-mason-registry",
lazy = false, -- must be available before mason setup
}
```
2. Register the registry with mason:
```lua
-- ~/.config/nvim/lua/plugins/mason.lua
return {
"mason-org/mason.nvim",
opts = {
registries = {
"file:" .. vim.fn.expand("~/.local/share/nvim/lazy/tia-mason-registry"),
"github:mason-org/mason-registry",
},
},
}
```
3. Add the plugin:
```lua
-- ~/.config/nvim/lua/plugins/tia-lsp.lua
return {
"lazar/tia-lsp.nvim",
ft = "scl",
lazy = true,
dependencies = {
"neovim/nvim-lspconfig",
"nvim-treesitter/nvim-treesitter",
"saghen/blink.cmp",
},
config = function()
require("tia_lsp").setup({
lsp = {
on_attach = function(client, bufnr)
-- custom keymaps
end,
},
cmp = true, -- Enable blink.cmp integration
workspace_types = true, -- Enable workspace UDT scanning
auto_prefix = true, -- Enable auto-# prefixing
debug = false,
})
end,
}
```
4. Install:
```
:MasonInstall yq " one-time prerequisite for file: registry
:MasonInstall tia-lsp " installs the LSP server
```
5. Verify: open a `.scl` file and run `:LspInfo` — `tia_lsp` should be attached.
### Manual Setup (without Mason)
### Manual Setup
```lua
-- In your init.lua or plugin file
require("scl_lsp").setup({
server_path = "/path/to/scl_lsp/src/main.lua",
require("tia_lsp").setup({
server_path = "/path/to/tia-lsp/src/main.lua", -- only used if tia-lsp is not on PATH
ts_parser_url = "https://gitea.l-tech.rs/lazar/tia-lsp.git",
cmp = true,
workspace_types = true,
auto_prefix = true,
@@ -100,7 +97,8 @@ require("scl_lsp").setup({
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `server_path` | string | `~/dev/scl_lsp/src/main.lua` | Path to LSP server |
| `server_path` | string | `~/dev/tia-lsp/src/main.lua` | Path to LSP server (only used if `tia-lsp` is not on PATH) |
| `ts_parser_url` | string | `file://~/dev/tia-lsp` | URL passed to nvim-treesitter for the SCL parser source |
| `lsp.on_attach` | function | `nil` | Custom LSP attach callback |
| `lsp.formatting` | boolean | `false` | Enable format command |
| `cmp` | boolean | `true` | Enable blink.cmp integration |
@@ -119,8 +117,7 @@ require("scl_lsp").setup({
| `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs |
| `:SCLPrefixWord` | Manually prefix current word with `#` |
| `:SCLMultilineParams` | Fill multiline parameters for FB/Function call |
| `:LspSCLFormat` | Format current SCL file |
| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ |
| `:SCLFormat` | Format current SCL file |
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
@@ -159,149 +156,65 @@ statCounter{...} : Int;
- Use `<Leader>xae` to expand all blocks in buffer
- Use `<Leader>xac` to collapse all blocks
**Note:** The formatter has automatic collapsing disabled by default to preserve compatibility with the toggle feature. Format with `:LspSCLFormat` first, then use `:SCLCollapseAllAttrBlocks` to collapse while preserving the ability to expand later.
**Note:** Collapsed blocks are automatically expanded before saving to preserve full content on disk.
## External UDT Support
## Filetype Detection
### Option 1: Create `.plc.json`
```json
{
"dataTypes": [
{
"name": "equipmentData",
"struct": [
{"name": "id", "dataTypeName": "INT"},
{"name": "status", "dataTypeName": "BOOL"},
{"name": "temperature", "dataTypeName": "REAL"}
]
}
]
}
```
The plugin includes `ftdetect/scl.vim` which registers the following file extensions as `scl` filetype:
- `*.scl` — SCL source files
- `*.udt` — User-defined type files
- `*.db` — Data block files
### Option 2: Auto-scan `data_types/` folder
The LSP automatically scans for SCL files in `data_types/` folder and extracts TYPE definitions.
### Option 3: Generate `.plc.json`
```bash
lua /home/lazar/dev/scl_lsp/src/plc_json.lua --generate
```
## Global Data Block Support
The plugin automatically scans for `.db` files in the workspace and provides auto-completion for global data blocks and their members.
### How it works
1. **Workspace Scanning**: When opening a `.scl` file, the plugin scans the project for `.db` files
2. **DB Parsing**: Parses `DATA_BLOCK` definitions and extracts members from `VAR` sections
3. **Auto-completion**: Provides smart completion for DB names and members
### Usage
- Type a space or `#` to trigger completion - global DB names appear alongside local variables
- Type `"` followed by a DB name to get member completions
- Access nested members: `"MyDB".member.submember`
### Example
```scl
// Access global DB members
"HmiData".units
"Parameter".machine.unit100.paramTimeStampApplied
// Inside FB call parameter
#instParameterManager(hmiInterface:="HmiData".units, ...)
```
### Project Structure
## Project Structure
```
scl_lsp/
├── README.md # This file
├── package.json # NPM scripts (tree-sitter)
├── Makefile # Build commands
├── grammar.js # Tree-sitter grammar
├── tree-sitter.json # Parser config
├── queries/ # Tree-sitter queries
├── highlights.scm # Syntax highlighting
├── indents.scm # Indentation
├── folds.scm # Code folding
├── locals.scm # Variable scoping
└── tags.scm # Navigation tags
├── src/ # LSP server
│ ├── main.lua # Main entry point
├── parser.lua # Regex-based parser
├── treesitter.lua # Tree-sitter integration
├── json.lua # JSON encoder/decoder
│ ├── plc_json.lua # External UDT loading
── diagnostics.lua # Linter diagnostics
└── formatter.lua # Document formatter
└── lua/
└── scl_lsp/
├── init.lua # Main plugin setup
── plugins/
│ └── scl.lua # LazyVim plugin spec
└── scl/ # Syntax/completion modules
├── init.lua
├── auto_prefix.lua
├── blink_cmp_source.lua
├── db_parser.lua
├── fb_parser.lua
├── udt_parser.lua
├── variables.lua
└── workspace_types.lua
tia-lsp.nvim/
├── lua/
│ ├── tia_lsp/ # Plugin entry point
│ │ ├── init.lua # Main setup (vim.lsp.start, exepath detection)
│ │ └── plugins/
│ │ └── scl.lua # LazyVim plugin spec (SCL filetype)
│ └── tia/ # Language modules (shared across TIA languages)
├── auto_prefix.lua # Auto-# prefixing
├── attr_toggle.lua # Attribute block expand/collapse
├── blink_cmp_source.lua # blink.cmp completion source
├── builtin_instructions.lua
├── db_parser.lua # Global DB parser
│ ├── fb_parser.lua # Function Block parser
├── multiline_params.lua # Multiline parameter filling
├── udt_parser.lua # UDT parser
├── variables.lua # Variable extraction
└── workspace_types.lua # Workspace scanning
├── queries/
── scl/ # Tree-sitter queries (per-language)
├── highlights.scm # Syntax highlighting
│ ├── indents.scm # Indentation
│ ├── folds.scm # Code folding
├── locals.scm # Variable scoping
── tags.scm # Navigation tags
├── ftdetect/
└── scl.vim # Filetype detection
└── test/ # Plugin tests
├── run_tests.lua
├── test_harness.lua
├── test_udt.lua
├── test_db.lua
├── test_fb.lua
└── test_integration.lua
```
## Building Tree-sitter Parser
```bash
# Generate parser from grammar.js
npm run build
# or: tree-sitter generate
# Run tests
npm test
# or: tree-sitter test
```
## SCL Code Example
```pascal
FUNCTION_BLOCK "Em101Sequence"
VAR
x : INT;
myVar : equipmentData; // User-defined type
END_VAR
IF x > 5 THEN
myVar.id := 10;
END_IF
myVar.status := TRUE;
myVar.temperature := 25.5;
END_FUNCTION_BLOCK
```
## Architecture
The plugin uses a standalone LSP server (`src/main.lua`) that communicates via JSON-RPC over stdio. The server handles:
- Text document synchronization
- Diagnostics (linting)
- Formatting
- Completion, hover, go-to-definition
- Semantic tokens
The Neovim plugin (`lua/scl_lsp/init.lua`) manages:
- LSP client lifecycle via `vim.lsp.start()`
- FileType autocmd for lazy loading
- blink.cmp integration
- Workspace scanning for UDTs/DBs
## Requirements
- **Neovim 0.11+** (uses `vim.lsp.start()`)
- **nvim-treesitter** - Syntax highlighting
- **blink.cmp** - Optional, for completion
- **Lua 5.1+** - LSP server runtime
- **tia-lsp** — the LSP server (install via mason or manually)
- **nvim-treesitter** — Syntax highlighting
- **blink.cmp** — Optional, for completion
- **Lua 5.1+** — LSP server runtime
## Testing
```bash
lua test/run_tests.lua
```
Tests use files from a reference project (override with `SCL_REF_PROJECT=/path/to/ref lua test/run_tests.lua`).
+3
View File
@@ -0,0 +1,3 @@
au BufRead,BufNewFile *.scl set filetype=scl
au BufRead,BufNewFile *.udt set filetype=scl
au BufRead,BufNewFile *.db set filetype=scl
-266
View File
@@ -1,266 +0,0 @@
-- SCL (Siemens Structured Control Language) support for Neovim
-- Main setup module
local M = {}
function M.setup(opts)
opts = opts or {}
-- Add plugin to runtime path
vim.opt.runtimepath:prepend(vim.fn.stdpath("data") .. "/lazy/scl_lsp")
M.create_commands()
-- Treesitter setup for Neovim 0.11+
local function setup_treesitter()
local lang = vim.treesitter.language
local parser_path = "/home/lazar/dev/scl_lsp/src/parser.so"
-- Register the SCL language
lang.add("scl", {
path = parser_path,
filetype = "scl",
})
end
pcall(setup_treesitter)
-- Install queries
local queries_src = "/home/lazar/dev/scl_lsp/queries/highlights.scm"
local queries_dest = vim.fn.stdpath("data") .. "/site/queries/scl/highlights.scm"
if vim.fn.filereadable(queries_src) == 1 then
vim.fn.mkdir(vim.fn.stdpath("data") .. "/site/queries/scl", "p")
vim.fn.writefile(vim.fn.readfile(queries_src), queries_dest)
end
-- Enable treesitter manually for SCL files - bypass LazyVim
vim.api.nvim_create_autocmd("FileType", {
pattern = "scl",
callback = function(args)
vim.treesitter.start(args.buf, "scl")
end,
})
-- Register blink.cmp source
if opts.cmp then
local function try_register()
local cmp_ok, cmp = pcall(require, "blink.cmp")
if cmp_ok and cmp and cmp.add_source_provider then
local source_config = {
name = "scl",
module = "scl.blink_cmp_source",
score_offset = 100,
}
pcall(cmp.add_source_provider, "scl", source_config)
pcall(cmp.add_filetype_source, "scl", "scl")
-- Create autocmd to trigger completion on ( for SCL files
local augroup = vim.api.nvim_create_augroup("SCLCompletionTrigger", { clear = true })
vim.api.nvim_create_autocmd("TextChangedI", {
group = augroup,
pattern = "*.scl",
callback = function(args)
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
-- Check if the character before cursor is (
if col > 0 and line:sub(col, col) == "(" then
-- Check if there's a known FB variable before the (
local before_paren = line:sub(1, col - 1):match("[%w_]+%s*$")
if before_paren then
vim.schedule(function()
pcall(require("blink.cmp").show, { providers = { "scl" } })
end)
end
end
end,
})
vim.notify("SCL: blink.cmp source registered", vim.log.levels.INFO)
return true
end
return false
end
if not try_register() then
vim.api.nvim_create_autocmd("BufEnter", {
pattern = "*.scl",
once = true,
callback = try_register,
})
end
end
-- Workspace type scanner
if opts.workspace_types ~= false then
local workspace_types = require("scl.workspace_types")
workspace_types.setup({
project_root = opts.project_root,
library_paths = opts.library_paths,
debug = opts.debug,
})
end
-- Auto-prefixing (enabled by default)
M.setup_auto_prefix()
-- Multiline parameters feature
M.setup_multiline_params()
-- Attribute block toggle feature
M.setup_attr_toggle()
end
function M.setup_multiline_params()
local mp = require("scl.multiline_params")
vim.api.nvim_create_user_command("SCLMultilineParams", function()
mp.fill_multiline_params()
end, {})
vim.keymap.set("i", "<Space>mp", function()
mp.fill_multiline_params()
end, { noremap = true, silent = true, desc = "SCL: Fill multiline parameters" })
vim.keymap.set("i", "<Tab>", function()
local ft = vim.bo.filetype
if ft == "scl" or ft == "udt" then
if mp.jump_to_next_param() then
return ""
end
end
return "<Tab>"
end, { noremap = true, silent = true, desc = "SCL: Jump to next param or Tab" })
end
function M.setup_auto_prefix()
require("scl.auto_prefix").setup()
end
function M.prefix_current_word()
require("scl.auto_prefix").prefix_word()
end
function M.show_variables()
local vars = require("scl.variables").extract_variables()
if #vars == 0 then
vim.notify("No local variables found", vim.log.levels.INFO)
return
end
local lines = { "Local Variables:", "---" }
for _, var in ipairs(vars) do
local type_info = var.type and (" : " .. var.type) or ""
table.insert(lines, "#" .. var.name .. type_info)
end
vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO)
end
function M.show_workspace_types()
local ws = require("scl.workspace_types")
local udt = require("scl.udt_parser")
local fb = require("scl.fb_parser")
local db = require("scl.db_parser")
local root = ws.get_project_root()
local udt_names = udt.get_all_udt_names()
local fb_names = fb.get_all_fb_names()
local db_names = db.get_all_db_names()
if not root then
vim.notify("No project root detected", vim.log.levels.WARN)
return
end
vim.notify("Project: " .. root .. "\nUDTs: " .. #udt_names .. "\nFBs/Functions: " .. #fb_names .. "\nGlobal DBs: " .. #db_names, vim.log.levels.INFO)
end
function M.rescan_workspace_types()
local ws = require("scl.workspace_types")
local udt = require("scl.udt_parser")
local fb = require("scl.fb_parser")
local db = require("scl.db_parser")
udt.clear_cache()
fb.clear_cache()
db.clear_cache()
ws.clear_root_cache()
local udt_result = ws.scan_project_udts()
local fb_result = ws.scan_project_fbs()
local db_result = ws.scan_project_dbs()
vim.notify("Scanned UDTs: " .. udt_result.parsed_count .. "/" .. udt_result.total_files ..
", FBs: " .. fb_result.parsed_count .. "/" .. fb_result.total_files ..
", DBs: " .. db_result.parsed_count .. "/" .. db_result.total_files, vim.log.levels.INFO)
end
function M.create_commands()
vim.api.nvim_create_user_command("SCLShowVariables", function() M.show_variables() end, {})
vim.api.nvim_create_user_command("SCLShowWorkspaceTypes", function() M.show_workspace_types() end, {})
vim.api.nvim_create_user_command("SCLRescanWorkspaceTypes", function() M.rescan_workspace_types() end, {})
-- Attribute toggle commands
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.toggle_attr_block()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.expand_all_attr_blocks()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.collapse_all_attr_blocks()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
end
function M.setup_attr_toggle()
local at = require("scl.attr_toggle")
at.setup()
local function setup_buffer_keymaps(bufnr)
local opts = { buffer = bufnr, silent = true }
local ft = vim.bo[bufnr].filetype
if ft ~= "scl" and ft ~= "udt" then
return
end
-- Toggle individual attribute block under cursor
vim.keymap.set({ "n", "i" }, "<Leader>xa", function()
at.toggle_attr_block()
end, vim.tbl_extend("force", opts, { desc = "SCL: Toggle attribute block" }))
-- Expand all collapsed blocks
vim.keymap.set("n", "<Leader>xae", function()
at.expand_all_attr_blocks()
end, vim.tbl_extend("force", opts, { desc = "SCL: Expand all attribute blocks" }))
-- Collapse all attribute blocks
vim.keymap.set("n", "<Leader>xac", function()
at.collapse_all_attr_blocks()
end, vim.tbl_extend("force", opts, { desc = "SCL: Collapse all attribute blocks" }))
end
-- Set up keybindings for SCL files only
vim.api.nvim_create_autocmd("FileType", {
pattern = { "scl", "udt" },
callback = function(args)
setup_buffer_keymaps(args.buf)
end,
})
-- Also check if there are already SCL buffers open
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(bufnr) then
setup_buffer_keymaps(bufnr)
end
end
end
return M
@@ -80,23 +80,24 @@ function M.toggle_attr_block()
end
-- Expand all collapsed blocks in current buffer
function M.expand_all_attr_blocks()
function M.expand_all_attr_blocks(opts)
opts = opts or {}
local bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local count = 0
if not buffer_attr_state[bufnr] then
vim.notify("No collapsed blocks to expand", vim.log.levels.INFO)
if not opts.silent then
vim.notify("No collapsed blocks to expand", vim.log.levels.INFO)
end
return
end
for state_key, state in pairs(buffer_attr_state[bufnr]) do
if state.is_collapsed then
-- state.line_num is 1-indexed, but nvim_buf_get_lines returns 0-indexed array
local line = lines[state.line_num]
if line then
-- Find the collapsed {...} and replace with expanded content
local new_line = line:sub(1, state.start_col) .. state.expanded .. line:sub(state.start_col + 4)
lines[state.line_num] = new_line
state.is_collapsed = false
@@ -106,7 +107,9 @@ function M.expand_all_attr_blocks()
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.notify("Expanded " .. count .. " attribute block(s)", vim.log.levels.INFO)
if not opts.silent and count > 0 then
vim.notify("Expanded " .. count .. " attribute block(s)", vim.log.levels.INFO)
end
end
-- Helper to make a pattern case-insensitive
@@ -193,6 +196,25 @@ end
function M.setup()
local augroup = vim.api.nvim_create_augroup("SCLAttrToggle", { clear = true })
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
pattern = { "*.scl", "*.udt", "*.db" },
callback = function(args)
if buffer_attr_state[args.buf] then
local has_collapsed = false
for _, state in pairs(buffer_attr_state[args.buf]) do
if state.is_collapsed then
has_collapsed = true
break
end
end
if has_collapsed then
M.expand_all_attr_blocks({ silent = true })
end
end
end,
})
vim.api.nvim_create_autocmd("BufUnload", {
group = augroup,
pattern = { "*.scl", "*.udt", "*.db" },
@@ -71,7 +71,7 @@ local function get_local_variables()
end
end
local scl_vars = require("scl.variables")
local scl_vars = require("tia.variables")
local vars = scl_vars.extract_variables(bufnr)
var_cache = {}
@@ -545,7 +545,7 @@ function M.setup()
local before_semicolon = line:sub(1, cursor_col - 1)
local type_name = before_semicolon:match(":%s*([%w_]+)%s*$")
if type_name then
local udt_parser = require("scl.udt_parser")
local udt_parser = require("tia.udt_parser")
local is_udt = udt_parser.is_udt_type(type_name)
if is_udt and not before_semicolon:match(":%s*\"" .. type_name .. "\"") then
local new_before = before_semicolon:gsub(":%s*" .. type_name .. "%s*$", ':"' .. type_name .. '"')
@@ -104,7 +104,7 @@ end
-- Get all known local variables
local function get_known_variables(bufnr)
local scl_vars = require("scl.variables")
local scl_vars = require("tia.variables")
local variables = scl_vars.extract_variables(bufnr)
local known = {}
for _, v in ipairs(variables) do
@@ -115,12 +115,12 @@ end
-- Check if a type is an FB/Function and get its interface
local function get_fb_interface(var_type)
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
@@ -129,12 +129,12 @@ end
-- Check if a type is an FB/Function
local function is_fb_type(var_type)
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
@@ -236,7 +236,7 @@ function M:get_completions(context, callback)
local after_closed_db = active_portion:match('"([%w_]+)"%s*%.') ~= nil
if db_member_pattern or after_closed_db then
local db_parser = require("scl.db_parser")
local db_parser = require("tia.db_parser")
local db_name = active_portion:match('"([%w_]+)"')
if db_name then
@@ -271,7 +271,7 @@ function M:get_completions(context, callback)
resolved_path = part
if found_member.is_udt then
-- This member is a UDT type, continue resolving
local udt_parser = require("scl.udt_parser")
local udt_parser = require("tia.udt_parser")
current_members = udt_parser.get_udt_members(found_member.type)
current_type = found_member.type
else
@@ -306,7 +306,7 @@ function M:get_completions(context, callback)
-- Get members to show (either from DB or from resolved UDT type)
if current_type then
local udt_parser = require("scl.udt_parser")
local udt_parser = require("tia.udt_parser")
current_members = udt_parser.get_udt_members(current_type)
end
@@ -335,11 +335,11 @@ function M:get_completions(context, callback)
-- Check for just " pattern (start of DB name completion or partial DB name)
if is_in_db_name or is_partial_db or is_after_colon_quote then
local db_parser = require("scl.db_parser")
local db_parser = require("tia.db_parser")
-- If cache is empty, try to scan workspace for DBs
if db_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_dbs(bufnr)
end
@@ -376,7 +376,7 @@ function M:get_completions(context, callback)
end
end
local scl_vars = require("scl.variables")
local scl_vars = require("tia.variables")
local variables = scl_vars.extract_variables(bufnr)
-- Get all known local variables and their types
@@ -747,8 +747,8 @@ function M:get_completions(context, callback)
end)
vim.schedule(function()
local udt_parser = require("scl.udt_parser")
local db_parser = require("scl.db_parser")
local udt_parser = require("tia.udt_parser")
local db_parser = require("tia.db_parser")
-- No member access = show local variables, UDT types, and basic types
if not base_var then
@@ -779,9 +779,9 @@ function M:get_completions(context, callback)
end
-- Add Global DB names (without quotes - will be wrapped in resolve)
local db_parser = require("scl.db_parser")
local db_parser = require("tia.db_parser")
if db_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_dbs(bufnr)
end
@@ -1,5 +1,9 @@
-- TIA Portal built-in instructions for SCL Neovim plugin
-- Defines functions, function blocks, and their parameters for completion
local M = {}
-- Built-in functions (return values, no state)
M.FUNCTIONS = {
ADD = true,
SUB = true,
@@ -38,6 +42,7 @@ M.FUNCTIONS = {
I_BCD = true,
}
-- Built-in function blocks (have state, need instance)
M.FUNCTION_BLOCKS = {
TON = true,
TOF = true,
@@ -64,6 +69,8 @@ M.FUNCTION_BLOCKS = {
DATALOG = true,
}
-- Parameter definitions for built-in instructions
-- Format: { inputs = { { name, type }, ... }, outputs = { { name, type }, ... } }
M.PARAMETERS = {
TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
TOF = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
@@ -121,14 +128,23 @@ M.PARAMETERS = {
I_BCD = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
}
--- Get parameter info for a built-in instruction
--- @param name string The instruction name
--- @return table|nil Parameter definition with inputs and outputs arrays
function M.get_parameters(name)
return M.PARAMETERS[name:upper()]
end
--- Check if name is a known function block
--- @param name string The name to check
--- @return boolean True if known function block
function M.is_known_function_block(name)
return M.FUNCTION_BLOCKS[name:upper()] or false
end
--- Check if name is a known function
--- @param name string The name to check
--- @return boolean True if known function
function M.is_known_function(name)
return M.FUNCTIONS[name:upper()] or false
end
@@ -3,10 +3,30 @@
local M = {}
-- Cache for parsed DBs: { [db_name] = db_definition }
local db_cache = {}
-- Inline DB definitions (from comments): { [db_name] = db_definition }
local inline_dbs = {}
-- DB Definition structure:
-- {
-- name = "DBName",
-- title = "Data Block Title",
-- version = "0.1",
-- comment = "User comment",
-- members = {
-- {
-- name = "memberName",
-- type = "Bool" or '"OtherUdt"',
-- attributes = "{ S7_SetPoint := 'True' }",
-- comment = "Member comment",
-- is_udt = false -- true if type is a quoted UDT reference
-- }
-- }
-- }
-- Register an inline DB definition (from comments or other sources)
function M.register_inline_db(db)
if db and db.name then
inline_dbs[db.name] = db
@@ -104,10 +124,10 @@ local function parse_attributes(attr_str)
end
local attrs = {}
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
attrs[key] = value
end
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
attrs[key] = value
end
return attrs
@@ -62,10 +62,10 @@ local function parse_attributes(attr_str)
local attrs = {}
-- Match key := 'value' or key := value patterns
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
attrs[key] = value
end
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
attrs[key] = value
end
return attrs
@@ -166,7 +166,7 @@ local function parse_var_section(content, section_name)
return vars
end
-- Parse FB/Function content
-- Parse FB/Function/OB content
function M.parse_fb_content(content, filename)
local fb = {
filename = filename,
@@ -189,11 +189,17 @@ function M.parse_fb_content(content, filename)
fb_type = "FUNCTION"
-- Extract return type if present
fb.return_type = content:match('FUNCTION%s+"[^"]+"%s*:%s*(%w+)')
else
-- Try ORGANIZATION_BLOCK
fb_name = content:match('ORGANIZATION_BLOCK%s+"([^"]+)"')
if fb_name then
fb_type = "ORGANIZATION_BLOCK"
end
end
end
if not fb_name then
return nil, "No FUNCTION_BLOCK or FUNCTION definition found"
return nil, "No FUNCTION_BLOCK, FUNCTION, or ORGANIZATION_BLOCK definition found"
end
fb.name = fb_name
@@ -1,7 +1,13 @@
-- Multiline parameter filling for SCL function block calls
-- Automatically expands FB calls to multi-line format with all parameters
local M = {}
--- Get interface for built-in FB types (TON, TOF, etc.)
--- @param var_type string The FB type name
--- @return table|nil Interface with inputs, outputs, inouts
local function get_builtin_interface(var_type)
local builtin = require("scl.builtin_instructions")
local builtin = require("tia.builtin_instructions")
local clean_type = var_type:gsub('"', ''):gsub("#", ""):upper()
local params_info = builtin.get_parameters(clean_type)
if params_info then
@@ -14,32 +20,41 @@ local function get_builtin_interface(var_type)
return nil
end
--- Get interface for custom FB types from workspace
--- @param var_type string The FB type name
--- @return table|nil Interface with inputs, outputs, inouts
local function get_fb_interface(var_type)
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.get_fb_interface(clean_type)
end
--- Check if a type is a known FB type
--- @param var_type string The type name to check
--- @return boolean True if type is an FB
local function is_fb_type(var_type)
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.is_fb_type(clean_type)
end
--- Get list of known local variable names
--- @param bufnr number Buffer number
--- @return table Set of variable names
local function get_known_variables(bufnr)
local scl_vars = require("scl.variables")
local scl_vars = require("tia.variables")
local variables = scl_vars.extract_variables(bufnr)
local known = {}
for _, v in ipairs(variables) do
@@ -48,11 +63,17 @@ local function get_known_variables(bufnr)
return known
end
--- Get the type of a variable by name
--- @param var_name string Variable name
--- @param bufnr number Buffer number
--- @return string|nil Variable type, or nil if not found
local function get_variable_type(var_name, bufnr)
local scl_vars = require("scl.variables")
local scl_vars = require("tia.variables")
return scl_vars.get_variable_type(var_name, bufnr)
end
--- Fill multiline parameters for FB call at cursor
--- Expands single-line call to multi-line with all parameters
function M.fill_multiline_params()
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
@@ -63,7 +84,7 @@ function M.fill_multiline_params()
local line_before_cursor = line:sub(1, col)
local known_vars = get_known_variables(bufnr)
local builtin = require("scl.builtin_instructions")
local builtin = require("tia.builtin_instructions")
local fb_name = nil
local fb_prefix = ""
@@ -169,6 +190,11 @@ function M.fill_multiline_params()
vim.api.nvim_win_set_cursor(0, { new_cursor_row, new_cursor_col })
end
--- Check if cursor is inside an FB call parameter list
--- @param bufnr number Buffer number
--- @param cursor_row number Current row (1-based)
--- @param cursor_col number Current column (1-based)
--- @return boolean True if inside FB call params
local function is_inside_fb_call(bufnr, cursor_row, cursor_col)
local function get_line_text(rownum)
return vim.api.nvim_buf_get_lines(bufnr, rownum - 1, rownum, false)[1] or ""
@@ -214,6 +240,9 @@ local function is_inside_fb_call(bufnr, cursor_row, cursor_col)
return false
end
--- Jump to next parameter in FB call
--- Used for Tab navigation within parameter lists
--- @return boolean True if jumped to next param, false otherwise
function M.jump_to_next_param()
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
@@ -140,10 +140,10 @@ local function parse_attributes(attr_str)
local attrs = {}
-- Match key := 'value' or key := value patterns
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
attrs[key] = value
end
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
attrs[key] = value
end
return attrs
@@ -361,7 +361,7 @@ end
-- Parse all UDT files in project and libraries
function M.scan_project_udts(bufnr)
local udt_parser = require("scl.udt_parser")
local udt_parser = require("tia.udt_parser")
local files = M.get_project_udt_files(bufnr)
local lib_files = M.get_library_udt_files()
@@ -391,7 +391,7 @@ end
-- Parse all FB/Function files in project and libraries
function M.scan_project_fbs(bufnr)
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
local files = M.get_project_fb_files(bufnr)
local lib_files = M.get_library_fb_files()
@@ -421,7 +421,7 @@ end
-- Parse all Global DB files in project and libraries
function M.scan_project_dbs(bufnr)
local db_parser = require("scl.db_parser")
local db_parser = require("tia.db_parser")
local files = M.get_project_db_files(bufnr)
local lib_files = M.get_library_db_files()
@@ -486,9 +486,9 @@ function M.setup(opts)
pattern = "*.scl",
callback = function(args)
-- Clear cache and rescan
local udt_parser = require("scl.udt_parser")
local fb_parser = require("scl.fb_parser")
local db_parser = require("scl.db_parser")
local udt_parser = require("tia.udt_parser")
local fb_parser = require("tia.fb_parser")
local db_parser = require("tia.db_parser")
udt_parser.clear_cache()
fb_parser.clear_cache()
db_parser.clear_cache()
@@ -506,7 +506,7 @@ function M.setup(opts)
pattern = "*.udt",
callback = function(args)
-- Re-parse the changed UDT file
local udt_parser = require("scl.udt_parser")
local udt_parser = require("tia.udt_parser")
udt_parser.parse_udt_file(args.file)
end,
})
@@ -517,7 +517,7 @@ function M.setup(opts)
pattern = "*.scl",
callback = function(args)
-- Re-parse the changed FB file
local fb_parser = require("scl.fb_parser")
local fb_parser = require("tia.fb_parser")
fb_parser.parse_fb_file(args.file)
end,
})
@@ -527,7 +527,7 @@ function M.setup(opts)
group = augroup,
pattern = "*.db",
callback = function(args)
local db_parser = require("scl.db_parser")
local db_parser = require("tia.db_parser")
db_parser.parse_db_file(args.file)
end,
})
+33 -35
View File
@@ -2,7 +2,7 @@ local M = {}
local function setup_package_path()
local plugin_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
plugin_path = plugin_path:gsub("/lua/scl_lsp", "")
plugin_path = plugin_path:gsub("/lua/tia_lsp", "")
local lua_path = plugin_path .. "/lua"
if not package.path:match(lua_path) then
package.path = lua_path .. "/?.lua;" .. package.path
@@ -13,7 +13,7 @@ function M.setup(opts)
setup_package_path()
opts = opts or {}
local server_path = opts.server_path or vim.fn.expand("~/dev/scl_lsp/src/main.lua")
local server_path = opts.server_path or vim.fn.expand("~/dev/tia-lsp/src/main.lua")
local root_patterns = { ".git", "data_types", "plc.data.json" }
@@ -24,9 +24,15 @@ function M.setup(opts)
local root_dir = vim.fs.root(fname, root_patterns)
or vim.fn.fnamemodify(fname, ":p:h")
-- Prefer the mason-installed executable (vim.fn.exepath resolves on each
-- buffer enter, so a :MasonInstall mid-session is picked up without restart).
-- Fall back to running the server via `lua <server_path>` for manual installs.
local tia_bin = vim.fn.exepath("tia-lsp")
local cmd = tia_bin ~= "" and { "tia-lsp" } or { "lua", server_path }
vim.lsp.start({
name = "scl_lsp",
cmd = { "lua", server_path },
name = "tia_lsp",
cmd = cmd,
root_dir = root_dir,
on_attach = function(client, b)
if opts.lsp and opts.lsp.on_attach then
@@ -34,13 +40,8 @@ function M.setup(opts)
end
if opts.lsp and opts.lsp.formatting then
vim.api.nvim_buf_create_user_command(b, "LspSCLFormat", function()
vim.lsp.buf.format({ name = "scl_lsp" })
-- Clear attr_toggle state since line positions may have changed
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.clear_buffer_state(vim.api.nvim_get_current_buf())
end
vim.api.nvim_buf_create_user_command(b, "SCLFormat", function()
vim.lsp.buf.format({ name = "tia_lsp" })
end, {})
end
end,
@@ -56,13 +57,8 @@ function M.setup(opts)
})
-- Create global format command (works on current buffer)
vim.api.nvim_create_user_command("LspSCLFormat", function()
vim.lsp.buf.format({ name = "scl_lsp" })
-- Clear attr_toggle state since line positions may have changed
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.clear_buffer_state(vim.api.nvim_get_current_buf())
end
vim.api.nvim_create_user_command("SCLFormat", function()
vim.lsp.buf.format({ name = "tia_lsp" })
end, {})
-- Start LSP for any existing scl buffers (handles lazy loading case)
@@ -89,7 +85,9 @@ function M.setup_syntax(opts)
if ok and parser_config then
parser_config.scl = {
install_info = {
url = "file://" .. vim.fn.expand("~/dev/scl_lsp"),
-- Override via setup({ ts_parser_url = "https://your-gitea/tia-lsp.git" }).
url = opts.ts_parser_url
or "https://gitea.l-tech.rs/lazar/tia-lsp.git",
files = { "src/parser.c" },
generate_requires_npm = false,
requires_generate_from_grammar = false,
@@ -102,7 +100,7 @@ function M.setup_syntax(opts)
if opts.cmp then
local function try_register()
local cmp_ok, cmp = pcall(require, "blink.cmp")
local blink_source_ok, blink_source = pcall(require, "scl.blink_cmp_source")
local blink_source_ok, blink_source = pcall(require, "tia.blink_cmp_source")
if cmp_ok and cmp and cmp.add_source_provider and blink_source_ok then
local source_config = {
name = "scl",
@@ -128,7 +126,7 @@ function M.setup_syntax(opts)
end
if opts.workspace_types ~= false then
local ok, workspace_types = pcall(require, "scl.workspace_types")
local ok, workspace_types = pcall(require, "tia.workspace_types")
if ok then
workspace_types.setup({
project_root = opts.project_root,
@@ -140,14 +138,14 @@ function M.setup_syntax(opts)
end
function M.setup_auto_prefix()
local ok, auto_prefix = pcall(require, "scl.auto_prefix")
local ok, auto_prefix = pcall(require, "tia.auto_prefix")
if ok then
auto_prefix.setup()
end
end
function M.setup_multiline_params()
local ok, mp = pcall(require, "scl.multiline_params")
local ok, mp = pcall(require, "tia.multiline_params")
if not ok then
vim.notify("SCL multiline_params module not found", vim.log.levels.WARN)
return
@@ -173,14 +171,14 @@ function M.setup_multiline_params()
end
function M.prefix_current_word()
local ok, auto_prefix = pcall(require, "scl.auto_prefix")
local ok, auto_prefix = pcall(require, "tia.auto_prefix")
if ok then
auto_prefix.prefix_word()
end
end
function M.show_variables()
local ok, variables = pcall(require, "scl.variables")
local ok, variables = pcall(require, "tia.variables")
if not ok then
vim.notify("SCL variables module not found", vim.log.levels.WARN)
return
@@ -201,9 +199,9 @@ function M.show_variables()
end
function M.show_workspace_types()
local ok_ws, ws = pcall(require, "scl.workspace_types")
local ok_udt, udt = pcall(require, "scl.udt_parser")
local ok_fb, fb = pcall(require, "scl.fb_parser")
local ok_ws, ws = pcall(require, "tia.workspace_types")
local ok_udt, udt = pcall(require, "tia.udt_parser")
local ok_fb, fb = pcall(require, "tia.fb_parser")
if not ok_ws or not ok_udt or not ok_fb then
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
@@ -223,9 +221,9 @@ function M.show_workspace_types()
end
function M.rescan_workspace_types()
local ok_ws, ws = pcall(require, "scl.workspace_types")
local ok_udt, udt = pcall(require, "scl.udt_parser")
local ok_fb, fb = pcall(require, "scl.fb_parser")
local ok_ws, ws = pcall(require, "tia.workspace_types")
local ok_udt, udt = pcall(require, "tia.udt_parser")
local ok_fb, fb = pcall(require, "tia.fb_parser")
if not ok_ws or not ok_udt or not ok_fb then
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
@@ -251,7 +249,7 @@ function M.create_commands()
-- Attribute toggle commands
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function()
local ok, at = pcall(require, "scl.attr_toggle")
local ok, at = pcall(require, "tia.attr_toggle")
if ok then
at.toggle_attr_block()
else
@@ -260,7 +258,7 @@ function M.create_commands()
end, {})
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
local ok, at = pcall(require, "tia.attr_toggle")
if ok then
at.expand_all_attr_blocks()
else
@@ -269,7 +267,7 @@ function M.create_commands()
end, {})
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
local ok, at = pcall(require, "tia.attr_toggle")
if ok then
at.collapse_all_attr_blocks()
else
@@ -279,7 +277,7 @@ function M.create_commands()
end
function M.setup_attr_toggle()
local ok, at = pcall(require, "scl.attr_toggle")
local ok, at = pcall(require, "tia.attr_toggle")
if not ok then
vim.notify("SCL: attr_toggle module not found", vim.log.levels.WARN)
return
@@ -1,5 +1,5 @@
return {
"lazar/scl_lsp",
"lazar/tia-lsp.nvim",
ft = "scl",
lazy = true,
dependencies = {
@@ -8,8 +8,9 @@ return {
"saghen/blink.cmp",
},
config = function(_, opts)
require("scl_lsp").setup({
require("tia_lsp").setup({
server_path = opts.server_path,
ts_parser_url = opts.ts_parser_url,
lsp = opts.lsp,
cmp = opts.cmp ~= false,
workspace_types = opts.workspace_types ~= false,
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
cd /home/lazar/dev/scl_lsp
lua src/main.lua
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env lua
package.path = package.path .. ";lua/?.lua"
local test = dofile("test/test_harness.lua")
print("========================================")
print(" tia-lsp.nvim - Plugin Test Suite")
print("========================================")
print(string.format("Reference Project: %s", test.REF_PROJECT))
local function run_all_tests()
print("\n" .. string.rep("=", 40))
print("Running All Tests")
print(string.rep("=", 40))
test.reset()
local function run_test_file(name)
print(string.format("\n>>> Running %s", name))
local ok, err = pcall(dofile, "test/" .. name)
if not ok then
print("ERROR: " .. tostring(err))
test.fail_count = test.fail_count + 1
end
end
run_test_file("test_udt.lua")
run_test_file("test_db.lua")
run_test_file("test_fb.lua")
run_test_file("test_integration.lua")
test.report()
return test.fail_count == 0
end
local success = run_all_tests()
if success then
print("\n=== ALL TESTS PASSED ===")
os.exit(0)
else
print("\n=== SOME TESTS FAILED ===")
os.exit(1)
end
+238
View File
@@ -0,0 +1,238 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local db_parser = require("tia.db_parser")
local function test_db_parser_basic()
local content = [[
DATA_BLOCK "test_db"
TITLE = Test DB
VERSION : 0.1
//Test data block
VAR
member1 : Bool;
member2 : Int;
member3 : "other_udt";
END_VAR
END_DATA_BLOCK
]]
local db, err = db_parser.parse_db_content(content, "test.db")
if db then
db_parser.register_inline_db(db)
end
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.name, "test_db", "DB name should be extracted")
test.assert_equals(#db.members, 3, "should have 3 members")
test.assert_equals(db.members[1].name, "member1", "first member name")
test.assert_equals(db.members[3].is_udt, true, "third member is UDT type")
end
local function test_db_parser_attributes()
local content = [[
DATA_BLOCK "attr_db"
TITLE = Attr DB
VERSION : 0.1
VAR
attrMember{S7_SetPoint := 'True'}: Bool;
normalMember : Int;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "attr.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
end
local function test_db_parser_comments()
local content = [[
DATA_BLOCK "comment_db"
TITLE = Comment DB
VERSION : 0.1
//This is a DB comment
VAR
member1 : Bool; //first member
member2 : Int; //second member
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "comment.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.comment, "This is a DB comment", "DB comment should be extracted")
test.assert_equals(db.members[1].comment, "first member", "member comment should be extracted")
end
local function test_db_parser_array()
local content = [[
DATA_BLOCK "array_db"
TITLE = Array DB
VERSION : 0.1
VAR
arr : Array[0..9] of Int;
arr2 : Array[0..5, 0..3] of Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "array.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert(db.members[1].type:match("Array"), "array type should be preserved")
end
local function test_db_cache_operations()
db_parser.clear_cache()
test.assert_equals(db_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
DATA_BLOCK "cached_db"
TITLE = Cached DB
VERSION : 0.1
VAR
m1 : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "cached.db")
if db then
db_parser.register_inline_db(db)
end
local cached = db_parser.get_db("cached_db")
test.assert_not_nil(cached, "DB should be cached")
test.assert_equals(db_parser.get_cache_count(), 1, "cache should have 1 entry")
local names = db_parser.get_all_db_names()
test.assert_equals(#names, 1, "should have 1 DB name")
test.assert_equals(names[1], "cached_db", "DB name should match")
end
local function test_db_is_type()
db_parser.clear_cache()
local content = [[
DATA_BLOCK "known_db"
TITLE = Known
VERSION : 0.1
VAR
m1 : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "known.db")
if db then
db_parser.register_inline_db(db)
end
test.assert(db_parser.is_db_type("known_db"), "known_db should be recognized")
test.assert(db_parser.is_db_type('"known_db"'), "quoted known_db should be recognized")
test.assert(not db_parser.is_db_type("unknown_db"), "unknown_db should not be recognized")
end
local function test_db_members()
db_parser.clear_cache()
local content = [[
DATA_BLOCK "test_db"
TITLE = Test
VERSION : 0.1
VAR
inner : "other_db";
simple : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "test.db")
if db then
db_parser.register_inline_db(db)
end
local members = db_parser.get_db_members("test_db")
test.assert_not_nil(members, "members should not be nil")
if members and #members > 0 then
test.assert_equals(members[1].name, "inner", "first member name")
test.assert_equals(members[1].is_udt, true, "first member is UDT type")
end
end
local function test_db_reference_files()
db_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference DB file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local db = db_parser.parse_db_content(content, ref_path)
if db then
db_parser.register_inline_db(db)
end
test.assert_not_nil(db, "should parse reference DB file")
test.assert_equals(db.name, "GlobalMachineData", "DB name should match")
test.assert_gt(#db.members, 0, "should have members")
local cached = db_parser.get_db("GlobalMachineData")
test.assert_not_nil(cached, "DB should be cached")
end
local function test_db_multiple_reference_files()
db_parser.clear_cache()
local files = {
test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db",
test.REF_PROJECT .. "/Program blocks/01_Machine/Equipments.db",
test.REF_PROJECT .. "/Program blocks/10_Parameter/Parameter.db",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local db = db_parser.parse_db_content(content, filepath)
if db then
db_parser.register_inline_db(db)
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference DB")
test.assert_equals(db_parser.get_cache_count(), parsed_count, "cache should have correct count")
end
local function run_db_tests()
test.run_suite("DB Parser Tests", function()
print("\n--- Basic Tests ---")
test_db_parser_basic()
test_db_parser_attributes()
test_db_parser_comments()
test_db_parser_array()
print("\n--- Cache Tests ---")
test_db_cache_operations()
test_db_is_type()
test_db_members()
print("\n--- Reference Project Tests ---")
test_db_reference_files()
test_db_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestDb", function()
run_db_tests()
end, {})
end
run_db_tests()
+240
View File
@@ -0,0 +1,240 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local fb_parser = require("tia.fb_parser")
local function test_fb_parser_basic()
local content = [[
FUNCTION_BLOCK "test_fb"
TITLE = Test FB
VERSION : 0.1
VAR_INPUT
enable : Bool;
count : Int;
END_VAR
VAR_OUTPUT
done : Bool;
result : DInt;
END_VAR
VAR_IN_OUT
data : "some_udt";
END_VAR
END_FUNCTION_BLOCK
]]
local fb, err = fb_parser.parse_fb_content(content, "test.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.name, "test_fb", "FB name should be extracted")
test.assert_equals(fb.type, "FUNCTION_BLOCK", "FB type should be FUNCTION_BLOCK")
test.assert_equals(#fb.inputs, 2, "should have 2 inputs")
test.assert_equals(#fb.outputs, 2, "should have 2 outputs")
test.assert_equals(#fb.inouts, 1, "should have 1 inout")
test.assert_equals(fb.inputs[1].name, "enable", "first input name")
test.assert_equals(fb.inouts[1].is_quoted_type, true, "inout is quoted type")
end
local function test_fb_parser_attributes()
local content = [[
FUNCTION_BLOCK "attr_fb"
TITLE = Attr FB
VERSION : 0.1
VAR_INPUT
attrInput{S7_SetPoint := 'True'}: Bool;
normalInput : Int;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "attr.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.inputs[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
end
local function test_fb_parser_comments()
local content = [[
FUNCTION_BLOCK "comment_fb"
TITLE = Comment FB
VERSION : 0.1
VAR_INPUT
input1 : Bool; //enable flag
input2 : Int; //counter
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "comment.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.inputs[1].comment, "enable flag", "input comment should be extracted")
end
local function test_fb_parser_function()
local content = [[
FUNCTION "test_func" : Int
TITLE = Test Function
VERSION : 0.1
VAR_INPUT
a : Int;
b : Int;
END_VAR
BEGIN
END_FUNCTION
]]
local fb = fb_parser.parse_fb_content(content, "test_func.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.name, "test_func", "Function name should be extracted")
test.assert_equals(fb.type, "FUNCTION", "FB type should be FUNCTION")
test.assert_equals(fb.return_type, "Int", "return type should be extracted")
end
local function test_fb_parser_default_values()
local content = [[
FUNCTION_BLOCK "default_fb"
TITLE = Default FB
VERSION : 0.1
VAR_INPUT
withDefault : Int := 5;
withoutDefault : Bool;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "default.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert(fb.inputs[1].has_default, "first input has default")
test.assert_equals(fb.inputs[1].default_value, "5", "default value should be extracted")
test.assert(not fb.inputs[2].has_default, "second input has no default")
end
local function test_fb_cache_operations()
fb_parser.clear_cache()
test.assert_equals(fb_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
FUNCTION_BLOCK "cached_fb"
TITLE = Cached FB
VERSION : 0.1
VAR_INPUT
input1 : Bool;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "cached.scl")
test.assert_not_nil(fb, "FB should be parsed")
test.assert_equals(fb.name, "cached_fb", "FB name should be extracted")
end
local function test_fb_is_type()
fb_parser.clear_cache()
test.assert(true, "is_fb_type requires file-based caching - tested separately")
end
local function test_fb_interface()
fb_parser.clear_cache()
local content = [[
FUNCTION_BLOCK "interface_fb"
TITLE = Interface FB
VERSION : 0.1
VAR_INPUT
enable : Bool;
value : Int;
END_VAR
VAR_OUTPUT
done : Bool;
error : Bool;
END_VAR
VAR_IN_OUT
data : "some_type";
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "interface.scl")
test.assert_not_nil(fb, "FB should be parsed")
if fb then
test.assert_equals(#fb.inputs, 2, "should have 2 inputs")
test.assert_equals(#fb.outputs, 2, "should have 2 outputs")
test.assert_equals(#fb.inouts, 1, "should have 1 inout")
end
end
local function test_fb_reference_files()
fb_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference SCL file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local fb = fb_parser.parse_fb_content(content, ref_path)
test.assert_not_nil(fb, "should parse reference SCL file")
test.assert_equals(fb.name, "MachineMain", "FB name should match")
test.assert(fb.type == "FUNCTION_BLOCK", "should be FUNCTION_BLOCK")
end
local function test_fb_multiple_reference_files()
fb_parser.clear_cache()
local files = {
test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl",
test.REF_PROJECT .. "/Program blocks/01_Machine/ReadInputs.scl",
test.REF_PROJECT .. "/Program blocks/01_Machine/WriteOutputs.scl",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local fb = fb_parser.parse_fb_content(content, filepath)
if fb then
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference SCL")
end
local function run_fb_tests()
test.run_suite("FB Parser Tests", function()
print("\n--- Basic Tests ---")
test_fb_parser_basic()
test_fb_parser_attributes()
test_fb_parser_comments()
test_fb_parser_function()
test_fb_parser_default_values()
print("\n--- Cache Tests ---")
test_fb_cache_operations()
test_fb_is_type()
test_fb_interface()
print("\n--- Reference Project Tests ---")
test_fb_reference_files()
test_fb_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestFb", function()
run_fb_tests()
end, {})
end
run_fb_tests()
+168
View File
@@ -0,0 +1,168 @@
local M = {}
M.test_count = 0
M.pass_count = 0
M.fail_count = 0
M.failures = {}
M.REF_PROJECT = os.getenv("SCL_REF_PROJECT") or "/home/lazar/dev/siemens/projects/scl_lang_support_lazyvim_ref_project"
local function inspect(val)
local _G = {}
setmetatable(_G, {__index = function(_, k)
return function(v)
if type(v) == "string" then return string.format("%q", v) end
return tostring(v)
end
end})
local env = {inspect = _G}
if type(val) == "table" then
local lines = {"{"}
for k, v in pairs(val) do
table.insert(lines, string.format(" [%s] = %s,", inspect(k), inspect(v)))
end
table.insert(lines, "}")
return table.concat(lines, "\n")
elseif type(val) == "string" then
return string.format("%q", val)
else
return tostring(val)
end
end
function M.assert(condition, message)
M.test_count = M.test_count + 1
if condition then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "unnamed test",
msg = "assertion failed"
})
return false
end
end
function M.assert_equals(actual, expected, message)
M.test_count = M.test_count + 1
local actual_str = inspect(actual)
local expected_str = inspect(expected)
if actual == expected then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_equals",
msg = string.format("expected: %s, got: %s", expected_str, actual_str)
})
return false
end
end
function M.assert_not_nil(value, message)
M.test_count = M.test_count + 1
if value ~= nil then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_not_nil",
msg = "value is nil"
})
return false
end
end
function M.assert_nil(value, message)
M.test_count = M.test_count + 1
if value == nil then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_nil",
msg = string.format("expected nil, got: %s", inspect(value))
})
return false
end
end
function M.assert_gt(actual, expected, message)
M.test_count = M.test_count + 1
if actual > expected then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_gt",
msg = string.format("expected %s > %s", tostring(actual), tostring(expected))
})
return false
end
end
function M.assert_items_equal(actual, expected, message)
M.test_count = M.test_count + 1
if not actual or not expected then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = "nil table passed"
})
return false
end
if #actual ~= #expected then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = string.format("length mismatch: expected %d, got %d", #expected, #actual)
})
return false
end
for i, v in ipairs(actual) do
if v ~= expected[i] then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = string.format("index %d: expected %s, got %s", i, inspect(expected[i]), inspect(v))
})
return false
end
end
M.pass_count = M.pass_count + 1
return true
end
function M.reset()
M.test_count = 0
M.pass_count = 0
M.fail_count = 0
M.failures = {}
end
function M.report()
print(string.format("\n=== Test Results ==="))
print(string.format("Total: %d | Passed: %d | Failed: %d", M.test_count, M.pass_count, M.fail_count))
if M.fail_count > 0 then
print("\nFailures:")
for _, f in ipairs(M.failures) do
print(string.format(" - %s: %s", f.test, f.msg))
end
end
return M.fail_count == 0
end
function M.run_suite(name, fn)
print(string.format("\n=== Running %s ===", name))
M.reset()
fn()
M.report()
end
return M
+240
View File
@@ -0,0 +1,240 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local udt_parser = require("tia.udt_parser")
local db_parser = require("tia.db_parser")
local fb_parser = require("tia.fb_parser")
local function scan_directory(dir, ext)
local files = {}
local handle = io.popen('find "' .. dir .. '" -type f -name "*.' .. ext .. '" 2>/dev/null')
if handle then
for line in handle:lines() do
if line ~= "" then
table.insert(files, line)
end
end
handle:close()
end
return files
end
local function test_parse_all_udt_files()
udt_parser.clear_cache()
local udt_files = scan_directory(test.REF_PROJECT .. "/PLC data types", "udt")
test.assert_gt(#udt_files, 0, "should find UDT files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(udt_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local udt, err = udt_parser.parse_udt_content(content, filepath)
if udt then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d UDT files", parsed, #udt_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one UDT file")
end
local function test_parse_all_db_files()
db_parser.clear_cache()
local db_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "db")
test.assert_gt(#db_files, 0, "should find DB files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(db_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local db, err = db_parser.parse_db_content(content, filepath)
if db then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d DB files", parsed, #db_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one DB file")
end
local function test_parse_all_scl_files()
fb_parser.clear_cache()
local scl_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "scl")
test.assert_gt(#scl_files, 0, "should find SCL files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(scl_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local fb, err = fb_parser.parse_fb_content(content, filepath)
if fb then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d SCL files", parsed, #scl_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one SCL file")
end
local function test_parse_xml_files()
local xml_files = scan_directory(test.REF_PROJECT, "xml")
local found = 0
for _, filepath in ipairs(xml_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
if content and #content > 10 then
found = found + 1
end
end
end
print(string.format("Found %d/%d XML files", found, #xml_files))
test.assert(found > 0, "should find XML files")
end
local function test_type_resolution()
udt_parser.clear_cache()
db_parser.clear_cache()
fb_parser.clear_cache()
local udt_content = [[
TYPE "inner_type"
TITLE = Inner
VERSION : 0.1
STRUCT
x : Int;
y : Bool;
END_STRUCT;
END_TYPE
]]
local db_content = [[
DATA_BLOCK "test_db"
TITLE = Test
VERSION : 0.1
VAR
myInner : "inner_type";
simple : Bool;
END_VAR
END_DATA_BLOCK
]]
local fb_content = [[
FUNCTION_BLOCK "test_fb"
TITLE = Test
VERSION : 0.1
VAR_INPUT
input1 : "inner_type";
END_VAR
END_FUNCTION_BLOCK
]]
local udt = udt_parser.parse_udt_content(udt_content, "inner.udt")
if udt then udt_parser.register_inline_udt(udt) end
local db = db_parser.parse_db_content(db_content, "test.db")
if db then db_parser.register_inline_db(db) end
local fb = fb_parser.parse_fb_content(fb_content, "test.scl")
test.assert(udt_parser.is_udt_type("inner_type"), "inner_type should be recognized as UDT")
test.assert(db_parser.is_db_type("test_db"), "test_db should be recognized as DB")
local db_members = db_parser.get_db_members("test_db")
if db_members and db_members[1] then
test.assert_equals(db_members[1].is_udt, true, "DB member should be recognized as UDT reference")
end
end
local function test_workspace_type_loading()
plc_json.clear_cache()
local types = plc_json.get_types(test.REF_PROJECT)
local total_types = 0
for _ in pairs(types) do
total_types = total_types + 1
end
print(string.format("Loaded %d types from workspace", total_types))
test.assert_gt(total_types, 0, "should load types from reference project")
end
local function run_integration_tests()
test.run_suite("Integration Tests - Reference Project", function()
print("\n--- UDT Files ---")
test_parse_all_udt_files()
print("\n--- DB Files ---")
test_parse_all_db_files()
print("\n--- SCL Files ---")
test_parse_all_scl_files()
print("\n--- XML Files ---")
test_parse_xml_files()
print("\n--- Type Resolution ---")
test_type_resolution()
end)
end
if vim then
vim.api.nvim_create_user_command("TestIntegration", function()
run_integration_tests()
end, {})
end
run_integration_tests()
+252
View File
@@ -0,0 +1,252 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local udt_parser = require("tia.udt_parser")
local function test_udt_parser_basic()
local content = [[
TYPE "test_udt"
TITLE = Test UDT
VERSION : 0.1
//Test type definition
STRUCT
member1 : Bool;
member2 : Int;
member3 : "other_udt";
END_STRUCT;
END_TYPE
]]
local udt, err = udt_parser.parse_udt_content(content, "test.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.name, "test_udt", "type name should be extracted")
test.assert_equals(#udt.members, 3, "should have 3 members")
test.assert_equals(udt.members[1].name, "member1", "first member name")
test.assert_equals(udt.members[1].type, "Bool", "first member type")
test.assert_equals(udt.members[3].is_udt, true, "third member is UDT type")
end
local function test_udt_parser_attributes()
local content = [[
TYPE "attr_test"
TITLE = Attr Test
VERSION : 0.1
STRUCT
attrMember{S7_SetPoint := 'True'}: Bool;
normalMember : Int;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "attr.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
test.assert(udt.members[2].attributes == nil or next(udt.members[2].attributes) == nil, "normal member has no attributes")
end
local function test_udt_parser_comments()
local content = [[
TYPE "comment_test"
TITLE = Comment Test
VERSION : 0.1
//This is a type comment
STRUCT
member1 : Bool; //first member
member2 : Int; //second member
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "comment.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.comment, "This is a type comment", "type comment should be extracted")
test.assert_equals(udt.members[1].comment, "first member", "member comment should be extracted")
end
local function test_udt_parser_array()
local content = [[
TYPE "array_test"
TITLE = Array Test
VERSION : 0.1
STRUCT
arr : Array[0..9] of Int;
arr2 : Array[0..5, 0..3] of Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "array.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert(udt.members[1].type:match("Array"), "array type should be preserved")
end
local function test_udt_cache_operations()
udt_parser.clear_cache()
test.assert_equals(udt_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
TYPE "cached_udt"
TITLE = Cached UDT
VERSION : 0.1
STRUCT
m1 : Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "cached.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
local cached = udt_parser.get_udt("cached_udt")
test.assert_not_nil(cached, "UDT should be cached")
test.assert_equals(udt_parser.get_cache_count(), 1, "cache should have 1 entry")
local names = udt_parser.get_all_udt_names()
test.assert_equals(#names, 1, "should have 1 UDT name")
test.assert_equals(names[1], "cached_udt", "UDT name should match")
end
local function test_udt_is_type()
udt_parser.clear_cache()
local content = [[
TYPE "known_type"
TITLE = Known
VERSION : 0.1
STRUCT
m1 : Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "known.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert(udt_parser.is_udt_type("known_type"), "known_type should be recognized")
test.assert(udt_parser.is_udt_type('"known_type"'), "quoted known_type should be recognized")
test.assert(not udt_parser.is_udt_type("unknown_type"), "unknown_type should not be recognized")
end
local function test_udt_members()
udt_parser.clear_cache()
local content1 = [[
TYPE "inner_type"
TITLE = Inner
VERSION : 0.1
STRUCT
x : Int;
END_STRUCT;
END_TYPE
]]
local content2 = [[
TYPE "outer_type"
TITLE = Outer
VERSION : 0.1
STRUCT
inner : "inner_type";
simple : Bool;
END_STRUCT;
END_TYPE
]]
local udt1 = udt_parser.parse_udt_content(content1, "inner.udt")
local udt2 = udt_parser.parse_udt_content(content2, "outer.udt")
if udt1 then udt_parser.register_inline_udt(udt1) end
if udt2 then udt_parser.register_inline_udt(udt2) end
local members = udt_parser.get_udt_members("outer_type")
test.assert_not_nil(members, "members should not be nil")
if members and #members > 0 then
test.assert_equals(members[1].name, "inner", "first member name")
test.assert_equals(members[1].is_udt, true, "first member is UDT type")
end
end
local function test_udt_reference_files()
udt_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/01_Machine/LMS_typePartCounter.udt"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference UDT file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local udt = udt_parser.parse_udt_content(content, ref_path)
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert_not_nil(udt, "should parse reference UDT file")
test.assert_equals(udt.name, "LMS_typePartCounter", "UDT name should match")
test.assert_gt(#udt.members, 0, "should have members")
local cached = udt_parser.get_udt("LMS_typePartCounter")
test.assert_not_nil(cached, "UDT should be cached")
end
local function test_udt_multiple_reference_files()
udt_parser.clear_cache()
local base_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/"
local files = {
base_path .. "01_Machine/LMS_typePartCounter.udt",
base_path .. "01_Machine/LMS_typePartCounterCommands.udt",
base_path .. "01_Machine/LMS_typePartCounterStatus.udt",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local udt = udt_parser.parse_udt_content(content, filepath)
if udt then
udt_parser.register_inline_udt(udt)
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference UDT")
test.assert_equals(udt_parser.get_cache_count(), parsed_count, "cache should have correct count")
end
local function run_udt_tests()
test.run_suite("UDT Parser Tests", function()
print("\n--- Basic Tests ---")
test_udt_parser_basic()
test_udt_parser_attributes()
test_udt_parser_comments()
test_udt_parser_array()
print("\n--- Cache Tests ---")
test_udt_cache_operations()
test_udt_is_type()
test_udt_members()
print("\n--- Reference Project Tests ---")
test_udt_reference_files()
test_udt_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestUdt", function()
run_udt_tests()
end, {})
end
run_udt_tests()