Compare commits
19
Commits
15f88d110b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f219a8662f | ||
|
|
a9c1b8db16 | ||
|
|
a4400106db | ||
|
|
b86ce08426 | ||
|
|
0a0b5c9bf1 | ||
|
|
3776ce33ff | ||
|
|
1acac2b73c | ||
|
|
03548dab7d | ||
|
|
aebffc34e7 | ||
|
|
bd50f7b459 | ||
|
|
8255db7f64 | ||
|
|
8c6a050efb | ||
|
|
137eda3e77 | ||
|
|
15b0e5360a | ||
|
|
bfdec0c4b1 | ||
|
|
4471d9e1d9 | ||
|
|
eb10b1c0df | ||
|
|
5ca5628d39 | ||
|
|
3dfca6bba3 |
@@ -1,41 +1,31 @@
|
||||
# AGENTS.md - SCL Language Server
|
||||
# AGENTS.md - tia-lsp.nvim
|
||||
|
||||
A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion.
|
||||
Neovim plugin for Siemens TIA Portal languages. Currently supports **SCL**; planned support for STL/AWL, LAD, FBD. This repo is the plugin only (`lua/` + tree-sitter queries). The companion LSP server lives in `tia-lsp`.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# LSP Server
|
||||
make start # Start LSP server
|
||||
make test # Run LSP in test mode
|
||||
lua src/main.lua --test # Direct test execution
|
||||
|
||||
# Tree-sitter Parser
|
||||
tree-sitter generate # Generate parser from grammar.js
|
||||
tree-sitter test # Run parser tests
|
||||
tree-sitter parse <file.scl> # Parse single SCL file
|
||||
|
||||
# Lua Linting
|
||||
luacheck src/ # Lint LSP server code
|
||||
luacheck lua/scl/ # Lint plugin modules
|
||||
luacheck lua/tia/ # Lint language modules
|
||||
luacheck lua/tia_lsp/ # Lint plugin entry point
|
||||
|
||||
# Unit & Integration Tests
|
||||
lua test/run_tests.lua # Run all plugin tests
|
||||
lua test/test_udt.lua # Run UDT parser tests
|
||||
lua test/test_db.lua # Run DB parser tests
|
||||
lua test/test_fb.lua # Run FB parser tests
|
||||
lua test/test_integration.lua # Run integration tests
|
||||
|
||||
# Reference Project
|
||||
# Tests use files from: ~/Documents/siemens/scl_lang_support_lazyvim_ref_project/
|
||||
# Override with: SCL_REF_PROJECT=/path/to/ref lua tests/run_tests.lua
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
scl_lsp/
|
||||
├── src/ # LSP server (uses dofile)
|
||||
│ ├── main.lua # Entry point, JSON-RPC protocol
|
||||
│ ├── parser.lua # SCL parser
|
||||
│ ├── diagnostics.lua # Linter
|
||||
│ ├── formatter.lua # Document formatter
|
||||
│ ├── treesitter.lua # Tree-sitter integration
|
||||
│ ├── plc_json.lua # External UDT/DB loading
|
||||
│ ├── builtin_instructions.lua # TIA Portal built-in types/FBs/functions
|
||||
│ └── json.lua # JSON encoder/decoder
|
||||
├── lua/scl/ # Neovim plugin (uses require)
|
||||
│ ├── init.lua # Main plugin setup
|
||||
│ ├── blink_cmp_source.lua
|
||||
tia-lsp.nvim/
|
||||
├── lua/tia/ # Language modules (uses require)
|
||||
│ ├── udt_parser.lua # UDT parser
|
||||
│ ├── db_parser.lua # Global DB parser
|
||||
│ ├── fb_parser.lua # Function Block parser
|
||||
@@ -43,34 +33,36 @@ scl_lsp/
|
||||
│ ├── workspace_types.lua # Workspace scanning
|
||||
│ ├── multiline_params.lua
|
||||
│ ├── auto_prefix.lua
|
||||
│ └── attr_toggle.lua # Interactive attribute block toggle
|
||||
├── queries/ # Tree-sitter queries
|
||||
│ ├── highlights.scm
|
||||
│ ├── indents.scm
|
||||
│ └── folds.scm
|
||||
└── grammar.js # Tree-sitter grammar
|
||||
│ └── attr_toggle.lua
|
||||
├── lua/tia_lsp/ # Plugin entry point
|
||||
│ ├── init.lua # Main plugin setup (vim.lsp.start)
|
||||
│ └── plugins/scl.lua # LazyVim plugin spec
|
||||
├── queries/scl/ # Tree-sitter queries (per-language)
|
||||
└── ftdetect/scl.vim # Filetype detection
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
- **Project name** (repo, Lua module): `tia-lsp.nvim` / `tia_lsp` — umbrella for all TIA languages.
|
||||
- **Language name** (filetype, tree-sitter parser): `scl` — stays as-is. Future languages (`stl`, `lad`, `fbd`) get their own names.
|
||||
- **LSP client name**: `tia_lsp` (one client handles all TIA languages; dispatches by `filetype` / `languageId`).
|
||||
- **Commands**: `:SCLFormat`, `:SCLShowVariables`, etc. — per-filetype prefix. Future: `:STLFormat`, etc.
|
||||
|
||||
## Filetype Detection
|
||||
|
||||
The LSP requires the `scl` filetype to be set. Add to your Neovim config (e.g., `ftdetect/scl.vim`):
|
||||
|
||||
`ftdetect/scl.vim` registers the following extensions as `scl` filetype:
|
||||
```vim
|
||||
au BufRead,BufNewFile *.scl set filetype=scl
|
||||
au BufRead,BufNewFile *.udt set filetype=scl
|
||||
au BufRead,BufNewFile *.db set filetype=scl
|
||||
```
|
||||
|
||||
This enables LSP features (completion, goto definition, hover, etc.) for all SCL-related file types.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Module Pattern
|
||||
```lua
|
||||
-- File header comment
|
||||
local M = {}
|
||||
|
||||
-- Private cache
|
||||
local cache = {}
|
||||
|
||||
function M.public_function()
|
||||
@@ -89,16 +81,18 @@ return M
|
||||
- Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`)
|
||||
|
||||
### Imports
|
||||
- `lua/scl/` modules: use `require("scl.module_name")`
|
||||
- `src/` modules: use `dofile(script_path .. "/module.lua")`
|
||||
- `lua/tia/` modules: `require("tia.module_name")`
|
||||
- `lua/tia_lsp/` entry: `require("tia_lsp")`
|
||||
- External dependencies: wrap in `pcall()` for safety
|
||||
|
||||
### Code Formatting
|
||||
- `lua/scl/`: 2 spaces indentation
|
||||
- `src/`: tab-based indentation
|
||||
- `lua/tia/` and `lua/tia_lsp/`: 2 spaces indentation
|
||||
- Max line length: 120 characters
|
||||
- No trailing whitespace
|
||||
|
||||
### Comments
|
||||
Add comments to explain non-obvious code sections, complex logic, and public API functions. Keep comments concise and relevant.
|
||||
|
||||
### Parser Module API
|
||||
All parser modules must implement:
|
||||
- `parse_*_file(filepath)` - Parse and cache
|
||||
@@ -121,291 +115,52 @@ function M.clear_cache()
|
||||
end
|
||||
```
|
||||
|
||||
## Lua Reserved Keywords
|
||||
|
||||
Cannot use reserved keywords as table keys directly:
|
||||
```lua
|
||||
-- WRONG: syntax error
|
||||
local range = { start = pos1, end = pos2 }
|
||||
|
||||
-- CORRECT: bracket notation
|
||||
local range = { start = pos1, ["end"] = pos2 }
|
||||
```
|
||||
|
||||
Critical for LSP range objects with `end` field.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Return Pattern
|
||||
### Error Handling
|
||||
```lua
|
||||
function M.parse_file(filepath)
|
||||
local file = io.open(filepath, "r")
|
||||
if not file then
|
||||
return nil, "Cannot open file: " .. filepath
|
||||
end
|
||||
-- ... parse logic
|
||||
return result
|
||||
end
|
||||
```
|
||||
|
||||
### External Dependencies
|
||||
```lua
|
||||
-- External dependencies
|
||||
local ok, module = pcall(require, "some_module")
|
||||
if ok and module then
|
||||
module.do_something()
|
||||
end
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
The LSP validates data types and provides warnings for unknown types. It recognizes:
|
||||
- **Elementary data types** - BOOL, BYTE, WORD, DWORD, LWORD, CHAR, WCHAR, STRING, WSTRING, INT, DINT, LINT, USINT, SINT, UINT, UDINT, ULINT, REAL, LREAL, TIME, DATE, TOD, TIME_OF_DAY, DATE_AND_TIME, DT, S5TIME, LTIME, DTL
|
||||
- **Timer/counter types** - IEC_TIMER, TON_TIME, TOF_TIME, TONR_TIME, TP_TIME, CTU, CTD, CTUD
|
||||
- **TIA Portal built-in instructions** - Functions and function blocks
|
||||
- **Workspace types** - UDTs from `.udt` files and data blocks from `.db` files
|
||||
|
||||
## Completion Triggers
|
||||
- `#` - Local variables (after BEGIN)
|
||||
- `.` - Member access (UDT/DB)
|
||||
- `"` - Global DB names
|
||||
- `(` - FB/Function parameters
|
||||
- ` ` (space) - General completion
|
||||
- `:` - Type declaration (in VAR section)
|
||||
|
||||
### Completion Types
|
||||
The LSP provides auto-completion for:
|
||||
- **Local variables** - Variables declared in VAR sections
|
||||
- **UDT types** - User-defined types from `.udt` files
|
||||
- **Global DBs** - Data blocks from `.db` files
|
||||
- **Elementary types** - BOOL, INT, DINT, REAL, TIME, etc.
|
||||
- **TIA Portal built-in FBs** - TON, TOF, TP, CTU, CTD, CTUD, R_TRIG, etc.
|
||||
- **TIA Portal built-in functions** - ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
|
||||
|
||||
## Formatter Options
|
||||
|
||||
The formatter supports collapsing verbose attribute blocks:
|
||||
## Lua Reserved Keywords
|
||||
|
||||
Cannot use reserved keywords as table keys directly:
|
||||
```lua
|
||||
-- Before formatting:
|
||||
statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int;
|
||||
-- WRONG
|
||||
local range = { start = pos1, end = pos2 }
|
||||
|
||||
-- After formatting:
|
||||
statCntrPartsIn{...} : Int;
|
||||
-- CORRECT
|
||||
local range = { start = pos1, ["end"] = pos2 }
|
||||
```
|
||||
|
||||
### Formatter Configuration
|
||||
```lua
|
||||
local options = {
|
||||
insertSpaces = false,
|
||||
tabSize = 1,
|
||||
collapseAttributes = false, -- Disabled by default (see note below)
|
||||
collapsePatterns = { -- Override default patterns
|
||||
"^%s*{%s*EXTERNAL",
|
||||
"^%s*{ S7_",
|
||||
},
|
||||
extendCollapsePatterns = { -- Add custom patterns
|
||||
"^%s*{%s*CUSTOM",
|
||||
},
|
||||
}
|
||||
```
|
||||
Critical for LSP range objects with `end` field.
|
||||
|
||||
**Note:** `collapseAttributes` is disabled by default to preserve compatibility with the interactive toggle feature (`:SCLToggleAttrBlock`). When the formatter automatically collapses blocks, the original content is lost. To use both formatting and toggle:
|
||||
1. Format with `:LspSCLFormat` (preserves original content)
|
||||
2. Manually collapse with `:SCLCollapseAllAttrBlocks` (stores original content)
|
||||
3. Now toggle/expand works correctly
|
||||
## Type System
|
||||
|
||||
### Default Collapse Patterns
|
||||
The `:SCLCollapseAllAttrBlocks` command uses case-insensitive patterns:
|
||||
- `EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'}`
|
||||
- `S7_` - Block attributes: `{S7_Optimized_Access := 'TRUE'}`
|
||||
- `NonRetain` - NonRetain attribute: `{NonRetain}`
|
||||
- `Retain` - Retain attribute: `{Retain}`
|
||||
- `%w+ :=` - Generic attributes with assignments: `{key := 'value'}`
|
||||
- `%w+` - Single word attributes: `{SomeAttribute}`
|
||||
|
||||
Works in `.scl`, `.udt`, and `.db` files.
|
||||
|
||||
### Per-Variable Collapse Rules
|
||||
Control collapsing per variable using rules (evaluated before global patterns):
|
||||
|
||||
```lua
|
||||
local options = {
|
||||
collapseVariableRules = {
|
||||
-- Collapse attributes for variables starting with "stat"
|
||||
{
|
||||
variablePattern = "^stat",
|
||||
collapse = true,
|
||||
},
|
||||
-- Never collapse for "temp" prefix variables
|
||||
{
|
||||
variablePattern = "^temp",
|
||||
collapse = false,
|
||||
},
|
||||
-- Collapse only if both variable AND attribute match
|
||||
{
|
||||
variablePattern = "^config",
|
||||
attributePattern = "EXTERNAL",
|
||||
collapse = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Rule properties:**
|
||||
- `variablePattern` - Lua pattern to match variable name (optional)
|
||||
- `attributePattern` - Lua pattern to match attribute content (optional)
|
||||
- `collapse` - `true` to collapse to `{...}`, `false` to keep expanded
|
||||
|
||||
Rules are checked in order; first match wins.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `:SCLShowVariables` | Show local variables |
|
||||
| `:SCLShowWorkspaceTypes` | Show workspace types count |
|
||||
| `:SCLRescanWorkspaceTypes` | Rescan workspace |
|
||||
| `:SCLPrefixWord` | Prefix word with `#` |
|
||||
| `:SCLMultilineParams` | Fill FB parameters |
|
||||
| `:LspSCLFormat` | Format SCL file |
|
||||
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
|
||||
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
|
||||
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
|
||||
|
||||
## LSP Features
|
||||
|
||||
### Goto Declaration (`gD`)
|
||||
The LSP supports goto declaration functionality for:
|
||||
- **Functions** - Jump to function definition in `.scl` files
|
||||
- **Function Blocks (FB)** - Jump to FB definition in `.scl` files
|
||||
- **Data Blocks (DB)** - Jump to DB definition in `.scl` and `.db` files
|
||||
- **User-Defined Types (UDT)** - Jump to type definition in `.scl` and `.udt` files
|
||||
|
||||
Usage: Place cursor on a variable or type name and press `gD` (or use LSP client command).
|
||||
|
||||
## Auto-Editing Features
|
||||
|
||||
### Auto-Uppercase Keywords
|
||||
When typing SCL keywords followed by a space, they are automatically converted to uppercase:
|
||||
- `if ` → `IF `
|
||||
- `then ` → `THEN `
|
||||
- `end_if` → `END_IF`
|
||||
- etc.
|
||||
|
||||
Works in `.scl`, `.udt`, and `.db` files.
|
||||
|
||||
### Auto-Semicolon for Control Structures
|
||||
When pressing Enter after an `END_*` control structure keyword, a semicolon is automatically added:
|
||||
- `END_IF` → `END_IF;`
|
||||
- `END_FOR` → `END_FOR;`
|
||||
- `END_WHILE` → `END_WHILE;`
|
||||
- `END_REPEAT` → `END_REPEAT;`
|
||||
- `END_CASE` → `END_CASE;`
|
||||
|
||||
Block definitions (END_VAR, END_FUNCTION, etc.) are NOT affected.
|
||||
|
||||
### Auto-Prefix Local Variables
|
||||
Local variables are automatically prefixed with `#` when typing space or semicolon after BEGIN:
|
||||
- `myVar := ` → `#myVar := `
|
||||
- `counter.in` → `#counter.in` (on dot)
|
||||
|
||||
Can be toggled with `:lua require("scl.auto_prefix").toggle()`.
|
||||
|
||||
## Keybindings (SCL/UDT files)
|
||||
|
||||
### Attribute Block Toggle
|
||||
Interactive expand/collapse of individual `{...}` blocks (uses `<Leader>x` prefix):
|
||||
|
||||
| Key | Mode | Description |
|
||||
|-----|------|-------------|
|
||||
| `<Leader>xa` | Normal/Insert | Toggle block under cursor |
|
||||
| `<Leader>xae` | Normal | Expand all collapsed blocks |
|
||||
| `<Leader>xac` | Normal | Collapse all attribute blocks |
|
||||
|
||||
**How it works:**
|
||||
1. Place cursor inside any `{...}` block
|
||||
2. Press `<Leader>xa` to toggle between collapsed `{...}` and expanded content
|
||||
3. Original content is stored per-buffer and persists until buffer is closed
|
||||
4. Works on both formatter-collapsed blocks and manually collapsed ones
|
||||
|
||||
### Other Keybindings
|
||||
|
||||
| Key | Mode | Description |
|
||||
|-----|------|-------------|
|
||||
| `<Space>mp` | Insert | Fill multiline FB parameters |
|
||||
| `<Tab>` | Insert | Jump to next parameter or regular Tab |
|
||||
|
||||
Note: `<Leader>` is typically `\` (backslash) or `<Space>` depending on your configuration.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**If commands or keybindings don't work:**
|
||||
|
||||
1. Check if setup() was called:
|
||||
```lua
|
||||
:lua print(vim.inspect(require("scl_lsp")))
|
||||
```
|
||||
|
||||
2. Verify commands exist:
|
||||
```vim
|
||||
:command SCLToggleAttrBlock
|
||||
```
|
||||
Should show the command definition.
|
||||
|
||||
3. Check for errors during setup:
|
||||
```lua
|
||||
:lua require("scl_lsp").setup({})
|
||||
```
|
||||
|
||||
4. Verify leader key:
|
||||
```vim
|
||||
:echo mapleader
|
||||
```
|
||||
If empty, your leader is `\` (backslash).
|
||||
|
||||
5. Manual test keybinding:
|
||||
```vim
|
||||
:nmap <Leader>xa
|
||||
```
|
||||
Should show the mapping.
|
||||
|
||||
### Attribute Toggle After Formatting
|
||||
|
||||
**Note:** After using `:LspSCLFormat`, all collapsed attribute blocks will be expanded and the toggle state is reset. This is because formatting changes line numbers and positions. You can collapse blocks again after formatting.
|
||||
The LSP validates data types and provides warnings for unknown types:
|
||||
- **Elementary**: BOOL, BYTE, WORD, DWORD, INT, DINT, REAL, TIME, STRING, etc.
|
||||
- **Timer/counter**: IEC_TIMER, TON_TIME, TOF_TIME, CTU, CTD, CTUD
|
||||
- **Built-in FBs**: TON, TOF, TP, R_TRIG, F_TRIG, etc.
|
||||
- **Built-in functions**: ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
|
||||
- **Workspace types**: UDTs from `.udt` files, DBs from `.db` files
|
||||
|
||||
## Testing
|
||||
|
||||
Manual testing using: `~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project`
|
||||
|
||||
Workflow:
|
||||
1. Open `.scl` file in Neovim
|
||||
2. Test LSP features (hover, completion, go-to-definition)
|
||||
3. Verify diagnostics
|
||||
4. Test formatting
|
||||
5. Check workspace scanning
|
||||
|
||||
## LSP Implementation Notes
|
||||
|
||||
### Method Name Conversion
|
||||
```lua
|
||||
local method_name = message.method:gsub("/", "_")
|
||||
local handler = handlers[method_name]
|
||||
Use the reference project for testing:
|
||||
```
|
||||
~/Documents/siemens/scl_lang_support_lazyvim_ref_project/
|
||||
```
|
||||
|
||||
### Request vs Notification
|
||||
```lua
|
||||
if message.id then
|
||||
-- Request: send response
|
||||
return { id = message.id, result = result }
|
||||
end
|
||||
-- Notification: no response needed
|
||||
```
|
||||
|
||||
### Script Path Pattern
|
||||
```lua
|
||||
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
|
||||
if script_path == "" then
|
||||
script_path = "."
|
||||
end
|
||||
package.path = package.path .. ";" .. script_path .. "/?.lua"
|
||||
```
|
||||
Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of:
|
||||
- Plugin features (auto-prefix, workspace scanning, attribute toggle)
|
||||
- Workspace type scanning
|
||||
|
||||
@@ -1,49 +1,9 @@
|
||||
# SCL Language Server - Unified Plugin for Siemens SCL
|
||||
# tia-lsp.nvim - Neovim Plugin for Siemens TIA Portal
|
||||
|
||||
A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)**, **Linting**, **Formatting**, and **Syntax Highlighting** for Siemens SCL (Structured Control Language) used in Siemens TIA Portal.
|
||||
A Neovim/LazyVim plugin that launches the [`tia-lsp`](https://gitea.l-tech.rs/lazar/tia-lsp) language server and adds editor-specific features for Siemens TIA Portal languages. Currently supports **SCL** (Structured Control Language), with planned support for **STL/AWL**, **LAD**, and **FBD**.
|
||||
|
||||
## Features
|
||||
|
||||
### LSP (Language Server Protocol)
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Hover** | Press `K` to see variable type and struct fields |
|
||||
| **Completion** | `#` for variables, `.` for struct fields, `(` for functions |
|
||||
| **Go to Definition** | `gD` jump to function, FB, DB, or UDT definition |
|
||||
| **Document Symbols** | Shows functions, variables, types |
|
||||
| **Workspace Symbols** | Search across all `.scl` files with fuzzy matching |
|
||||
| **Semantic Tokens** | Keywords, variables highlighted |
|
||||
| **Inlay Hints** | Shows variable types after declaration |
|
||||
| **External UDT** | Load types from `.plc.json` or `data_types/` folder |
|
||||
|
||||
### Linter (Diagnostics)
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Undefined Variables** | Detects variables used without declaration |
|
||||
| **Invalid # Prefix** | Detects `#` prefix in VAR sections |
|
||||
| **Unknown Types** | Warns about unknown data types |
|
||||
| **Type Validation** | Basic type checking |
|
||||
|
||||
### Formatter
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Document Formatting** | Format entire SCL files |
|
||||
| **Indentation** | Proper block indentation |
|
||||
| **Keyword Casing** | Maintains SCL keyword casing |
|
||||
|
||||
### Syntax Highlighting
|
||||
- Full SCL syntax support via tree-sitter
|
||||
- Block definitions: ORGANIZATION_BLOCK, FUNCTION_BLOCK, FUNCTION
|
||||
- Variable sections: VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_CONSTANT
|
||||
- Control structures: IF/THEN/ELSIF/ELSE/END_IF, CASE/OF/END_CASE, FOR/TO/BY/DO/END_FOR, WHILE/END_WHILE, REPEAT/UNTIL/END_REPEAT
|
||||
- Built-in data types: BOOL, INT, DINT, REAL, STRING, TIME, etc.
|
||||
- Function block instances highlighted in calls
|
||||
- FB parameter names (IN, PT, CU, etc.) highlighted as properties
|
||||
- `#` prefix for local variables highlighted
|
||||
- Operators: AND, OR, NOT, XOR, MOD
|
||||
- Comments: line (`//`), block (`(* *)`), C-style (`/* */`)
|
||||
|
||||
### Additional Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) |
|
||||
@@ -51,45 +11,151 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)
|
||||
| **Tab Navigation** | Jump between parameters with `<Tab>` in insert mode |
|
||||
| **Workspace UDT Scanner** | Scans project for user-defined types (.udt 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 — DB members, FB parameters, built-in types (IEC_TIMER IN/PT/Q/ET), nested UDT/array-of-UDT path resolution |
|
||||
| **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `<Leader>xa` |
|
||||
| **Tree-sitter Highlighting** | Syntax highlighting via tree-sitter queries |
|
||||
|
||||
## Architecture
|
||||
|
||||
This plugin is part of a three-repo ecosystem:
|
||||
|
||||
```
|
||||
tia-lsp.nvim ← Neovim plugin (you are here) — launches the LSP client,
|
||||
│ adds editor features (auto-# prefix, blink.cmp source,
|
||||
│ attribute toggle, workspace scanning, ...)
|
||||
▼
|
||||
tia-lsp ← Standalone LSP server — reads JSON-RPC over stdio,
|
||||
│ provides hover, completion, diagnostics, formatting
|
||||
│ Requires: lua src/main.lua + compiled parser.so
|
||||
▲
|
||||
tia-mason-registry ← mason.nvim recipe — tells mason how to download tia-lsp,
|
||||
compile parser.so, and create the tia-lsp executable
|
||||
```
|
||||
|
||||
- **tia-lsp** ([repo](https://gitea.l-tech.rs/lazar/tia-lsp)) — the actual language server, editor-agnostic
|
||||
- **tia-mason-registry** ([repo](https://gitea.l-tech.rs/lazar/tia-mason-registry)) — a single `package.yaml` for mason
|
||||
- **tia-lsp.nvim** (this repo) — the Neovim-specific integration
|
||||
|
||||
## Installation
|
||||
|
||||
### LazyVim
|
||||
The plugin auto-detects the mason-installed `tia-lsp` executable via `vim.fn.exepath("tia-lsp")`. If not found, it falls back to running `lua <server_path>` (configurable).
|
||||
|
||||
### LazyVim + Mason (Recommended)
|
||||
|
||||
Mason downloads and builds the `tia-lsp` server; this plugin configures the LSP client.
|
||||
|
||||
**Step 1: Add the mason registry as a lazy.nvim plugin**
|
||||
|
||||
mason needs a local copy of the registry to read the install recipe. Adding it as a lazy.nvim plugin gets it cloned automatically:
|
||||
|
||||
```lua
|
||||
-- ~/.config/nvim/lua/plugins/scl.lua
|
||||
-- ~/.config/nvim/lua/plugins/tia-mason-registry.lua
|
||||
return {
|
||||
"lazar/scl_lsp",
|
||||
url = "https://gitea.l-tech.rs/lazar/tia-mason-registry.git",
|
||||
name = "tia-mason-registry",
|
||||
}
|
||||
```
|
||||
|
||||
**Step 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **Why this works:** lazy.nvim clones `tia-mason-registry` to `~/.local/share/nvim/lazy/tia-mason-registry`. The `file:` registry path points mason to that directory, where it finds `packages/tia-lsp/package.yaml`. Mason needs [`yq`](https://github.com/mikefarah/yq) to parse YAML custom registries — install it once: `:MasonInstall yq`
|
||||
|
||||
**Step 3: Install the LSP server**
|
||||
|
||||
```
|
||||
:MasonInstall tia-lsp
|
||||
```
|
||||
|
||||
This clones `tia-lsp` from the source repo, compiles `parser.so` (tree-sitter), and places the `tia-lsp` executable on PATH.
|
||||
|
||||
**Step 4: Add this plugin**
|
||||
|
||||
```lua
|
||||
-- ~/.config/nvim/lua/plugins/tia-lsp.lua
|
||||
return {
|
||||
"https://gitea.l-tech.rs/lazar/tia-lsp.nvim",
|
||||
ft = "scl",
|
||||
lazy = true,
|
||||
dependencies = {
|
||||
"neovim/nvim-lspconfig",
|
||||
"nvim-treesitter/nvim-treesitter",
|
||||
"saghen/blink.cmp",
|
||||
"saghen/blink.cmp", -- optional, for completion
|
||||
},
|
||||
config = function()
|
||||
require("scl_lsp").setup({
|
||||
-- LSP options
|
||||
opts = {
|
||||
lsp = {
|
||||
on_attach = function(client, bufnr)
|
||||
-- custom keymaps
|
||||
end,
|
||||
-- on_attach = function(client, bufnr) ... end,
|
||||
},
|
||||
cmp = true, -- enable blink.cmp integration
|
||||
workspace_types = true, -- scan project for UDTs and DBs
|
||||
auto_prefix = true, -- auto-# prefix for local variables
|
||||
},
|
||||
cmp = true, -- Enable blink.cmp integration
|
||||
workspace_types = true, -- Enable workspace UDT scanning
|
||||
auto_prefix = true, -- Enable auto-# prefixing
|
||||
debug = false,
|
||||
})
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
**Step 5: Configure blink.cmp defaults**
|
||||
|
||||
The `scl` completion source must be listed in blink.cmp's `sources.default` with the corresponding provider definition. Create or update your blink.cmp config:
|
||||
|
||||
```lua
|
||||
-- In your init.lua or plugin file
|
||||
require("scl_lsp").setup({
|
||||
server_path = "/path/to/scl_lsp/src/main.lua",
|
||||
-- ~/.config/nvim/lua/plugins/blink.lua (or extend your existing blink.cmp spec)
|
||||
return {
|
||||
"saghen/blink.cmp",
|
||||
optional = true,
|
||||
opts = {
|
||||
sources = {
|
||||
default = { "lsp", "path", "snippets", "buffer", "scl" },
|
||||
providers = {
|
||||
scl = {
|
||||
name = "scl",
|
||||
module = "tia.blink_cmp_source",
|
||||
score_offset = 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **Why this is needed:** blink.cmp only queries sources listed in `sources.default`. Without `"scl"` there, the DB member and built-in type completions (e.g., `"MyDB".myField` or `#myTimer.IN`) will not appear.
|
||||
|
||||
**Step 6: Verify**
|
||||
|
||||
Open a `.scl` file and run `:LspInfo`. `tia_lsp` should be attached. Run `:checkhealth` for detailed diagnostics.
|
||||
|
||||
---
|
||||
|
||||
### Manual Setup (without Mason)
|
||||
|
||||
Clone and build the server manually, then point the plugin at it.
|
||||
|
||||
```bash
|
||||
# 1. Clone the LSP server
|
||||
git clone --depth 1 https://gitea.l-tech.rs/lazar/tia-lsp.git ~/tia-lsp
|
||||
|
||||
# 2. Compile the tree-sitter parser (required for parsing SCL)
|
||||
cc -fPIC -I ~/tia-lsp/src/tree_sitter -c ~/tia-lsp/src/parser.c -o ~/tia-lsp/parser.o
|
||||
cc -shared ~/tia-lsp/parser.o -o ~/tia-lsp/parser.so
|
||||
```
|
||||
|
||||
Then configure the plugin. The plugin first looks for `tia-lsp` on PATH; if not found, it falls back to running `lua <server_path>`. The tree-sitter parser is loaded from `parser.so` adjacent to the server.
|
||||
|
||||
```lua
|
||||
require("tia_lsp").setup({
|
||||
server_path = "~/tia-lsp/src/main.lua", -- fallback if tia-lsp not on PATH
|
||||
ts_parser_url = "https://gitea.l-tech.rs/lazar/tia-lsp.git",
|
||||
cmp = true,
|
||||
workspace_types = true,
|
||||
auto_prefix = true,
|
||||
@@ -100,7 +166,8 @@ require("scl_lsp").setup({
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `server_path` | string | `~/dev/scl_lsp/src/main.lua` | Path to LSP server |
|
||||
| `server_path` | string | `~/dev/tia-lsp/src/main.lua` | Path to LSP server (fallback when `tia-lsp` not on PATH; change for manual installs) |
|
||||
| `ts_parser_url` | string | `file://~/dev/tia-lsp` | URL passed to nvim-treesitter for the SCL parser source |
|
||||
| `lsp.on_attach` | function | `nil` | Custom LSP attach callback |
|
||||
| `lsp.formatting` | boolean | `false` | Enable format command |
|
||||
| `cmp` | boolean | `true` | Enable blink.cmp integration |
|
||||
@@ -119,8 +186,7 @@ require("scl_lsp").setup({
|
||||
| `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs |
|
||||
| `:SCLPrefixWord` | Manually prefix current word with `#` |
|
||||
| `:SCLMultilineParams` | Fill multiline parameters for FB/Function call |
|
||||
| `:LspSCLFormat` | Format current SCL file |
|
||||
| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ |
|
||||
| `:SCLFormat` | Format current SCL file |
|
||||
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
|
||||
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
|
||||
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
|
||||
@@ -159,149 +225,76 @@ statCounter{...} : Int;
|
||||
- Use `<Leader>xae` to expand all blocks in buffer
|
||||
- Use `<Leader>xac` to collapse all blocks
|
||||
|
||||
**Note:** The formatter has automatic collapsing disabled by default to preserve compatibility with the toggle feature. Format with `:LspSCLFormat` first, then use `:SCLCollapseAllAttrBlocks` to collapse while preserving the ability to expand later.
|
||||
**Note:** Collapsed blocks are automatically expanded before saving to preserve full content on disk.
|
||||
|
||||
## External UDT Support
|
||||
## Filetype Detection
|
||||
|
||||
### Option 1: Create `.plc.json`
|
||||
```json
|
||||
{
|
||||
"dataTypes": [
|
||||
{
|
||||
"name": "equipmentData",
|
||||
"struct": [
|
||||
{"name": "id", "dataTypeName": "INT"},
|
||||
{"name": "status", "dataTypeName": "BOOL"},
|
||||
{"name": "temperature", "dataTypeName": "REAL"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
The plugin includes `ftdetect/scl.vim` which registers the following file extensions as `scl` filetype:
|
||||
- `*.scl` — SCL source files
|
||||
- `*.udt` — User-defined type files
|
||||
- `*.db` — Data block files
|
||||
|
||||
### Option 2: Auto-scan `data_types/` folder
|
||||
The LSP automatically scans for SCL files in `data_types/` folder and extracts TYPE definitions.
|
||||
|
||||
### Option 3: Generate `.plc.json`
|
||||
```bash
|
||||
lua /home/lazar/dev/scl_lsp/src/plc_json.lua --generate
|
||||
```
|
||||
|
||||
## Global Data Block Support
|
||||
|
||||
The plugin automatically scans for `.db` files in the workspace and provides auto-completion for global data blocks and their members.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Workspace Scanning**: When opening a `.scl` file, the plugin scans the project for `.db` files
|
||||
2. **DB Parsing**: Parses `DATA_BLOCK` definitions and extracts members from `VAR` sections
|
||||
3. **Auto-completion**: Provides smart completion for DB names and members
|
||||
|
||||
### Usage
|
||||
|
||||
- Type a space or `#` to trigger completion - global DB names appear alongside local variables
|
||||
- Type `"` followed by a DB name to get member completions
|
||||
- Access nested members: `"MyDB".member.submember`
|
||||
|
||||
### Example
|
||||
|
||||
```scl
|
||||
// Access global DB members
|
||||
"HmiData".units
|
||||
"Parameter".machine.unit100.paramTimeStampApplied
|
||||
|
||||
// Inside FB call parameter
|
||||
#instParameterManager(hmiInterface:="HmiData".units, ...)
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
scl_lsp/
|
||||
├── README.md # This file
|
||||
├── package.json # NPM scripts (tree-sitter)
|
||||
├── Makefile # Build commands
|
||||
├── grammar.js # Tree-sitter grammar
|
||||
├── tree-sitter.json # Parser config
|
||||
├── queries/ # Tree-sitter queries
|
||||
tia-lsp.nvim/
|
||||
├── lua/
|
||||
│ ├── tia_lsp/ # Plugin entry point
|
||||
│ │ ├── init.lua # Main setup (vim.lsp.start, exepath detection)
|
||||
│ │ └── plugins/
|
||||
│ │ └── scl.lua # LazyVim plugin spec (SCL filetype)
|
||||
│ └── tia/ # Language modules (shared across TIA languages)
|
||||
│ ├── auto_prefix.lua # Auto-# prefixing
|
||||
│ ├── attr_toggle.lua # Attribute block expand/collapse
|
||||
│ ├── blink_cmp_source.lua # blink.cmp completion source
|
||||
│ ├── builtin_instructions.lua
|
||||
│ ├── db_parser.lua # Global DB parser
|
||||
│ ├── fb_parser.lua # Function Block parser
|
||||
│ ├── multiline_params.lua # Multiline parameter filling
|
||||
│ ├── udt_parser.lua # UDT parser
|
||||
│ ├── variables.lua # Variable extraction
|
||||
│ └── workspace_types.lua # Workspace scanning
|
||||
├── queries/
|
||||
│ └── scl/ # Tree-sitter queries (per-language)
|
||||
│ ├── highlights.scm # Syntax highlighting
|
||||
│ ├── indents.scm # Indentation
|
||||
│ ├── folds.scm # Code folding
|
||||
│ ├── locals.scm # Variable scoping
|
||||
│ └── tags.scm # Navigation tags
|
||||
├── src/ # LSP server
|
||||
│ ├── main.lua # Main entry point
|
||||
│ ├── parser.lua # Regex-based parser
|
||||
│ ├── treesitter.lua # Tree-sitter integration
|
||||
│ ├── json.lua # JSON encoder/decoder
|
||||
│ ├── plc_json.lua # External UDT loading
|
||||
│ ├── diagnostics.lua # Linter diagnostics
|
||||
│ └── formatter.lua # Document formatter
|
||||
└── lua/
|
||||
└── scl_lsp/
|
||||
├── init.lua # Main plugin setup
|
||||
├── plugins/
|
||||
│ └── scl.lua # LazyVim plugin spec
|
||||
└── scl/ # Syntax/completion modules
|
||||
├── init.lua
|
||||
├── auto_prefix.lua
|
||||
├── blink_cmp_source.lua
|
||||
├── db_parser.lua
|
||||
├── fb_parser.lua
|
||||
├── udt_parser.lua
|
||||
├── variables.lua
|
||||
└── workspace_types.lua
|
||||
├── ftdetect/
|
||||
│ └── scl.vim # Filetype detection
|
||||
└── test/ # Plugin tests
|
||||
├── run_tests.lua
|
||||
├── test_harness.lua
|
||||
├── test_udt.lua
|
||||
├── test_db.lua
|
||||
├── test_fb.lua
|
||||
└── test_integration.lua
|
||||
```
|
||||
|
||||
## Building Tree-sitter Parser
|
||||
|
||||
```bash
|
||||
# Generate parser from grammar.js
|
||||
npm run build
|
||||
# or: tree-sitter generate
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
# or: tree-sitter test
|
||||
```
|
||||
|
||||
## SCL Code Example
|
||||
|
||||
```pascal
|
||||
FUNCTION_BLOCK "Em101Sequence"
|
||||
VAR
|
||||
x : INT;
|
||||
myVar : equipmentData; // User-defined type
|
||||
END_VAR
|
||||
|
||||
IF x > 5 THEN
|
||||
myVar.id := 10;
|
||||
END_IF
|
||||
|
||||
myVar.status := TRUE;
|
||||
myVar.temperature := 25.5;
|
||||
|
||||
END_FUNCTION_BLOCK
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The plugin uses a standalone LSP server (`src/main.lua`) that communicates via JSON-RPC over stdio. The server handles:
|
||||
- Text document synchronization
|
||||
- Diagnostics (linting)
|
||||
- Formatting
|
||||
- Completion, hover, go-to-definition
|
||||
- Semantic tokens
|
||||
|
||||
The Neovim plugin (`lua/scl_lsp/init.lua`) manages:
|
||||
- LSP client lifecycle via `vim.lsp.start()`
|
||||
- FileType autocmd for lazy loading
|
||||
- blink.cmp integration
|
||||
- Workspace scanning for UDTs/DBs
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Neovim 0.11+** (uses `vim.lsp.start()`)
|
||||
- **nvim-treesitter** - Syntax highlighting
|
||||
- **blink.cmp** - Optional, for completion
|
||||
- **Lua 5.1+** - LSP server runtime
|
||||
- **tia-lsp** — the LSP server (install via mason or manually)
|
||||
- **nvim-treesitter** — Syntax highlighting
|
||||
- **blink.cmp** — Optional, for completion
|
||||
- **Lua 5.1+** — LSP server runtime
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| `:MasonInstall tia-lsp` fails with "unknown package" | Custom registry not registered with mason | Check the `registries` list in your mason config includes the `file:` path to the cloned registry |
|
||||
| `:MasonInstall tia-lsp` fails with "yq: command not found" | Mason needs `yq` to parse the YAML registry | Run `:MasonInstall yq` to install it, then retry `:MasonInstall tia-lsp` |
|
||||
| `tia_lsp` not attached after opening `.scl` | Server not on PATH or `server_path` points to wrong location | Run `:LspInfo` to see what server is configured. Check `vim.fn.exepath("tia-lsp")` returns a path |
|
||||
| LSP starts but no syntax highlighting | Tree-sitter parser (`parser.so`) not found | mason install generates it automatically. For manual setups, run the `cc` commands in the Manual Setup section |
|
||||
| `parser.so` load error at startup | Compiled for wrong architecture or Lua version | Recompile: clean the old `.so` and run the `cc` build commands again |
|
||||
| LSP starts but no completions/hover | Server can't find its modules | Mason installs to a managed directory. If using manual setup, ensure `src/` contents are present next to `main.lua` |
|
||||
|
||||
## 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`).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,330 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
local function setup_package_path()
|
||||
local plugin_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
|
||||
plugin_path = plugin_path:gsub("/lua/scl_lsp", "")
|
||||
local lua_path = plugin_path .. "/lua"
|
||||
if not package.path:match(lua_path) then
|
||||
package.path = lua_path .. "/?.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup(opts)
|
||||
setup_package_path()
|
||||
opts = opts or {}
|
||||
|
||||
local server_path = opts.server_path or vim.fn.expand("~/dev/scl_lsp/src/main.lua")
|
||||
|
||||
local root_patterns = { ".git", "data_types", "plc.data.json" }
|
||||
|
||||
local function start_lsp(bufnr)
|
||||
local fname = vim.api.nvim_buf_get_name(bufnr)
|
||||
if fname == "" then return end
|
||||
|
||||
local root_dir = vim.fs.root(fname, root_patterns)
|
||||
or vim.fn.fnamemodify(fname, ":p:h")
|
||||
|
||||
vim.lsp.start({
|
||||
name = "scl_lsp",
|
||||
cmd = { "lua", server_path },
|
||||
root_dir = root_dir,
|
||||
on_attach = function(client, b)
|
||||
if opts.lsp and opts.lsp.on_attach then
|
||||
opts.lsp.on_attach(client, b)
|
||||
end
|
||||
|
||||
if opts.lsp and opts.lsp.formatting then
|
||||
vim.api.nvim_buf_create_user_command(b, "LspSCLFormat", function()
|
||||
vim.lsp.buf.format({ name = "scl_lsp" })
|
||||
-- Clear attr_toggle state since line positions may have changed
|
||||
local ok, at = pcall(require, "scl.attr_toggle")
|
||||
if ok then
|
||||
at.clear_buffer_state(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
end, {})
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Create FileType autocmd for future buffers
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
pattern = "scl",
|
||||
callback = function(args)
|
||||
start_lsp(args.buf)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Create global format command (works on current buffer)
|
||||
vim.api.nvim_create_user_command("LspSCLFormat", function()
|
||||
vim.lsp.buf.format({ name = "scl_lsp" })
|
||||
-- Clear attr_toggle state since line positions may have changed
|
||||
local ok, at = pcall(require, "scl.attr_toggle")
|
||||
if ok then
|
||||
at.clear_buffer_state(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
end, {})
|
||||
|
||||
-- Start LSP for any existing scl buffers (handles lazy loading case)
|
||||
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.bo[bufnr].filetype == "scl" and vim.api.nvim_buf_is_loaded(bufnr) then
|
||||
start_lsp(bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
M.setup_syntax(opts)
|
||||
M.setup_multiline_params()
|
||||
M.setup_attr_toggle()
|
||||
M.create_commands()
|
||||
|
||||
if opts.auto_prefix ~= false then
|
||||
M.setup_auto_prefix()
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_syntax(opts)
|
||||
local has_ts, ts = pcall(require, "nvim-treesitter.parsers")
|
||||
if has_ts and ts.get_parser_configs then
|
||||
local ok, parser_config = pcall(ts.get_parser_configs)
|
||||
if ok and parser_config then
|
||||
parser_config.scl = {
|
||||
install_info = {
|
||||
url = "file://" .. vim.fn.expand("~/dev/scl_lsp"),
|
||||
files = { "src/parser.c" },
|
||||
generate_requires_npm = false,
|
||||
requires_generate_from_grammar = false,
|
||||
},
|
||||
filetype = "scl",
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if opts.cmp then
|
||||
local function try_register()
|
||||
local cmp_ok, cmp = pcall(require, "blink.cmp")
|
||||
local blink_source_ok, blink_source = pcall(require, "scl.blink_cmp_source")
|
||||
if cmp_ok and cmp and cmp.add_source_provider and blink_source_ok then
|
||||
local source_config = {
|
||||
name = "scl",
|
||||
provider = blink_source,
|
||||
score_offset = 100,
|
||||
}
|
||||
pcall(cmp.add_source_provider, "scl", source_config)
|
||||
pcall(cmp.add_filetype_source, "scl", "scl")
|
||||
|
||||
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
|
||||
|
||||
if opts.workspace_types ~= false then
|
||||
local ok, workspace_types = pcall(require, "scl.workspace_types")
|
||||
if ok then
|
||||
workspace_types.setup({
|
||||
project_root = opts.project_root,
|
||||
library_paths = opts.library_paths,
|
||||
debug = opts.debug,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_auto_prefix()
|
||||
local ok, auto_prefix = pcall(require, "scl.auto_prefix")
|
||||
if ok then
|
||||
auto_prefix.setup()
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_multiline_params()
|
||||
local ok, mp = pcall(require, "scl.multiline_params")
|
||||
if not ok then
|
||||
vim.notify("SCL multiline_params module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
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.prefix_current_word()
|
||||
local ok, auto_prefix = pcall(require, "scl.auto_prefix")
|
||||
if ok then
|
||||
auto_prefix.prefix_word()
|
||||
end
|
||||
end
|
||||
|
||||
function M.show_variables()
|
||||
local ok, variables = pcall(require, "scl.variables")
|
||||
if not ok then
|
||||
vim.notify("SCL variables module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local vars = 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 ok_ws, ws = pcall(require, "scl.workspace_types")
|
||||
local ok_udt, udt = pcall(require, "scl.udt_parser")
|
||||
local ok_fb, fb = pcall(require, "scl.fb_parser")
|
||||
|
||||
if not ok_ws or not ok_udt or not ok_fb then
|
||||
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local root = ws.get_project_root()
|
||||
local udt_names = udt.get_all_udt_names()
|
||||
local fb_names = fb.get_all_fb_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, vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
function M.rescan_workspace_types()
|
||||
local ok_ws, ws = pcall(require, "scl.workspace_types")
|
||||
local ok_udt, udt = pcall(require, "scl.udt_parser")
|
||||
local ok_fb, fb = pcall(require, "scl.fb_parser")
|
||||
|
||||
if not ok_ws or not ok_udt or not ok_fb then
|
||||
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
udt.clear_cache()
|
||||
fb.clear_cache()
|
||||
ws.clear_root_cache()
|
||||
|
||||
local udt_result = ws.scan_project_udts()
|
||||
local fb_result = ws.scan_project_fbs()
|
||||
|
||||
vim.notify("Scanned UDTs: " .. udt_result.parsed_count .. "/" .. udt_result.total_files ..
|
||||
", FBs: " .. fb_result.parsed_count .. "/" .. fb_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, {})
|
||||
vim.api.nvim_create_user_command("SCLPrefixWord", function() M.prefix_current_word() 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: " .. tostring(at), 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: " .. tostring(at), 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: " .. tostring(at), vim.log.levels.ERROR)
|
||||
end
|
||||
end, {})
|
||||
end
|
||||
|
||||
function M.setup_attr_toggle()
|
||||
local ok, at = pcall(require, "scl.attr_toggle")
|
||||
if not ok then
|
||||
vim.notify("SCL: attr_toggle module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
at.setup()
|
||||
|
||||
local function setup_buffer_keymaps(bufnr)
|
||||
local opts = { buffer = bufnr, silent = true }
|
||||
local ft = vim.bo[bufnr].filetype
|
||||
|
||||
if ft ~= "scl" and ft ~= "udt" then
|
||||
return
|
||||
end
|
||||
|
||||
-- Toggle individual attribute block under cursor
|
||||
vim.keymap.set({ "n", "i" }, "<Leader>xa", function()
|
||||
at.toggle_attr_block()
|
||||
end, vim.tbl_extend("force", opts, { desc = "SCL: Toggle attribute block" }))
|
||||
|
||||
-- Expand all collapsed blocks
|
||||
vim.keymap.set("n", "<Leader>xae", function()
|
||||
at.expand_all_attr_blocks()
|
||||
end, vim.tbl_extend("force", opts, { desc = "SCL: Expand all attribute blocks" }))
|
||||
|
||||
-- Collapse all attribute blocks
|
||||
vim.keymap.set("n", "<Leader>xac", function()
|
||||
at.collapse_all_attr_blocks()
|
||||
end, vim.tbl_extend("force", opts, { desc = "SCL: Collapse all attribute blocks" }))
|
||||
end
|
||||
|
||||
-- Set up keybindings for SCL files only
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
pattern = { "scl", "udt" },
|
||||
callback = function(args)
|
||||
setup_buffer_keymaps(args.buf)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Also check if there are already SCL buffers open
|
||||
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(bufnr) then
|
||||
setup_buffer_keymaps(bufnr)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -80,23 +80,24 @@ function M.toggle_attr_block()
|
||||
end
|
||||
|
||||
-- Expand all collapsed blocks in current buffer
|
||||
function M.expand_all_attr_blocks()
|
||||
function M.expand_all_attr_blocks(opts)
|
||||
opts = opts or {}
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local count = 0
|
||||
|
||||
if not buffer_attr_state[bufnr] then
|
||||
if not opts.silent then
|
||||
vim.notify("No collapsed blocks to expand", vim.log.levels.INFO)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for state_key, state in pairs(buffer_attr_state[bufnr]) do
|
||||
if state.is_collapsed then
|
||||
-- state.line_num is 1-indexed, but nvim_buf_get_lines returns 0-indexed array
|
||||
local line = lines[state.line_num]
|
||||
|
||||
if line then
|
||||
-- Find the collapsed {...} and replace with expanded content
|
||||
local new_line = line:sub(1, state.start_col) .. state.expanded .. line:sub(state.start_col + 4)
|
||||
lines[state.line_num] = new_line
|
||||
state.is_collapsed = false
|
||||
@@ -106,57 +107,49 @@ function M.expand_all_attr_blocks()
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
-- Helper to make a pattern case-insensitive
|
||||
local function case_insensitive_pattern(pattern)
|
||||
return pattern:gsub("(%a)", function(c)
|
||||
return "[" .. c:upper() .. c:lower() .. "]"
|
||||
end)
|
||||
-- Find the last line of the declaration section based on filetype
|
||||
local function get_declaration_end(lines, ft)
|
||||
if ft == "udt" then
|
||||
return #lines
|
||||
end
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^%s*BEGIN%s*$") then
|
||||
return i - 1
|
||||
end
|
||||
end
|
||||
return #lines
|
||||
end
|
||||
|
||||
-- Collapse all attribute blocks matching patterns in current buffer
|
||||
function M.collapse_all_attr_blocks(patterns)
|
||||
patterns = patterns or {
|
||||
case_insensitive_pattern("^%s*EXTERNAL"), -- EXTERNALACCESSIBLE, EXTERNALVISIBLE
|
||||
case_insensitive_pattern("^%s*S7_"), -- S7_Optimized_Access
|
||||
case_insensitive_pattern("^%s*NonRetain"), -- NonRetain
|
||||
case_insensitive_pattern("^%s*Retain"), -- Retain (but not NonRetain)
|
||||
"^%s*%w+%s*:=", -- key := value
|
||||
"^%s*%w+%s*$", -- single word attribute
|
||||
}
|
||||
|
||||
-- Collapse all {...} blocks in the declaration section of current buffer
|
||||
function M.collapse_all_attr_blocks()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local ft = vim.bo[bufnr].filetype
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local count = 0
|
||||
|
||||
-- Initialize buffer state
|
||||
local decl_end = get_declaration_end(lines, ft)
|
||||
|
||||
if not buffer_attr_state[bufnr] then
|
||||
buffer_attr_state[bufnr] = {}
|
||||
end
|
||||
|
||||
for line_idx, line in ipairs(lines) do
|
||||
for line_idx = 1, decl_end do
|
||||
local line = lines[line_idx]
|
||||
local pos = 1
|
||||
while pos <= #line do
|
||||
local start_pos, end_pos, capture = line:find("{(.-)}", pos)
|
||||
local start_pos, end_pos = line:find("{(.-)}", pos)
|
||||
if not start_pos then
|
||||
break
|
||||
end
|
||||
|
||||
local content = line:sub(start_pos + 1, end_pos - 1)
|
||||
|
||||
-- Check if content matches any pattern
|
||||
local should_collapse = false
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if content:match(pattern) then
|
||||
should_collapse = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Don't collapse if already collapsed
|
||||
if should_collapse and content ~= "..." then
|
||||
if content ~= "..." then
|
||||
local state_key = line_idx .. "_" .. start_pos
|
||||
buffer_attr_state[bufnr][state_key] = {
|
||||
collapsed = "...",
|
||||
@@ -166,17 +159,13 @@ function M.collapse_all_attr_blocks(patterns)
|
||||
start_col = start_pos,
|
||||
}
|
||||
|
||||
-- Replace in line
|
||||
line = line:sub(1, start_pos) .. "..." .. line:sub(end_pos)
|
||||
count = count + 1
|
||||
|
||||
-- Update position for next search
|
||||
pos = start_pos + 4
|
||||
else
|
||||
pos = end_pos + 1
|
||||
end
|
||||
end
|
||||
|
||||
lines[line_idx] = line
|
||||
end
|
||||
|
||||
@@ -193,6 +182,25 @@ end
|
||||
function M.setup()
|
||||
local augroup = vim.api.nvim_create_augroup("SCLAttrToggle", { clear = true })
|
||||
|
||||
vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
group = augroup,
|
||||
pattern = { "*.scl", "*.udt", "*.db" },
|
||||
callback = function(args)
|
||||
if buffer_attr_state[args.buf] then
|
||||
local has_collapsed = false
|
||||
for _, state in pairs(buffer_attr_state[args.buf]) do
|
||||
if state.is_collapsed then
|
||||
has_collapsed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if has_collapsed then
|
||||
M.expand_all_attr_blocks({ silent = true })
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd("BufUnload", {
|
||||
group = augroup,
|
||||
pattern = { "*.scl", "*.udt", "*.db" },
|
||||
@@ -71,7 +71,7 @@ local function get_local_variables()
|
||||
end
|
||||
end
|
||||
|
||||
local scl_vars = require("scl.variables")
|
||||
local scl_vars = require("tia.variables")
|
||||
local vars = scl_vars.extract_variables(bufnr)
|
||||
|
||||
var_cache = {}
|
||||
@@ -545,7 +545,7 @@ function M.setup()
|
||||
local before_semicolon = line:sub(1, cursor_col - 1)
|
||||
local type_name = before_semicolon:match(":%s*([%w_]+)%s*$")
|
||||
if type_name then
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
local is_udt = udt_parser.is_udt_type(type_name)
|
||||
if is_udt and not before_semicolon:match(":%s*\"" .. type_name .. "\"") then
|
||||
local new_before = before_semicolon:gsub(":%s*" .. type_name .. "%s*$", ':"' .. type_name .. '"')
|
||||
@@ -104,7 +104,7 @@ end
|
||||
|
||||
-- Get all known local variables
|
||||
local function get_known_variables(bufnr)
|
||||
local scl_vars = require("scl.variables")
|
||||
local scl_vars = require("tia.variables")
|
||||
local variables = scl_vars.extract_variables(bufnr)
|
||||
local known = {}
|
||||
for _, v in ipairs(variables) do
|
||||
@@ -115,12 +115,12 @@ end
|
||||
|
||||
-- Check if a type is an FB/Function and get its interface
|
||||
local function get_fb_interface(var_type)
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local clean_type = var_type:gsub('"', '')
|
||||
|
||||
-- If cache is empty, try to scan workspace for FBs
|
||||
if fb_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
|
||||
@@ -129,12 +129,12 @@ end
|
||||
|
||||
-- Check if a type is an FB/Function
|
||||
local function is_fb_type(var_type)
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local clean_type = var_type:gsub('"', '')
|
||||
|
||||
-- If cache is empty, try to scan workspace for FBs
|
||||
if fb_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
|
||||
@@ -236,13 +236,19 @@ function M:get_completions(context, callback)
|
||||
local after_closed_db = active_portion:match('"([%w_]+)"%s*%.') ~= nil
|
||||
|
||||
if db_member_pattern or after_closed_db then
|
||||
local db_parser = require("scl.db_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
local db_name = active_portion:match('"([%w_]+)"')
|
||||
|
||||
-- Ensure cache is populated
|
||||
if db_name and db_parser.get_cache_count() == 0 then
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_dbs(bufnr)
|
||||
end
|
||||
|
||||
if db_name then
|
||||
local db = db_parser.get_db(db_name)
|
||||
if db then
|
||||
local path = active_portion:match('"[^"]+%"%.([%w_.]*)')
|
||||
local path = active_portion:match('"[^"]+"%.([%w_.]*)$')
|
||||
|
||||
-- Resolve nested path through DB members
|
||||
local current_type = nil
|
||||
@@ -269,15 +275,23 @@ function M:get_completions(context, callback)
|
||||
|
||||
if found_member then
|
||||
resolved_path = part
|
||||
local nested_type = nil
|
||||
if found_member.is_udt then
|
||||
-- This member is a UDT type, continue resolving
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
current_members = udt_parser.get_udt_members(found_member.type)
|
||||
current_type = found_member.type
|
||||
nested_type = found_member.type
|
||||
else
|
||||
-- Check for array of UDT: Array[..] of "TypeName"
|
||||
local array_udt = found_member.raw_type:match('Array%b[]%s*of%s*"([^"]+)"')
|
||||
if array_udt then
|
||||
nested_type = array_udt
|
||||
end
|
||||
end
|
||||
if nested_type then
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
current_members = udt_parser.get_udt_members(nested_type)
|
||||
current_type = nested_type
|
||||
else
|
||||
-- This is a basic type, can't continue
|
||||
if i < #path_parts then
|
||||
-- There are more parts but this isn't a UDT
|
||||
found = false
|
||||
break
|
||||
end
|
||||
@@ -306,7 +320,7 @@ function M:get_completions(context, callback)
|
||||
|
||||
-- Get members to show (either from DB or from resolved UDT type)
|
||||
if current_type then
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
current_members = udt_parser.get_udt_members(current_type)
|
||||
end
|
||||
|
||||
@@ -319,14 +333,11 @@ function M:get_completions(context, callback)
|
||||
detail = m.raw_type,
|
||||
})
|
||||
end
|
||||
local wrapped_callback = vim.schedule_wrap(function(response)
|
||||
callback({
|
||||
items = response.items,
|
||||
items = items,
|
||||
is_incomplete_backward = false,
|
||||
is_incomplete_forward = false,
|
||||
})
|
||||
end)
|
||||
wrapped_callback({ items = items })
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -335,11 +346,11 @@ function M:get_completions(context, callback)
|
||||
|
||||
-- Check for just " pattern (start of DB name completion or partial DB name)
|
||||
if is_in_db_name or is_partial_db or is_after_colon_quote then
|
||||
local db_parser = require("scl.db_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
|
||||
-- If cache is empty, try to scan workspace for DBs
|
||||
if db_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_dbs(bufnr)
|
||||
end
|
||||
|
||||
@@ -376,7 +387,7 @@ function M:get_completions(context, callback)
|
||||
end
|
||||
end
|
||||
|
||||
local scl_vars = require("scl.variables")
|
||||
local scl_vars = require("tia.variables")
|
||||
local variables = scl_vars.extract_variables(bufnr)
|
||||
|
||||
-- Get all known local variables and their types
|
||||
@@ -747,8 +758,8 @@ function M:get_completions(context, callback)
|
||||
end)
|
||||
|
||||
vim.schedule(function()
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local db_parser = require("scl.db_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
|
||||
-- No member access = show local variables, UDT types, and basic types
|
||||
if not base_var then
|
||||
@@ -779,9 +790,9 @@ function M:get_completions(context, callback)
|
||||
end
|
||||
|
||||
-- Add Global DB names (without quotes - will be wrapped in resolve)
|
||||
local db_parser = require("scl.db_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
if db_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_dbs(bufnr)
|
||||
end
|
||||
|
||||
@@ -959,6 +970,28 @@ function M:get_completions(context, callback)
|
||||
end
|
||||
|
||||
if not is_udt and not is_fb then
|
||||
-- Check built-in instructions (IEC_TIMER, TON, etc.)
|
||||
local builtin = require("tia.builtin_instructions")
|
||||
local builtin_params = builtin.get_parameters(var_type)
|
||||
if builtin_params then
|
||||
local items = {}
|
||||
for _, inp in ipairs(builtin_params.inputs or {}) do
|
||||
table.insert(items, {
|
||||
label = inp.name,
|
||||
kind = vim.lsp.protocol.CompletionItemKind.Field,
|
||||
detail = (inp.type or "ANY") .. " (Input)",
|
||||
})
|
||||
end
|
||||
for _, out in ipairs(builtin_params.outputs or {}) do
|
||||
table.insert(items, {
|
||||
label = out.name,
|
||||
kind = vim.lsp.protocol.CompletionItemKind.Field,
|
||||
detail = (out.type or "ANY") .. " (Output)",
|
||||
})
|
||||
end
|
||||
wrapped_callback({ items = items })
|
||||
return
|
||||
end
|
||||
wrapped_callback({ items = {} })
|
||||
return
|
||||
end
|
||||
@@ -1,5 +1,9 @@
|
||||
-- TIA Portal built-in instructions for SCL Neovim plugin
|
||||
-- Defines functions, function blocks, and their parameters for completion
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Built-in functions (return values, no state)
|
||||
M.FUNCTIONS = {
|
||||
ADD = true,
|
||||
SUB = true,
|
||||
@@ -38,6 +42,7 @@ M.FUNCTIONS = {
|
||||
I_BCD = true,
|
||||
}
|
||||
|
||||
-- Built-in function blocks (have state, need instance)
|
||||
M.FUNCTION_BLOCKS = {
|
||||
TON = true,
|
||||
TOF = true,
|
||||
@@ -64,6 +69,8 @@ M.FUNCTION_BLOCKS = {
|
||||
DATALOG = true,
|
||||
}
|
||||
|
||||
-- Parameter definitions for built-in instructions
|
||||
-- Format: { inputs = { { name, type }, ... }, outputs = { { name, type }, ... } }
|
||||
M.PARAMETERS = {
|
||||
TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
|
||||
TOF = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
|
||||
@@ -121,14 +128,23 @@ M.PARAMETERS = {
|
||||
I_BCD = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
|
||||
}
|
||||
|
||||
--- Get parameter info for a built-in instruction
|
||||
--- @param name string The instruction name
|
||||
--- @return table|nil Parameter definition with inputs and outputs arrays
|
||||
function M.get_parameters(name)
|
||||
return M.PARAMETERS[name:upper()]
|
||||
end
|
||||
|
||||
--- Check if name is a known function block
|
||||
--- @param name string The name to check
|
||||
--- @return boolean True if known function block
|
||||
function M.is_known_function_block(name)
|
||||
return M.FUNCTION_BLOCKS[name:upper()] or false
|
||||
end
|
||||
|
||||
--- Check if name is a known function
|
||||
--- @param name string The name to check
|
||||
--- @return boolean True if known function
|
||||
function M.is_known_function(name)
|
||||
return M.FUNCTIONS[name:upper()] or false
|
||||
end
|
||||
@@ -3,10 +3,30 @@
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Cache for parsed DBs: { [db_name] = db_definition }
|
||||
local db_cache = {}
|
||||
|
||||
-- Inline DB definitions (from comments): { [db_name] = db_definition }
|
||||
local inline_dbs = {}
|
||||
|
||||
-- DB Definition structure:
|
||||
-- {
|
||||
-- name = "DBName",
|
||||
-- title = "Data Block Title",
|
||||
-- version = "0.1",
|
||||
-- comment = "User comment",
|
||||
-- members = {
|
||||
-- {
|
||||
-- name = "memberName",
|
||||
-- type = "Bool" or '"OtherUdt"',
|
||||
-- attributes = "{ S7_SetPoint := 'True' }",
|
||||
-- comment = "Member comment",
|
||||
-- is_udt = false -- true if type is a quoted UDT reference
|
||||
-- }
|
||||
-- }
|
||||
-- }
|
||||
|
||||
-- Register an inline DB definition (from comments or other sources)
|
||||
function M.register_inline_db(db)
|
||||
if db and db.name then
|
||||
inline_dbs[db.name] = db
|
||||
@@ -104,10 +124,10 @@ local function parse_attributes(attr_str)
|
||||
end
|
||||
|
||||
local attrs = {}
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
|
||||
attrs[key] = value
|
||||
end
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
|
||||
attrs[key] = value
|
||||
end
|
||||
return attrs
|
||||
@@ -62,10 +62,10 @@ local function parse_attributes(attr_str)
|
||||
|
||||
local attrs = {}
|
||||
-- Match key := 'value' or key := value patterns
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
|
||||
attrs[key] = value
|
||||
end
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
|
||||
attrs[key] = value
|
||||
end
|
||||
return attrs
|
||||
@@ -166,7 +166,7 @@ local function parse_var_section(content, section_name)
|
||||
return vars
|
||||
end
|
||||
|
||||
-- Parse FB/Function content
|
||||
-- Parse FB/Function/OB content
|
||||
function M.parse_fb_content(content, filename)
|
||||
local fb = {
|
||||
filename = filename,
|
||||
@@ -189,11 +189,17 @@ function M.parse_fb_content(content, filename)
|
||||
fb_type = "FUNCTION"
|
||||
-- Extract return type if present
|
||||
fb.return_type = content:match('FUNCTION%s+"[^"]+"%s*:%s*(%w+)')
|
||||
else
|
||||
-- Try ORGANIZATION_BLOCK
|
||||
fb_name = content:match('ORGANIZATION_BLOCK%s+"([^"]+)"')
|
||||
if fb_name then
|
||||
fb_type = "ORGANIZATION_BLOCK"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not fb_name then
|
||||
return nil, "No FUNCTION_BLOCK or FUNCTION definition found"
|
||||
return nil, "No FUNCTION_BLOCK, FUNCTION, or ORGANIZATION_BLOCK definition found"
|
||||
end
|
||||
|
||||
fb.name = fb_name
|
||||
@@ -1,7 +1,13 @@
|
||||
-- Multiline parameter filling for SCL function block calls
|
||||
-- Automatically expands FB calls to multi-line format with all parameters
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Get interface for built-in FB types (TON, TOF, etc.)
|
||||
--- @param var_type string The FB type name
|
||||
--- @return table|nil Interface with inputs, outputs, inouts
|
||||
local function get_builtin_interface(var_type)
|
||||
local builtin = require("scl.builtin_instructions")
|
||||
local builtin = require("tia.builtin_instructions")
|
||||
local clean_type = var_type:gsub('"', ''):gsub("#", ""):upper()
|
||||
local params_info = builtin.get_parameters(clean_type)
|
||||
if params_info then
|
||||
@@ -14,32 +20,41 @@ local function get_builtin_interface(var_type)
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Get interface for custom FB types from workspace
|
||||
--- @param var_type string The FB type name
|
||||
--- @return table|nil Interface with inputs, outputs, inouts
|
||||
local function get_fb_interface(var_type)
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local clean_type = var_type:gsub('"', '')
|
||||
|
||||
if fb_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
|
||||
return fb_parser.get_fb_interface(clean_type)
|
||||
end
|
||||
|
||||
--- Check if a type is a known FB type
|
||||
--- @param var_type string The type name to check
|
||||
--- @return boolean True if type is an FB
|
||||
local function is_fb_type(var_type)
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local clean_type = var_type:gsub('"', '')
|
||||
|
||||
if fb_parser.get_cache_count() == 0 then
|
||||
local ws = require("scl.workspace_types")
|
||||
local ws = require("tia.workspace_types")
|
||||
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
|
||||
return fb_parser.is_fb_type(clean_type)
|
||||
end
|
||||
|
||||
--- Get list of known local variable names
|
||||
--- @param bufnr number Buffer number
|
||||
--- @return table Set of variable names
|
||||
local function get_known_variables(bufnr)
|
||||
local scl_vars = require("scl.variables")
|
||||
local scl_vars = require("tia.variables")
|
||||
local variables = scl_vars.extract_variables(bufnr)
|
||||
local known = {}
|
||||
for _, v in ipairs(variables) do
|
||||
@@ -48,11 +63,17 @@ local function get_known_variables(bufnr)
|
||||
return known
|
||||
end
|
||||
|
||||
--- Get the type of a variable by name
|
||||
--- @param var_name string Variable name
|
||||
--- @param bufnr number Buffer number
|
||||
--- @return string|nil Variable type, or nil if not found
|
||||
local function get_variable_type(var_name, bufnr)
|
||||
local scl_vars = require("scl.variables")
|
||||
local scl_vars = require("tia.variables")
|
||||
return scl_vars.get_variable_type(var_name, bufnr)
|
||||
end
|
||||
|
||||
--- Fill multiline parameters for FB call at cursor
|
||||
--- Expands single-line call to multi-line with all parameters
|
||||
function M.fill_multiline_params()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
@@ -63,7 +84,7 @@ function M.fill_multiline_params()
|
||||
local line_before_cursor = line:sub(1, col)
|
||||
|
||||
local known_vars = get_known_variables(bufnr)
|
||||
local builtin = require("scl.builtin_instructions")
|
||||
local builtin = require("tia.builtin_instructions")
|
||||
|
||||
local fb_name = nil
|
||||
local fb_prefix = ""
|
||||
@@ -169,6 +190,11 @@ function M.fill_multiline_params()
|
||||
vim.api.nvim_win_set_cursor(0, { new_cursor_row, new_cursor_col })
|
||||
end
|
||||
|
||||
--- Check if cursor is inside an FB call parameter list
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param cursor_row number Current row (1-based)
|
||||
--- @param cursor_col number Current column (1-based)
|
||||
--- @return boolean True if inside FB call params
|
||||
local function is_inside_fb_call(bufnr, cursor_row, cursor_col)
|
||||
local function get_line_text(rownum)
|
||||
return vim.api.nvim_buf_get_lines(bufnr, rownum - 1, rownum, false)[1] or ""
|
||||
@@ -214,6 +240,9 @@ local function is_inside_fb_call(bufnr, cursor_row, cursor_col)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Jump to next parameter in FB call
|
||||
--- Used for Tab navigation within parameter lists
|
||||
--- @return boolean True if jumped to next param, false otherwise
|
||||
function M.jump_to_next_param()
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
local row = cursor[1]
|
||||
@@ -140,10 +140,10 @@ local function parse_attributes(attr_str)
|
||||
|
||||
local attrs = {}
|
||||
-- Match key := 'value' or key := value patterns
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do
|
||||
attrs[key] = value
|
||||
end
|
||||
for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do
|
||||
for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do
|
||||
attrs[key] = value
|
||||
end
|
||||
return attrs
|
||||
@@ -361,7 +361,7 @@ end
|
||||
|
||||
-- Parse all UDT files in project and libraries
|
||||
function M.scan_project_udts(bufnr)
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
local files = M.get_project_udt_files(bufnr)
|
||||
local lib_files = M.get_library_udt_files()
|
||||
|
||||
@@ -391,7 +391,7 @@ end
|
||||
|
||||
-- Parse all FB/Function files in project and libraries
|
||||
function M.scan_project_fbs(bufnr)
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local files = M.get_project_fb_files(bufnr)
|
||||
local lib_files = M.get_library_fb_files()
|
||||
|
||||
@@ -421,7 +421,7 @@ end
|
||||
|
||||
-- Parse all Global DB files in project and libraries
|
||||
function M.scan_project_dbs(bufnr)
|
||||
local db_parser = require("scl.db_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
local files = M.get_project_db_files(bufnr)
|
||||
local lib_files = M.get_library_db_files()
|
||||
|
||||
@@ -486,9 +486,9 @@ function M.setup(opts)
|
||||
pattern = "*.scl",
|
||||
callback = function(args)
|
||||
-- Clear cache and rescan
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local db_parser = require("scl.db_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
udt_parser.clear_cache()
|
||||
fb_parser.clear_cache()
|
||||
db_parser.clear_cache()
|
||||
@@ -506,7 +506,7 @@ function M.setup(opts)
|
||||
pattern = "*.udt",
|
||||
callback = function(args)
|
||||
-- Re-parse the changed UDT file
|
||||
local udt_parser = require("scl.udt_parser")
|
||||
local udt_parser = require("tia.udt_parser")
|
||||
udt_parser.parse_udt_file(args.file)
|
||||
end,
|
||||
})
|
||||
@@ -517,7 +517,7 @@ function M.setup(opts)
|
||||
pattern = "*.scl",
|
||||
callback = function(args)
|
||||
-- Re-parse the changed FB file
|
||||
local fb_parser = require("scl.fb_parser")
|
||||
local fb_parser = require("tia.fb_parser")
|
||||
fb_parser.parse_fb_file(args.file)
|
||||
end,
|
||||
})
|
||||
@@ -527,7 +527,7 @@ function M.setup(opts)
|
||||
group = augroup,
|
||||
pattern = "*.db",
|
||||
callback = function(args)
|
||||
local db_parser = require("scl.db_parser")
|
||||
local db_parser = require("tia.db_parser")
|
||||
db_parser.parse_db_file(args.file)
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,438 @@
|
||||
local M = {}
|
||||
|
||||
local function setup_package_path()
|
||||
local plugin_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
|
||||
plugin_path = plugin_path:gsub("/lua/tia_lsp", "")
|
||||
local lua_path = plugin_path .. "/lua"
|
||||
if not package.path:match(lua_path) then
|
||||
package.path = lua_path .. "/?.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup(opts)
|
||||
setup_package_path()
|
||||
opts = opts or {}
|
||||
|
||||
local server_path = opts.server_path or vim.fn.expand("~/Documents/lua/tia-lsp/src/main.lua")
|
||||
|
||||
local root_patterns = { ".git", "data_types", "plc.data.json" }
|
||||
|
||||
local function start_lsp(bufnr)
|
||||
local fname = vim.api.nvim_buf_get_name(bufnr)
|
||||
if fname == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local root_dir = vim.fs.root(fname, root_patterns) or vim.fn.fnamemodify(fname, ":p:h")
|
||||
|
||||
-- Prefer the mason-installed executable (vim.fn.exepath resolves on each
|
||||
-- buffer enter, so a :MasonInstall mid-session is picked up without restart).
|
||||
-- Fall back to running the server via `lua <server_path>` for manual installs.
|
||||
local tia_bin = vim.fn.exepath("tia-lsp")
|
||||
local cmd = tia_bin ~= "" and { "tia-lsp" } or { "lua", server_path }
|
||||
|
||||
vim.lsp.start({
|
||||
name = "tia_lsp",
|
||||
cmd = cmd,
|
||||
root_dir = root_dir,
|
||||
on_attach = function(client, b)
|
||||
if opts.lsp and opts.lsp.on_attach then
|
||||
opts.lsp.on_attach(client, b)
|
||||
end
|
||||
|
||||
if opts.lsp and opts.lsp.formatting then
|
||||
vim.api.nvim_buf_create_user_command(b, "SCLFormat", function()
|
||||
vim.lsp.buf.format({ name = "tia_lsp" })
|
||||
end, {})
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Create FileType autocmd for future buffers
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
pattern = "scl",
|
||||
callback = function(args)
|
||||
start_lsp(args.buf)
|
||||
M.notify_parser_install_if_needed(args.buf)
|
||||
-- Start tree-sitter highlighting if the parser is registered
|
||||
pcall(vim.treesitter.start, args.buf, "scl")
|
||||
end,
|
||||
})
|
||||
|
||||
-- Create global format command (works on current buffer)
|
||||
vim.api.nvim_create_user_command("SCLFormat", function()
|
||||
vim.lsp.buf.format({ name = "tia_lsp" })
|
||||
end, {})
|
||||
|
||||
-- Start LSP for any existing scl buffers (handles lazy loading case)
|
||||
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.bo[bufnr].filetype == "scl" and vim.api.nvim_buf_is_loaded(bufnr) then
|
||||
start_lsp(bufnr)
|
||||
M.notify_parser_install_if_needed(bufnr)
|
||||
pcall(vim.treesitter.start, bufnr, "scl")
|
||||
end
|
||||
end
|
||||
|
||||
M.setup_syntax(opts)
|
||||
M.setup_multiline_params()
|
||||
M.setup_attr_toggle()
|
||||
M.create_commands()
|
||||
|
||||
if opts.auto_prefix ~= false then
|
||||
M.setup_auto_prefix()
|
||||
end
|
||||
end
|
||||
|
||||
-- Find a compiled tree-sitter parser (.so) for SCL.
|
||||
-- Looks in: opts.ts_parser_path, mason package dir, ~/dev/tia-lsp.
|
||||
-- Returns the path to parser.so, or nil if not found.
|
||||
function M.find_parser_so(opts)
|
||||
-- 1. Explicit path from opts
|
||||
if opts.ts_parser_path and vim.fn.filereadable(opts.ts_parser_path) == 1 then
|
||||
return opts.ts_parser_path
|
||||
end
|
||||
-- 2. Mason install: resolve exepath("tia-lsp") -> real bin -> package dir
|
||||
local tia_bin = vim.fn.exepath("tia-lsp")
|
||||
if tia_bin ~= "" then
|
||||
local real_bin = vim.fn.resolve(vim.fn.fnamemodify(tia_bin, ":p"))
|
||||
local pkg_dir = vim.fn.fnamemodify(real_bin, ":h:h")
|
||||
local so = pkg_dir .. "/parser.so"
|
||||
if vim.fn.filereadable(so) == 1 then
|
||||
return so
|
||||
end
|
||||
end
|
||||
-- 3. Local dev path
|
||||
local dev_so = vim.fn.expand("~/dev/tia-lsp/parser.so")
|
||||
if vim.fn.filereadable(dev_so) == 1 then
|
||||
return dev_so
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Notify the user once per session if the tree-sitter parser isn't available.
|
||||
local parser_notified = false
|
||||
|
||||
function M.notify_parser_install_if_needed(bufnr)
|
||||
if parser_notified then
|
||||
return
|
||||
end
|
||||
if vim.bo[bufnr].filetype ~= "scl" then
|
||||
return
|
||||
end
|
||||
local ok = pcall(vim.treesitter.get_parser, bufnr, "scl")
|
||||
if ok then
|
||||
return
|
||||
end
|
||||
parser_notified = true
|
||||
vim.schedule(function()
|
||||
local msg = "SCL: Tree-sitter parser not found. "
|
||||
if vim.fn.exepath("tia-lsp") ~= "" then
|
||||
msg = msg .. "Run :MasonInstall tia-lsp to install it."
|
||||
else
|
||||
msg = msg .. "Install tia-lsp via mason or compile parser.so manually."
|
||||
end
|
||||
vim.notify(msg, vim.log.levels.INFO)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.setup_syntax(opts)
|
||||
-- Load the compiled tree-sitter parser (.so) via Neovim's built-in API.
|
||||
-- This bypasses nvim-treesitter's install system, which reloads its
|
||||
-- parsers module from source on :TSInstall and wipes runtime config.
|
||||
local parser_so = M.find_parser_so(opts)
|
||||
if parser_so then
|
||||
local ok, err = pcall(vim.treesitter.language.add, "scl", { path = parser_so })
|
||||
if not ok then
|
||||
vim.notify(
|
||||
"SCL: Failed to load tree-sitter parser from " .. parser_so .. ": " .. tostring(err),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Also register with nvim-treesitter (best-effort, for :TSInstall support
|
||||
-- in sessions where the user wants to install from source).
|
||||
local has_ts, parsers = pcall(require, "nvim-treesitter.parsers")
|
||||
if has_ts and type(parsers) == "table" then
|
||||
local parser_config
|
||||
if type(parsers.get_parser_configs) == "function" then
|
||||
parser_config = parsers.get_parser_configs()
|
||||
else
|
||||
parser_config = parsers
|
||||
end
|
||||
if type(parser_config) == "table" then
|
||||
parser_config.scl = {
|
||||
install_info = {
|
||||
url = opts.ts_parser_url or "https://gitea.l-tech.rs/lazar/tia-lsp.git",
|
||||
files = { "src/parser.c" },
|
||||
generate_requires_npm = false,
|
||||
requires_generate_from_grammar = false,
|
||||
},
|
||||
filetype = "scl",
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if opts.cmp then
|
||||
local function try_register()
|
||||
local cmp_ok, cmp = pcall(require, "blink.cmp")
|
||||
local blink_source_ok = pcall(require, "tia.blink_cmp_source")
|
||||
if cmp_ok and cmp and blink_source_ok then
|
||||
if cmp.add_source_provider then
|
||||
local blink_source = require("tia.blink_cmp_source")
|
||||
local source_config = {
|
||||
name = "scl",
|
||||
provider = blink_source,
|
||||
score_offset = 100,
|
||||
}
|
||||
pcall(cmp.add_source_provider, "scl", source_config)
|
||||
pcall(cmp.add_filetype_source, "scl", "scl")
|
||||
end
|
||||
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
|
||||
|
||||
if opts.workspace_types ~= false then
|
||||
local ok, workspace_types = pcall(require, "tia.workspace_types")
|
||||
if ok then
|
||||
workspace_types.setup({
|
||||
project_root = opts.project_root,
|
||||
library_paths = opts.library_paths,
|
||||
debug = opts.debug,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_auto_prefix()
|
||||
local ok, auto_prefix = pcall(require, "tia.auto_prefix")
|
||||
if ok then
|
||||
auto_prefix.setup()
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_multiline_params()
|
||||
local ok, mp = pcall(require, "tia.multiline_params")
|
||||
if not ok then
|
||||
vim.notify("SCL multiline_params module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
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.prefix_current_word()
|
||||
local ok, auto_prefix = pcall(require, "tia.auto_prefix")
|
||||
if ok then
|
||||
auto_prefix.prefix_word()
|
||||
end
|
||||
end
|
||||
|
||||
function M.show_variables()
|
||||
local ok, variables = pcall(require, "tia.variables")
|
||||
if not ok then
|
||||
vim.notify("SCL variables module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local vars = 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 ok_ws, ws = pcall(require, "tia.workspace_types")
|
||||
local ok_udt, udt = pcall(require, "tia.udt_parser")
|
||||
local ok_fb, fb = pcall(require, "tia.fb_parser")
|
||||
|
||||
if not ok_ws or not ok_udt or not ok_fb then
|
||||
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local root = ws.get_project_root()
|
||||
local udt_names = udt.get_all_udt_names()
|
||||
local fb_names = fb.get_all_fb_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, vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
function M.rescan_workspace_types()
|
||||
local ok_ws, ws = pcall(require, "tia.workspace_types")
|
||||
local ok_udt, udt = pcall(require, "tia.udt_parser")
|
||||
local ok_fb, fb = pcall(require, "tia.fb_parser")
|
||||
|
||||
if not ok_ws or not ok_udt or not ok_fb then
|
||||
vim.notify("SCL workspace modules not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
udt.clear_cache()
|
||||
fb.clear_cache()
|
||||
ws.clear_root_cache()
|
||||
|
||||
local udt_result = ws.scan_project_udts()
|
||||
local fb_result = ws.scan_project_fbs()
|
||||
|
||||
vim.notify(
|
||||
"Scanned UDTs: "
|
||||
.. udt_result.parsed_count
|
||||
.. "/"
|
||||
.. udt_result.total_files
|
||||
.. ", FBs: "
|
||||
.. fb_result.parsed_count
|
||||
.. "/"
|
||||
.. fb_result.total_files,
|
||||
vim.log.levels.INFO
|
||||
)
|
||||
|
||||
local clients = vim.lsp.get_clients({ name = "tia_lsp" })
|
||||
for _, client in ipairs(clients) do
|
||||
client.request("workspace/executeCommand", {
|
||||
command = "SCLRescanWorkspaceTypes",
|
||||
}, function(err, _)
|
||||
if err then
|
||||
vim.notify("SCL RescanWorkspaceTypes LSP error: " .. err.message, vim.log.levels.ERROR)
|
||||
end
|
||||
end, 0)
|
||||
end
|
||||
|
||||
if vim.lsp.diagnostics then
|
||||
vim.lsp.diagnostics.refresh()
|
||||
end
|
||||
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, {})
|
||||
vim.api.nvim_create_user_command("SCLPrefixWord", function()
|
||||
M.prefix_current_word()
|
||||
end, {})
|
||||
|
||||
-- Attribute toggle commands
|
||||
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function()
|
||||
local ok, at = pcall(require, "tia.attr_toggle")
|
||||
if ok then
|
||||
at.toggle_attr_block()
|
||||
else
|
||||
vim.notify("SCL: Failed to load attr_toggle module: " .. tostring(at), vim.log.levels.ERROR)
|
||||
end
|
||||
end, {})
|
||||
|
||||
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function()
|
||||
local ok, at = pcall(require, "tia.attr_toggle")
|
||||
if ok then
|
||||
at.expand_all_attr_blocks()
|
||||
else
|
||||
vim.notify("SCL: Failed to load attr_toggle module: " .. tostring(at), vim.log.levels.ERROR)
|
||||
end
|
||||
end, {})
|
||||
|
||||
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function()
|
||||
local ok, at = pcall(require, "tia.attr_toggle")
|
||||
if ok then
|
||||
at.collapse_all_attr_blocks()
|
||||
else
|
||||
vim.notify("SCL: Failed to load attr_toggle module: " .. tostring(at), vim.log.levels.ERROR)
|
||||
end
|
||||
end, {})
|
||||
end
|
||||
|
||||
function M.setup_attr_toggle()
|
||||
local ok, at = pcall(require, "tia.attr_toggle")
|
||||
if not ok then
|
||||
vim.notify("SCL: attr_toggle module not found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
return {
|
||||
"lazar/scl_lsp",
|
||||
"lazar/tia-lsp.nvim",
|
||||
ft = "scl",
|
||||
lazy = true,
|
||||
dependencies = {
|
||||
@@ -8,8 +8,9 @@ return {
|
||||
"saghen/blink.cmp",
|
||||
},
|
||||
config = function(_, opts)
|
||||
require("scl_lsp").setup({
|
||||
require("tia_lsp").setup({
|
||||
server_path = opts.server_path,
|
||||
ts_parser_url = opts.ts_parser_url,
|
||||
lsp = opts.lsp,
|
||||
cmp = opts.cmp ~= false,
|
||||
workspace_types = opts.workspace_types ~= false,
|
||||
@@ -1,41 +0,0 @@
|
||||
; Indentation rules for SCL
|
||||
|
||||
; Indent after block keywords
|
||||
[
|
||||
(organization_block)
|
||||
(function_block)
|
||||
(var_declaration)
|
||||
(if_statement)
|
||||
(case_statement)
|
||||
(for_statement)
|
||||
(while_statement)
|
||||
(repeat_statement)
|
||||
(region_statement)
|
||||
] @indent.begin
|
||||
|
||||
; Indent after THEN, DO, etc.
|
||||
[
|
||||
"THEN"
|
||||
"DO"
|
||||
"ELSE"
|
||||
"ELSIF"
|
||||
] @indent.branch
|
||||
|
||||
; Outdent at end keywords
|
||||
[
|
||||
"END_VAR"
|
||||
"END_IF"
|
||||
"END_CASE"
|
||||
"END_FOR"
|
||||
"END_WHILE"
|
||||
"END_REPEAT"
|
||||
"END_REGION"
|
||||
"END_ORGANIZATION_BLOCK"
|
||||
"END_FUNCTION_BLOCK"
|
||||
] @indent.end
|
||||
|
||||
; Outdent for ELSE and ELSIF
|
||||
[
|
||||
"ELSE"
|
||||
"ELSIF"
|
||||
] @indent.dedent
|
||||
@@ -1,15 +0,0 @@
|
||||
; Local variable scoping for SCL - matches grammar.js structure
|
||||
|
||||
; Variable declarations define local variables
|
||||
(var_item
|
||||
name: (identifier) @definition.var)
|
||||
|
||||
; Variable sections create scopes
|
||||
(var_declaration) @local.scope
|
||||
|
||||
; Blocks create scopes
|
||||
(organization_block) @local.scope
|
||||
(function_block) @local.scope
|
||||
|
||||
; Identifiers are references
|
||||
(identifier) @reference
|
||||
@@ -3,12 +3,21 @@
|
||||
[
|
||||
(organization_block)
|
||||
(function_block)
|
||||
(function)
|
||||
(type_definition)
|
||||
(data_block)
|
||||
(var_declaration)
|
||||
(var_temp_declaration)
|
||||
(var_constant_declaration)
|
||||
(var_retain_declaration)
|
||||
(var_non_retain_declaration)
|
||||
(var_db_specific_declaration)
|
||||
(if_statement)
|
||||
(case_statement)
|
||||
(for_statement)
|
||||
(while_statement)
|
||||
(repeat_statement)
|
||||
(region_statement)
|
||||
(struct_type)
|
||||
(block_comment)
|
||||
] @fold
|
||||
@@ -1,66 +1,99 @@
|
||||
; SCL highlights
|
||||
|
||||
; Comments
|
||||
(line_comment) @comment
|
||||
(block_comment) @comment
|
||||
(c_style_comment) @comment
|
||||
|
||||
; Literals
|
||||
(number) @number
|
||||
(hex_number) @number
|
||||
(string) @string
|
||||
(binary_number) @number
|
||||
(octal_number) @number
|
||||
(time_value) @number
|
||||
(date_literal) @number
|
||||
(bool_literal) @constant.builtin
|
||||
(typed_int_literal) @number
|
||||
(wstring_literal) @string
|
||||
(char_literal) @character
|
||||
(string) @string
|
||||
|
||||
; Prefix
|
||||
(prefix) @punctuation.special
|
||||
|
||||
; Variables and identifiers
|
||||
(identifier) @variable
|
||||
|
||||
; FB instance calls - highlight instance name as type
|
||||
; FB instance calls
|
||||
(fb_call
|
||||
instance: (prefixed_identifier
|
||||
(identifier) @type))
|
||||
(fb_call
|
||||
instance: (identifier) @type)
|
||||
|
||||
; FB parameter names (IN :=, PT :=, etc.)
|
||||
; FB parameter names
|
||||
(fb_parameter
|
||||
name: (identifier) @property)
|
||||
|
||||
; Function call names
|
||||
(function_call
|
||||
(identifier) @function)
|
||||
(function_call
|
||||
(string) @function)
|
||||
|
||||
; Method call names
|
||||
(method_callee
|
||||
(identifier) @function)
|
||||
|
||||
; Variable declarations
|
||||
(var_item
|
||||
name: (identifier) @variable)
|
||||
|
||||
; Type references in declarations
|
||||
; Type references
|
||||
(type
|
||||
(identifier) @type)
|
||||
|
||||
(type_builtin) @type.builtin
|
||||
|
||||
; Keywords and operators
|
||||
(keyword) @keyword
|
||||
(constant) @constant.builtin
|
||||
(operator) @operator
|
||||
|
||||
(assignment) @operator
|
||||
(binary_expression) @operator
|
||||
(unary_expression) @operator
|
||||
; Block names
|
||||
(organization_block
|
||||
name: (string) @type)
|
||||
(function_block
|
||||
name: (string) @type)
|
||||
(function
|
||||
name: (string) @function)
|
||||
(data_block
|
||||
name: (string) @type)
|
||||
(type_definition
|
||||
name: (string) @type)
|
||||
|
||||
; Access
|
||||
(field_access
|
||||
"."
|
||||
(identifier) @property)
|
||||
|
||||
(array_access) @property
|
||||
(bit_access) @property
|
||||
|
||||
; Attributes
|
||||
(attribute_list) @attribute
|
||||
(attribute) @attribute
|
||||
(attribute_value) @string
|
||||
|
||||
(var_item) @property
|
||||
; Statements
|
||||
(assignment) @operator
|
||||
(binary_expression) @operator
|
||||
(unary_expression) @operator
|
||||
(chained_assignment) @operator
|
||||
|
||||
; Function/FB calls
|
||||
(function_call) @function.call
|
||||
(fb_call) @function.call
|
||||
|
||||
; Control flow
|
||||
(if_statement) @conditional
|
||||
(elsif_clause) @conditional
|
||||
(else_clause) @conditional
|
||||
@@ -71,6 +104,13 @@
|
||||
(while_statement) @repeat
|
||||
(repeat_statement) @repeat
|
||||
|
||||
(return_statement) @keyword
|
||||
(goto_statement) @keyword
|
||||
|
||||
; Title
|
||||
(title_statement) @comment
|
||||
|
||||
; Punctuation
|
||||
";" @punctuation.delimiter
|
||||
":" @punctuation.delimiter
|
||||
"," @punctuation.delimiter
|
||||
@@ -80,15 +120,22 @@
|
||||
"]" @punctuation.bracket
|
||||
"." @punctuation.delimiter
|
||||
|
||||
; Operators
|
||||
":=" @operator
|
||||
"=>" @operator
|
||||
"+=" @operator
|
||||
"-=" @operator
|
||||
"*=" @operator
|
||||
"/=" @operator
|
||||
"&=" @operator
|
||||
"|=" @operator
|
||||
"^=" @operator
|
||||
"=" @operator
|
||||
"<>" @operator
|
||||
"<" @operator
|
||||
">" @operator
|
||||
"<=" @operator
|
||||
">=" @operator
|
||||
|
||||
"+" @operator
|
||||
"-" @operator
|
||||
"*" @operator
|
||||
@@ -0,0 +1,65 @@
|
||||
; Indentation rules for SCL
|
||||
|
||||
; Indent after block definitions
|
||||
[
|
||||
(organization_block)
|
||||
(function_block)
|
||||
(function)
|
||||
(type_definition)
|
||||
(data_block)
|
||||
] @indent.begin
|
||||
|
||||
; Indent after VAR sections
|
||||
[
|
||||
(var_declaration)
|
||||
(var_temp_declaration)
|
||||
(var_constant_declaration)
|
||||
(var_retain_declaration)
|
||||
(var_non_retain_declaration)
|
||||
(var_db_specific_declaration)
|
||||
] @indent.begin
|
||||
|
||||
; Indent after control structures
|
||||
[
|
||||
(if_statement)
|
||||
(case_statement)
|
||||
(for_statement)
|
||||
(while_statement)
|
||||
(repeat_statement)
|
||||
(region_statement)
|
||||
] @indent.begin
|
||||
|
||||
; Indent after STRUCT in UDTs and inline structs
|
||||
(struct_type) @indent.begin
|
||||
|
||||
; Indent after THEN, DO, ELSE, ELSIF, BEGIN
|
||||
[
|
||||
"THEN"
|
||||
"DO"
|
||||
"ELSE"
|
||||
"ELSIF"
|
||||
(keyword) @indent.branch
|
||||
] @indent.branch
|
||||
|
||||
; Outdent at end keywords
|
||||
[
|
||||
"END_VAR"
|
||||
"END_IF"
|
||||
"END_CASE"
|
||||
"END_FOR"
|
||||
"END_WHILE"
|
||||
"END_REPEAT"
|
||||
"END_REGION"
|
||||
"END_STRUCT"
|
||||
"END_ORGANIZATION_BLOCK"
|
||||
"END_FUNCTION_BLOCK"
|
||||
"END_FUNCTION"
|
||||
"END_TYPE"
|
||||
"END_DATA_BLOCK"
|
||||
] @indent.end
|
||||
|
||||
; Outdent for ELSE and ELSIF
|
||||
[
|
||||
"ELSE"
|
||||
"ELSIF"
|
||||
] @indent.dedent
|
||||
@@ -0,0 +1,39 @@
|
||||
; Local variable scoping for SCL
|
||||
|
||||
; Variable declarations define local variables
|
||||
(var_item
|
||||
name: (identifier) @definition.var)
|
||||
|
||||
; Blocks create scopes
|
||||
(organization_block) @local.scope
|
||||
(function_block) @local.scope
|
||||
(function) @local.scope
|
||||
(type_definition) @local.scope
|
||||
(data_block) @local.scope
|
||||
|
||||
; VAR sections create scopes
|
||||
(var_declaration) @local.scope
|
||||
(var_temp_declaration) @local.scope
|
||||
(var_constant_declaration) @local.scope
|
||||
(var_retain_declaration) @local.scope
|
||||
(var_non_retain_declaration) @local.scope
|
||||
(var_db_specific_declaration) @local.scope
|
||||
|
||||
; Struct types create scopes
|
||||
(struct_type) @local.scope
|
||||
|
||||
; Block names are definitions
|
||||
(organization_block
|
||||
name: (string) @definition.function)
|
||||
(function_block
|
||||
name: (string) @definition.function)
|
||||
(function
|
||||
name: (string) @definition.function)
|
||||
(data_block
|
||||
name: (string) @definition.class)
|
||||
(type_definition
|
||||
name: (string) @definition.type)
|
||||
|
||||
; References
|
||||
(prefixed_identifier
|
||||
(identifier) @reference)
|
||||
@@ -1,4 +1,4 @@
|
||||
; Tag queries for SCL - matches grammar.js structure
|
||||
; Tag queries for SCL - symbol navigation
|
||||
|
||||
; Organization blocks
|
||||
(organization_block
|
||||
@@ -8,6 +8,18 @@
|
||||
(function_block
|
||||
name: (string) @name) @definition.function
|
||||
|
||||
; Functions
|
||||
(function
|
||||
name: (string) @name) @definition.function
|
||||
|
||||
; Data blocks
|
||||
(data_block
|
||||
name: (string) @name) @definition.class
|
||||
|
||||
; Type definitions (UDTs)
|
||||
(type_definition
|
||||
name: (string) @name) @definition.type
|
||||
|
||||
; Variable definitions within blocks
|
||||
(var_item
|
||||
name: (identifier) @name
|
||||
@@ -25,3 +37,14 @@
|
||||
|
||||
(function_call
|
||||
(string) @name) @reference.call
|
||||
|
||||
; FB calls (references)
|
||||
(fb_call
|
||||
instance: (identifier) @name) @reference.call
|
||||
|
||||
(fb_call
|
||||
instance: (prefixed_identifier
|
||||
(identifier) @name)) @reference.call
|
||||
|
||||
; Region statements
|
||||
(region_statement) @definition.module
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd /home/lazar/dev/scl_lsp
|
||||
lua src/main.lua
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user