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 ## Build/Lint/Test Commands
```bash ```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 # Lua Linting
luacheck src/ # Lint LSP server code luacheck lua/tia/ # Lint language modules
luacheck lua/scl/ # Lint plugin 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 ## Project Structure
``` ```
scl_lsp/ tia-lsp.nvim/
├── src/ # LSP server (uses dofile) ├── lua/tia/ # Language modules (uses require)
│ ├── 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
│ ├── udt_parser.lua # UDT parser │ ├── udt_parser.lua # UDT parser
│ ├── db_parser.lua # Global DB parser │ ├── db_parser.lua # Global DB parser
│ ├── fb_parser.lua # Function Block parser │ ├── fb_parser.lua # Function Block parser
@@ -43,34 +33,36 @@ scl_lsp/
│ ├── workspace_types.lua # Workspace scanning │ ├── workspace_types.lua # Workspace scanning
│ ├── multiline_params.lua │ ├── multiline_params.lua
│ ├── auto_prefix.lua │ ├── auto_prefix.lua
│ └── attr_toggle.lua # Interactive attribute block toggle │ └── attr_toggle.lua
├── queries/ # Tree-sitter queries ├── lua/tia_lsp/ # Plugin entry point
│ ├── highlights.scm │ ├── init.lua # Main plugin setup (vim.lsp.start)
── indents.scm ── plugins/scl.lua # LazyVim plugin spec
│ └── folds.scm ├── queries/scl/ # Tree-sitter queries (per-language)
└── grammar.js # Tree-sitter grammar └── 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 ## 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 ```vim
au BufRead,BufNewFile *.scl set filetype=scl au BufRead,BufNewFile *.scl set filetype=scl
au BufRead,BufNewFile *.udt set filetype=scl au BufRead,BufNewFile *.udt set filetype=scl
au BufRead,BufNewFile *.db 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 ## Code Style Guidelines
### Module Pattern ### Module Pattern
```lua ```lua
-- File header comment
local M = {} local M = {}
-- Private cache
local cache = {} local cache = {}
function M.public_function() function M.public_function()
@@ -89,16 +81,18 @@ return M
- Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`) - Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`)
### Imports ### Imports
- `lua/scl/` modules: use `require("scl.module_name")` - `lua/tia/` modules: `require("tia.module_name")`
- `src/` modules: use `dofile(script_path .. "/module.lua")` - `lua/tia_lsp/` entry: `require("tia_lsp")`
- External dependencies: wrap in `pcall()` for safety - External dependencies: wrap in `pcall()` for safety
### Code Formatting ### Code Formatting
- `lua/scl/`: 2 spaces indentation - `lua/tia/` and `lua/tia_lsp/`: 2 spaces indentation
- `src/`: tab-based indentation
- Max line length: 120 characters - Max line length: 120 characters
- No trailing whitespace - 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 ### Parser Module API
All parser modules must implement: All parser modules must implement:
- `parse_*_file(filepath)` - Parse and cache - `parse_*_file(filepath)` - Parse and cache
@@ -121,291 +115,52 @@ function M.clear_cache()
end end
``` ```
## Lua Reserved Keywords ### Error Handling
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
```lua ```lua
function M.parse_file(filepath) function M.parse_file(filepath)
local file = io.open(filepath, "r") local file = io.open(filepath, "r")
if not file then if not file then
return nil, "Cannot open file: " .. filepath return nil, "Cannot open file: " .. filepath
end end
-- ... parse logic
return result return result
end end
```
### External Dependencies -- External dependencies
```lua
local ok, module = pcall(require, "some_module") local ok, module = pcall(require, "some_module")
if ok and module then if ok and module then
module.do_something() module.do_something()
end end
``` ```
## Diagnostics ## Lua Reserved Keywords
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:
Cannot use reserved keywords as table keys directly:
```lua ```lua
-- Before formatting: -- WRONG
statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int; local range = { start = pos1, end = pos2 }
-- After formatting: -- CORRECT
statCntrPartsIn{...} : Int; local range = { start = pos1, ["end"] = pos2 }
``` ```
### Formatter Configuration Critical for LSP range objects with `end` field.
```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",
},
}
```
**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: ## Type System
1. Format with `:LspSCLFormat` (preserves original content)
2. Manually collapse with `:SCLCollapseAllAttrBlocks` (stores original content)
3. Now toggle/expand works correctly
### Default Collapse Patterns The LSP validates data types and provides warnings for unknown types:
The `:SCLCollapseAllAttrBlocks` command uses case-insensitive patterns: - **Elementary**: BOOL, BYTE, WORD, DWORD, INT, DINT, REAL, TIME, STRING, etc.
- `EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'}` - **Timer/counter**: IEC_TIMER, TON_TIME, TOF_TIME, CTU, CTD, CTUD
- `S7_` - Block attributes: `{S7_Optimized_Access := 'TRUE'}` - **Built-in FBs**: TON, TOF, TP, R_TRIG, F_TRIG, etc.
- `NonRetain` - NonRetain attribute: `{NonRetain}` - **Built-in functions**: ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
- `Retain` - Retain attribute: `{Retain}` - **Workspace types**: UDTs from `.udt` files, DBs from `.db` files
- `%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.
## Testing ## Testing
Manual testing using: `~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project` Use the reference project for testing:
```
Workflow: ~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/
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]
``` ```
### Request vs Notification Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of:
```lua - Plugin features (auto-prefix, workspace scanning, attribute toggle)
if message.id then - Workspace type scanning
-- 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"
```
+98 -185
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 ## 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 | | Feature | Description |
|---------|-------------| |---------|-------------|
| **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) | | **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) |
@@ -53,14 +13,43 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)
| **Workspace Global DB Scanner** | Scans project for global data blocks (.db files) | | **Workspace Global DB Scanner** | Scans project for global data blocks (.db files) |
| **blink.cmp Integration** | Smart completion source for blink.cmp | | **blink.cmp Integration** | Smart completion source for blink.cmp |
| **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `<Leader>xa` | | **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `<Leader>xa` |
| **Tree-sitter Highlighting** | Syntax highlighting via tree-sitter queries |
## Installation ## Installation
### LazyVim 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 ```lua
-- ~/.config/nvim/lua/plugins/scl.lua -- ~/.config/nvim/lua/plugins/tia-mason-registry.lua
return { return {
"lazar/scl_lsp", 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", ft = "scl",
lazy = true, lazy = true,
dependencies = { dependencies = {
@@ -69,8 +58,7 @@ return {
"saghen/blink.cmp", "saghen/blink.cmp",
}, },
config = function() config = function()
require("scl_lsp").setup({ require("tia_lsp").setup({
-- LSP options
lsp = { lsp = {
on_attach = function(client, bufnr) on_attach = function(client, bufnr)
-- custom keymaps -- custom keymaps
@@ -85,11 +73,20 @@ return {
} }
``` ```
### Manual Setup 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)
```lua ```lua
-- In your init.lua or plugin file require("tia_lsp").setup({
require("scl_lsp").setup({ server_path = "/path/to/tia-lsp/src/main.lua", -- only used if tia-lsp is not on PATH
server_path = "/path/to/scl_lsp/src/main.lua", ts_parser_url = "https://gitea.l-tech.rs/lazar/tia-lsp.git",
cmp = true, cmp = true,
workspace_types = true, workspace_types = true,
auto_prefix = true, auto_prefix = true,
@@ -100,7 +97,8 @@ require("scl_lsp").setup({
| Option | Type | Default | Description | | 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.on_attach` | function | `nil` | Custom LSP attach callback |
| `lsp.formatting` | boolean | `false` | Enable format command | | `lsp.formatting` | boolean | `false` | Enable format command |
| `cmp` | boolean | `true` | Enable blink.cmp integration | | `cmp` | boolean | `true` | Enable blink.cmp integration |
@@ -119,8 +117,7 @@ require("scl_lsp").setup({
| `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs | | `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs |
| `:SCLPrefixWord` | Manually prefix current word with `#` | | `:SCLPrefixWord` | Manually prefix current word with `#` |
| `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | | `:SCLMultilineParams` | Fill multiline parameters for FB/Function call |
| `:LspSCLFormat` | Format current SCL file | | `:SCLFormat` | Format current SCL file |
| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ |
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor | | `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer | | `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks | | `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
@@ -159,149 +156,65 @@ statCounter{...} : Int;
- Use `<Leader>xae` to expand all blocks in buffer - Use `<Leader>xae` to expand all blocks in buffer
- Use `<Leader>xac` to collapse all blocks - 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` The plugin includes `ftdetect/scl.vim` which registers the following file extensions as `scl` filetype:
```json - `*.scl` — SCL source files
{ - `*.udt` — User-defined type files
"dataTypes": [ - `*.db` — Data block files
{
"name": "equipmentData",
"struct": [
{"name": "id", "dataTypeName": "INT"},
{"name": "status", "dataTypeName": "BOOL"},
{"name": "temperature", "dataTypeName": "REAL"}
]
}
]
}
```
### Option 2: Auto-scan `data_types/` folder ## Project Structure
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
``` ```
scl_lsp/ tia-lsp.nvim/
├── README.md # This file ├── lua/
├── package.json # NPM scripts (tree-sitter) │ ├── tia_lsp/ # Plugin entry point
├── Makefile # Build commands │ │ ├── init.lua # Main setup (vim.lsp.start, exepath detection)
├── grammar.js # Tree-sitter grammar │ │ └── plugins/
├── tree-sitter.json # Parser config │ │ └── scl.lua # LazyVim plugin spec (SCL filetype)
├── queries/ # Tree-sitter queries │ └── 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 │ ├── highlights.scm # Syntax highlighting
│ ├── indents.scm # Indentation │ ├── indents.scm # Indentation
│ ├── folds.scm # Code folding │ ├── folds.scm # Code folding
│ ├── locals.scm # Variable scoping │ ├── locals.scm # Variable scoping
│ └── tags.scm # Navigation tags │ └── tags.scm # Navigation tags
├── src/ # LSP server ├── ftdetect/
── main.lua # Main entry point ── scl.vim # Filetype detection
│ ├── parser.lua # Regex-based parser └── test/ # Plugin tests
├── treesitter.lua # Tree-sitter integration ├── run_tests.lua
├── json.lua # JSON encoder/decoder ├── test_harness.lua
├── plc_json.lua # External UDT loading ├── test_udt.lua
├── diagnostics.lua # Linter diagnostics ├── test_db.lua
── formatter.lua # Document formatter ── test_fb.lua
└── lua/ └── test_integration.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
``` ```
## 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 ## Requirements
- **Neovim 0.11+** (uses `vim.lsp.start()`) - **Neovim 0.11+** (uses `vim.lsp.start()`)
- **nvim-treesitter** - Syntax highlighting - **tia-lsp** — the LSP server (install via mason or manually)
- **blink.cmp** - Optional, for completion - **nvim-treesitter** — Syntax highlighting
- **Lua 5.1+** - LSP server runtime - **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 end
-- Expand all collapsed blocks in current buffer -- 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 bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local count = 0 local count = 0
if not buffer_attr_state[bufnr] then if not buffer_attr_state[bufnr] then
if not opts.silent then
vim.notify("No collapsed blocks to expand", vim.log.levels.INFO) vim.notify("No collapsed blocks to expand", vim.log.levels.INFO)
end
return return
end end
for state_key, state in pairs(buffer_attr_state[bufnr]) do for state_key, state in pairs(buffer_attr_state[bufnr]) do
if state.is_collapsed then 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] local line = lines[state.line_num]
if line then 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) local new_line = line:sub(1, state.start_col) .. state.expanded .. line:sub(state.start_col + 4)
lines[state.line_num] = new_line lines[state.line_num] = new_line
state.is_collapsed = false state.is_collapsed = false
@@ -106,8 +107,10 @@ function M.expand_all_attr_blocks()
end end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
if not opts.silent and count > 0 then
vim.notify("Expanded " .. count .. " attribute block(s)", vim.log.levels.INFO) vim.notify("Expanded " .. count .. " attribute block(s)", vim.log.levels.INFO)
end end
end
-- Helper to make a pattern case-insensitive -- Helper to make a pattern case-insensitive
local function case_insensitive_pattern(pattern) local function case_insensitive_pattern(pattern)
@@ -193,6 +196,25 @@ end
function M.setup() function M.setup()
local augroup = vim.api.nvim_create_augroup("SCLAttrToggle", { clear = true }) 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", { vim.api.nvim_create_autocmd("BufUnload", {
group = augroup, group = augroup,
pattern = { "*.scl", "*.udt", "*.db" }, pattern = { "*.scl", "*.udt", "*.db" },
@@ -71,7 +71,7 @@ local function get_local_variables()
end end
end end
local scl_vars = require("scl.variables") local scl_vars = require("tia.variables")
local vars = scl_vars.extract_variables(bufnr) local vars = scl_vars.extract_variables(bufnr)
var_cache = {} var_cache = {}
@@ -545,7 +545,7 @@ function M.setup()
local before_semicolon = line:sub(1, cursor_col - 1) local before_semicolon = line:sub(1, cursor_col - 1)
local type_name = before_semicolon:match(":%s*([%w_]+)%s*$") local type_name = before_semicolon:match(":%s*([%w_]+)%s*$")
if type_name then 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) local is_udt = udt_parser.is_udt_type(type_name)
if is_udt and not before_semicolon:match(":%s*\"" .. type_name .. "\"") then if is_udt and not before_semicolon:match(":%s*\"" .. type_name .. "\"") then
local new_before = before_semicolon:gsub(":%s*" .. type_name .. "%s*$", ':"' .. type_name .. '"') local new_before = before_semicolon:gsub(":%s*" .. type_name .. "%s*$", ':"' .. type_name .. '"')
@@ -104,7 +104,7 @@ end
-- Get all known local variables -- Get all known local variables
local function get_known_variables(bufnr) 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 variables = scl_vars.extract_variables(bufnr)
local known = {} local known = {}
for _, v in ipairs(variables) do for _, v in ipairs(variables) do
@@ -115,12 +115,12 @@ end
-- Check if a type is an FB/Function and get its interface -- Check if a type is an FB/Function and get its interface
local function get_fb_interface(var_type) 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('"', '') local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs -- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then 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()) ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end end
@@ -129,12 +129,12 @@ end
-- Check if a type is an FB/Function -- Check if a type is an FB/Function
local function is_fb_type(var_type) 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('"', '') local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs -- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then 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()) ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end end
@@ -236,7 +236,7 @@ function M:get_completions(context, callback)
local after_closed_db = active_portion:match('"([%w_]+)"%s*%.') ~= nil local after_closed_db = active_portion:match('"([%w_]+)"%s*%.') ~= nil
if db_member_pattern or after_closed_db then 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_]+)"') local db_name = active_portion:match('"([%w_]+)"')
if db_name then if db_name then
@@ -271,7 +271,7 @@ function M:get_completions(context, callback)
resolved_path = part resolved_path = part
if found_member.is_udt then if found_member.is_udt then
-- This member is a UDT type, continue resolving -- 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_members = udt_parser.get_udt_members(found_member.type)
current_type = found_member.type current_type = found_member.type
else else
@@ -306,7 +306,7 @@ function M:get_completions(context, callback)
-- Get members to show (either from DB or from resolved UDT type) -- Get members to show (either from DB or from resolved UDT type)
if current_type then 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) current_members = udt_parser.get_udt_members(current_type)
end end
@@ -335,11 +335,11 @@ function M:get_completions(context, callback)
-- Check for just " pattern (start of DB name completion or partial DB name) -- 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 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 cache is empty, try to scan workspace for DBs
if db_parser.get_cache_count() == 0 then 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) ws.scan_project_dbs(bufnr)
end end
@@ -376,7 +376,7 @@ function M:get_completions(context, callback)
end end
end end
local scl_vars = require("scl.variables") local scl_vars = require("tia.variables")
local variables = scl_vars.extract_variables(bufnr) local variables = scl_vars.extract_variables(bufnr)
-- Get all known local variables and their types -- Get all known local variables and their types
@@ -747,8 +747,8 @@ function M:get_completions(context, callback)
end) end)
vim.schedule(function() vim.schedule(function()
local udt_parser = require("scl.udt_parser") local udt_parser = require("tia.udt_parser")
local db_parser = require("scl.db_parser") local db_parser = require("tia.db_parser")
-- No member access = show local variables, UDT types, and basic types -- No member access = show local variables, UDT types, and basic types
if not base_var then if not base_var then
@@ -779,9 +779,9 @@ function M:get_completions(context, callback)
end end
-- Add Global DB names (without quotes - will be wrapped in resolve) -- 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 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) ws.scan_project_dbs(bufnr)
end 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 = {} local M = {}
-- Built-in functions (return values, no state)
M.FUNCTIONS = { M.FUNCTIONS = {
ADD = true, ADD = true,
SUB = true, SUB = true,
@@ -38,6 +42,7 @@ M.FUNCTIONS = {
I_BCD = true, I_BCD = true,
} }
-- Built-in function blocks (have state, need instance)
M.FUNCTION_BLOCKS = { M.FUNCTION_BLOCKS = {
TON = true, TON = true,
TOF = true, TOF = true,
@@ -64,6 +69,8 @@ M.FUNCTION_BLOCKS = {
DATALOG = true, DATALOG = true,
} }
-- Parameter definitions for built-in instructions
-- Format: { inputs = { { name, type }, ... }, outputs = { { name, type }, ... } }
M.PARAMETERS = { M.PARAMETERS = {
TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } }, 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" } } }, 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" } } }, 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) function M.get_parameters(name)
return M.PARAMETERS[name:upper()] return M.PARAMETERS[name:upper()]
end 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) function M.is_known_function_block(name)
return M.FUNCTION_BLOCKS[name:upper()] or false return M.FUNCTION_BLOCKS[name:upper()] or false
end 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) function M.is_known_function(name)
return M.FUNCTIONS[name:upper()] or false return M.FUNCTIONS[name:upper()] or false
end end
@@ -3,10 +3,30 @@
local M = {} local M = {}
-- Cache for parsed DBs: { [db_name] = db_definition }
local db_cache = {} local db_cache = {}
-- Inline DB definitions (from comments): { [db_name] = db_definition }
local inline_dbs = {} 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) function M.register_inline_db(db)
if db and db.name then if db and db.name then
inline_dbs[db.name] = db inline_dbs[db.name] = db
@@ -104,10 +124,10 @@ local function parse_attributes(attr_str)
end end
local attrs = {} 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 attrs[key] = value
end 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 attrs[key] = value
end end
return attrs return attrs
@@ -62,10 +62,10 @@ local function parse_attributes(attr_str)
local attrs = {} local attrs = {}
-- Match key := 'value' or key := value patterns -- 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 attrs[key] = value
end 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 attrs[key] = value
end end
return attrs return attrs
@@ -166,7 +166,7 @@ local function parse_var_section(content, section_name)
return vars return vars
end end
-- Parse FB/Function content -- Parse FB/Function/OB content
function M.parse_fb_content(content, filename) function M.parse_fb_content(content, filename)
local fb = { local fb = {
filename = filename, filename = filename,
@@ -189,11 +189,17 @@ function M.parse_fb_content(content, filename)
fb_type = "FUNCTION" fb_type = "FUNCTION"
-- Extract return type if present -- Extract return type if present
fb.return_type = content:match('FUNCTION%s+"[^"]+"%s*:%s*(%w+)') 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
end end
if not fb_name then 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 end
fb.name = fb_name 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 = {} 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 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 clean_type = var_type:gsub('"', ''):gsub("#", ""):upper()
local params_info = builtin.get_parameters(clean_type) local params_info = builtin.get_parameters(clean_type)
if params_info then if params_info then
@@ -14,32 +20,41 @@ local function get_builtin_interface(var_type)
return nil return nil
end 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 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('"', '') local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then 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()) ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end end
return fb_parser.get_fb_interface(clean_type) return fb_parser.get_fb_interface(clean_type)
end 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 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('"', '') local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then 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()) ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end end
return fb_parser.is_fb_type(clean_type) return fb_parser.is_fb_type(clean_type)
end 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 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 variables = scl_vars.extract_variables(bufnr)
local known = {} local known = {}
for _, v in ipairs(variables) do for _, v in ipairs(variables) do
@@ -48,11 +63,17 @@ local function get_known_variables(bufnr)
return known return known
end 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 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) return scl_vars.get_variable_type(var_name, bufnr)
end end
--- Fill multiline parameters for FB call at cursor
--- Expands single-line call to multi-line with all parameters
function M.fill_multiline_params() function M.fill_multiline_params()
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0) 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 line_before_cursor = line:sub(1, col)
local known_vars = get_known_variables(bufnr) 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_name = nil
local fb_prefix = "" 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 }) vim.api.nvim_win_set_cursor(0, { new_cursor_row, new_cursor_col })
end 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 is_inside_fb_call(bufnr, cursor_row, cursor_col)
local function get_line_text(rownum) local function get_line_text(rownum)
return vim.api.nvim_buf_get_lines(bufnr, rownum - 1, rownum, false)[1] or "" 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 return false
end 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() function M.jump_to_next_param()
local cursor = vim.api.nvim_win_get_cursor(0) local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1] local row = cursor[1]
@@ -140,10 +140,10 @@ local function parse_attributes(attr_str)
local attrs = {} local attrs = {}
-- Match key := 'value' or key := value patterns -- 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 attrs[key] = value
end 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 attrs[key] = value
end end
return attrs return attrs
@@ -361,7 +361,7 @@ end
-- Parse all UDT files in project and libraries -- Parse all UDT files in project and libraries
function M.scan_project_udts(bufnr) 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 files = M.get_project_udt_files(bufnr)
local lib_files = M.get_library_udt_files() local lib_files = M.get_library_udt_files()
@@ -391,7 +391,7 @@ end
-- Parse all FB/Function files in project and libraries -- Parse all FB/Function files in project and libraries
function M.scan_project_fbs(bufnr) 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 files = M.get_project_fb_files(bufnr)
local lib_files = M.get_library_fb_files() local lib_files = M.get_library_fb_files()
@@ -421,7 +421,7 @@ end
-- Parse all Global DB files in project and libraries -- Parse all Global DB files in project and libraries
function M.scan_project_dbs(bufnr) 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 files = M.get_project_db_files(bufnr)
local lib_files = M.get_library_db_files() local lib_files = M.get_library_db_files()
@@ -486,9 +486,9 @@ function M.setup(opts)
pattern = "*.scl", pattern = "*.scl",
callback = function(args) callback = function(args)
-- Clear cache and rescan -- Clear cache and rescan
local udt_parser = require("scl.udt_parser") local udt_parser = require("tia.udt_parser")
local fb_parser = require("scl.fb_parser") local fb_parser = require("tia.fb_parser")
local db_parser = require("scl.db_parser") local db_parser = require("tia.db_parser")
udt_parser.clear_cache() udt_parser.clear_cache()
fb_parser.clear_cache() fb_parser.clear_cache()
db_parser.clear_cache() db_parser.clear_cache()
@@ -506,7 +506,7 @@ function M.setup(opts)
pattern = "*.udt", pattern = "*.udt",
callback = function(args) callback = function(args)
-- Re-parse the changed UDT file -- 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) udt_parser.parse_udt_file(args.file)
end, end,
}) })
@@ -517,7 +517,7 @@ function M.setup(opts)
pattern = "*.scl", pattern = "*.scl",
callback = function(args) callback = function(args)
-- Re-parse the changed FB file -- 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) fb_parser.parse_fb_file(args.file)
end, end,
}) })
@@ -527,7 +527,7 @@ function M.setup(opts)
group = augroup, group = augroup,
pattern = "*.db", pattern = "*.db",
callback = function(args) callback = function(args)
local db_parser = require("scl.db_parser") local db_parser = require("tia.db_parser")
db_parser.parse_db_file(args.file) db_parser.parse_db_file(args.file)
end, end,
}) })
+33 -35
View File
@@ -2,7 +2,7 @@ local M = {}
local function setup_package_path() local function setup_package_path()
local plugin_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "" 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" local lua_path = plugin_path .. "/lua"
if not package.path:match(lua_path) then if not package.path:match(lua_path) then
package.path = lua_path .. "/?.lua;" .. package.path package.path = lua_path .. "/?.lua;" .. package.path
@@ -13,7 +13,7 @@ function M.setup(opts)
setup_package_path() setup_package_path()
opts = opts or {} 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" } 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) local root_dir = vim.fs.root(fname, root_patterns)
or vim.fn.fnamemodify(fname, ":p:h") 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({ vim.lsp.start({
name = "scl_lsp", name = "tia_lsp",
cmd = { "lua", server_path }, cmd = cmd,
root_dir = root_dir, root_dir = root_dir,
on_attach = function(client, b) on_attach = function(client, b)
if opts.lsp and opts.lsp.on_attach then if opts.lsp and opts.lsp.on_attach then
@@ -34,13 +40,8 @@ function M.setup(opts)
end end
if opts.lsp and opts.lsp.formatting then if opts.lsp and opts.lsp.formatting then
vim.api.nvim_buf_create_user_command(b, "LspSCLFormat", function() vim.api.nvim_buf_create_user_command(b, "SCLFormat", function()
vim.lsp.buf.format({ name = "scl_lsp" }) vim.lsp.buf.format({ name = "tia_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
end, {}) end, {})
end end
end, end,
@@ -56,13 +57,8 @@ function M.setup(opts)
}) })
-- Create global format command (works on current buffer) -- Create global format command (works on current buffer)
vim.api.nvim_create_user_command("LspSCLFormat", function() vim.api.nvim_create_user_command("SCLFormat", function()
vim.lsp.buf.format({ name = "scl_lsp" }) vim.lsp.buf.format({ name = "tia_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
end, {}) end, {})
-- Start LSP for any existing scl buffers (handles lazy loading case) -- 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 if ok and parser_config then
parser_config.scl = { parser_config.scl = {
install_info = { 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" }, files = { "src/parser.c" },
generate_requires_npm = false, generate_requires_npm = false,
requires_generate_from_grammar = false, requires_generate_from_grammar = false,
@@ -102,7 +100,7 @@ function M.setup_syntax(opts)
if opts.cmp then if opts.cmp then
local function try_register() local function try_register()
local cmp_ok, cmp = pcall(require, "blink.cmp") 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 if cmp_ok and cmp and cmp.add_source_provider and blink_source_ok then
local source_config = { local source_config = {
name = "scl", name = "scl",
@@ -128,7 +126,7 @@ function M.setup_syntax(opts)
end end
if opts.workspace_types ~= false then 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 if ok then
workspace_types.setup({ workspace_types.setup({
project_root = opts.project_root, project_root = opts.project_root,
@@ -140,14 +138,14 @@ function M.setup_syntax(opts)
end end
function M.setup_auto_prefix() 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 if ok then
auto_prefix.setup() auto_prefix.setup()
end end
end end
function M.setup_multiline_params() 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 if not ok then
vim.notify("SCL multiline_params module not found", vim.log.levels.WARN) vim.notify("SCL multiline_params module not found", vim.log.levels.WARN)
return return
@@ -173,14 +171,14 @@ function M.setup_multiline_params()
end end
function M.prefix_current_word() 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 if ok then
auto_prefix.prefix_word() auto_prefix.prefix_word()
end end
end end
function M.show_variables() function M.show_variables()
local ok, variables = pcall(require, "scl.variables") local ok, variables = pcall(require, "tia.variables")
if not ok then if not ok then
vim.notify("SCL variables module not found", vim.log.levels.WARN) vim.notify("SCL variables module not found", vim.log.levels.WARN)
return return
@@ -201,9 +199,9 @@ function M.show_variables()
end end
function M.show_workspace_types() function M.show_workspace_types()
local ok_ws, ws = pcall(require, "scl.workspace_types") local ok_ws, ws = pcall(require, "tia.workspace_types")
local ok_udt, udt = pcall(require, "scl.udt_parser") local ok_udt, udt = pcall(require, "tia.udt_parser")
local ok_fb, fb = pcall(require, "scl.fb_parser") local ok_fb, fb = pcall(require, "tia.fb_parser")
if not ok_ws or not ok_udt or not ok_fb then if not ok_ws or not ok_udt or not ok_fb then
vim.notify("SCL workspace modules not found", vim.log.levels.WARN) vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
@@ -223,9 +221,9 @@ function M.show_workspace_types()
end end
function M.rescan_workspace_types() function M.rescan_workspace_types()
local ok_ws, ws = pcall(require, "scl.workspace_types") local ok_ws, ws = pcall(require, "tia.workspace_types")
local ok_udt, udt = pcall(require, "scl.udt_parser") local ok_udt, udt = pcall(require, "tia.udt_parser")
local ok_fb, fb = pcall(require, "scl.fb_parser") local ok_fb, fb = pcall(require, "tia.fb_parser")
if not ok_ws or not ok_udt or not ok_fb then if not ok_ws or not ok_udt or not ok_fb then
vim.notify("SCL workspace modules not found", vim.log.levels.WARN) vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
@@ -251,7 +249,7 @@ function M.create_commands()
-- Attribute toggle commands -- Attribute toggle commands
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function() 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 if ok then
at.toggle_attr_block() at.toggle_attr_block()
else else
@@ -260,7 +258,7 @@ function M.create_commands()
end, {}) end, {})
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function() 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 if ok then
at.expand_all_attr_blocks() at.expand_all_attr_blocks()
else else
@@ -269,7 +267,7 @@ function M.create_commands()
end, {}) end, {})
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function() 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 if ok then
at.collapse_all_attr_blocks() at.collapse_all_attr_blocks()
else else
@@ -279,7 +277,7 @@ function M.create_commands()
end end
function M.setup_attr_toggle() 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 if not ok then
vim.notify("SCL: attr_toggle module not found", vim.log.levels.WARN) vim.notify("SCL: attr_toggle module not found", vim.log.levels.WARN)
return return
@@ -1,5 +1,5 @@
return { return {
"lazar/scl_lsp", "lazar/tia-lsp.nvim",
ft = "scl", ft = "scl",
lazy = true, lazy = true,
dependencies = { dependencies = {
@@ -8,8 +8,9 @@ return {
"saghen/blink.cmp", "saghen/blink.cmp",
}, },
config = function(_, opts) config = function(_, opts)
require("scl_lsp").setup({ require("tia_lsp").setup({
server_path = opts.server_path, server_path = opts.server_path,
ts_parser_url = opts.ts_parser_url,
lsp = opts.lsp, lsp = opts.lsp,
cmp = opts.cmp ~= false, cmp = opts.cmp ~= false,
workspace_types = opts.workspace_types ~= 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()