Compare commits

...
19 Commits
Author SHA1 Message Date
lazar f219a8662f fix: guard vim.lsp.diagnostics.refresh() against nil
Neovim 0.12+ restructured LSP internals; vim.lsp.diagnostics no
longer exists as a submodule. Guard the call so :SCLRescanWorkspaceTypes
doesn't crash on startup.
2026-07-24 12:35:36 +02:00
lazar a9c1b8db16 docs: update reference project path in AGENTS.md 2026-07-24 11:57:50 +02:00
lazar a4400106db refactor: collapse all {} blocks in declaration section, drop pattern filtering 2026-07-24 09:54:22 +02:00
lazar b86ce08426 fix: bridge :SCLRescanWorkspaceTypes to LSP server + refresh diagnostics 2026-07-23 13:55:04 +02:00
lazar 0a0b5c9bf1 fix: DB completion path parsing, array-of-UDT resolution, blink source registration
- Fix path extraction pattern (missing closing quote, extra capture group)
- Add Array[..] of "UdtName" detection for nested member resolution
- Remove vim.schedule_wrap from DB member callback (causes blink.cmp timeout)
- Add built-in instruction parameter fallback (IEC_TIMER IN/PT/Q/ET)
- Ensure DB cache is populated on demand in completion source
- Make try_register() succeed without cmp.add_source_provider API
- docs: add blink.cmp source config steps to installation guide
2026-07-22 20:26:16 +02:00
lazar 3776ce33ff refactor: change the default path for local dev env 2026-07-20 17:01:37 +02:00
lazar 1acac2b73c docs: rewrite installation with architecture overview, troubleshooting, and cross-references 2026-07-19 15:09:58 +02:00
lazar 03548dab7d feat: comprehensive query files for all block types and VAR sections (Phase 3)
Update all 5 tree-sitter query files to cover the full grammar:

highlights.scm:
- Add highlights for new literals (binary, octal, date, bool, typed_int,
  wstring, char)
- Add highlights for block names (organization_block, function_block,
  function, data_block, type_definition)
- Add highlights for return_statement, goto_statement, title_statement
- Add highlights for bit_access, method_callee
- Add compound assignment operators (+=, -=, *=, /=, etc.)

indents.scm:
- Add function, type_definition, data_block to @indent.begin
- Add all var_*_declaration variants to @indent.begin
- Add struct_type to @indent.begin
- Add END_FUNCTION, END_TYPE, END_DATA_BLOCK, END_STRUCT to @indent.end

folds.scm:
- Add function, type_definition, data_block to @fold
- Add all var_*_declaration variants to @fold
- Add struct_type to @fold

locals.scm:
- Add function, type_definition, data_block as @local.scope
- Add all var_*_declaration variants as @local.scope
- Add struct_type as @local.scope
- Add block name definitions (@definition.function, @definition.class,
  @definition.type)
- Narrow @reference to prefixed_identifier only (reduces noise)

tags.scm:
- Add function, data_block, type_definition tags
- Add FB call references
- Add region_statement as @definition.module
2026-07-18 19:20:33 +02:00
lazar aebffc34e7 fix: load compiled parser.so via vim.treesitter.language.add
nvim-treesitter's :TSInstall reloads the parsers module from source
(package.loaded['nvim-treesitter.parsers'] = nil), which wipes runtime
config modifications. The old get_parser_configs() approach no longer
works for registering custom parsers with :TSInstall.

New approach:
- The mason build script now compiles parser.c into parser.so during
  :MasonInstall tia-lsp (requires cc/gcc)
- The plugin's setup_syntax finds parser.so via:
  1. opts.ts_parser_path (explicit override)
  2. Mason package dir (resolved from exepath('tia-lsp'))
  3. ~/dev/tia-lsp/parser.so (local dev fallback)
- Loads it with vim.treesitter.language.add('scl', { path = parser.so })
- Starts highlighting via vim.treesitter.start(bufnr, 'scl') in the
  FileType autocmd
- Falls back to notifying the user if no parser.so is found
- Still registers with nvim-treesitter's parsers table as best-effort
  for :TSInstall support (won't survive reload, but works in-session)
2026-07-18 18:01:32 +02:00
lazar bd50f7b459 fix: update tree-sitter parser URL to Gitea
Change the default ts_parser_url from the local file:// path to the
Gitea-hosted tia-lsp repository, so :TSInstall scl works out of the
box after the repo split.
2026-07-18 11:49:16 +02:00
lazar 8255db7f64 chore: adjust for plugin-only scope after split
After splitting from the original scl_lsp repo, update for plugin-only scope:

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

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

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

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

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

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
2026-07-18 11:30:55 +02:00
lazar 137eda3e77 fix: Attribute block expand/collapse, TIME literal diagnostics, and formatter bugs
- SCLFormat: Remove clear_buffer_state() call that was preventing
       expand after formatting (attr_toggle state now preserved)
     - Diagnostics: Fix false 'Undefined variable' for IEC literals
       like T#2s, D#1d, S5T#1s (TIME, DATE, S5TIME types)
     - Formatter: Fix block comments (* ... *) being misidentified as
       FB calls in VAR_IN_OUT/VAR_INPUT/VAR_OUTPUT sections
     - Formatter: Fix array-based FB calls like instTimers[idx].TON()
       not being properly formatted with aligned parameters
2026-02-24 15:27:58 +01:00
lazar 15b0e5360a docs: encourage adding comments; fix nested IF/ELSIF indentation
- Update AGENTS.md to encourage comments instead of forbidding them
- Add docstrings to all public functions in src/ modules
- Add module headers and function comments to lua/scl/ modules
- Fix formatter to properly indent multi-line IF/ELSIF conditions
- Fix FB call detection to not match IF statements with parentheses
2026-02-23 17:40:44 +01:00
lazar bfdec0c4b1 test: add formatter tests
- Add comprehensive formatter test suite with 38 tests
- Test multiline assignments, FB calls, control structures
- Test IF, FOR, WHILE, CASE, REPEAT statements
- Test regions, structs, var sections
- Update AGENTS.md with formatter test command
2026-02-23 14:16:46 +01:00
lazar 4471d9e1d9 Merge branch 'feat/testing_framework' into develop 2026-02-23 13:36:49 +01:00
lazar eb10b1c0df feat: add testing framework with reference project integration
- Add test/ directory with test harness and test files
- Add UDT, DB, FB parser tests
- Add PLC JSON and integration tests
- Test against reference project (239 UDTs, 12 DBs, 37 SCL, 13 XML)
- Fix attribute parsing to handle underscores (S7_SetPoint)
- Add ORGANIZATION_BLOCK support to FB parser
2026-02-23 13:29:38 +01:00
lazar 5ca5628d39 Merge branch 'feat/scl_formatter' into develop 2026-02-23 10:30:32 +01:00
lazar 3dfca6bba3 feat: improve formatter and attribute block handling
- Format multi-line assignments with proper alignment
- Format FB calls in multiline style with parameter alignment
- Auto-expand collapsed attribute blocks before save
- Rename command from LspSCLFormat to SCLFormat
- Condense AGENTS.md and add testing section with reference project
2026-02-23 10:29:05 +01:00
31 changed files with 2291 additions and 1277 deletions
+59 -304
View File
@@ -1,41 +1,31 @@
# AGENTS.md - SCL Language Server
# AGENTS.md - tia-lsp.nvim
A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion.
Neovim plugin for Siemens TIA Portal languages. Currently supports **SCL**; planned support for STL/AWL, LAD, FBD. This repo is the plugin only (`lua/` + tree-sitter queries). The companion LSP server lives in `tia-lsp`.
## Build/Lint/Test Commands
```bash
# LSP Server
make start # Start LSP server
make test # Run LSP in test mode
lua src/main.lua --test # Direct test execution
# Tree-sitter Parser
tree-sitter generate # Generate parser from grammar.js
tree-sitter test # Run parser tests
tree-sitter parse <file.scl> # Parse single SCL file
# Lua Linting
luacheck src/ # Lint LSP server code
luacheck lua/scl/ # Lint plugin modules
luacheck lua/tia/ # Lint language modules
luacheck lua/tia_lsp/ # Lint plugin entry point
# Unit & Integration Tests
lua test/run_tests.lua # Run all plugin tests
lua test/test_udt.lua # Run UDT parser tests
lua test/test_db.lua # Run DB parser tests
lua test/test_fb.lua # Run FB parser tests
lua test/test_integration.lua # Run integration tests
# Reference Project
# Tests use files from: ~/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
+190 -197
View File
@@ -1,49 +1,9 @@
# SCL Language Server - Unified Plugin for Siemens SCL
# tia-lsp.nvim - Neovim Plugin for Siemens TIA Portal
A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)**, **Linting**, **Formatting**, and **Syntax Highlighting** for Siemens SCL (Structured Control Language) used in Siemens TIA Portal.
A Neovim/LazyVim plugin that launches the [`tia-lsp`](https://gitea.l-tech.rs/lazar/tia-lsp) language server and adds editor-specific features for Siemens TIA Portal languages. Currently supports **SCL** (Structured Control Language), with planned support for **STL/AWL**, **LAD**, and **FBD**.
## Features
### LSP (Language Server Protocol)
| Feature | Description |
|---------|-------------|
| **Hover** | Press `K` to see variable type and struct fields |
| **Completion** | `#` for variables, `.` for struct fields, `(` for functions |
| **Go to Definition** | `gD` jump to function, FB, DB, or UDT definition |
| **Document Symbols** | Shows functions, variables, types |
| **Workspace Symbols** | Search across all `.scl` files with fuzzy matching |
| **Semantic Tokens** | Keywords, variables highlighted |
| **Inlay Hints** | Shows variable types after declaration |
| **External UDT** | Load types from `.plc.json` or `data_types/` folder |
### Linter (Diagnostics)
| Feature | Description |
|---------|-------------|
| **Undefined Variables** | Detects variables used without declaration |
| **Invalid # Prefix** | Detects `#` prefix in VAR sections |
| **Unknown Types** | Warns about unknown data types |
| **Type Validation** | Basic type checking |
### Formatter
| Feature | Description |
|---------|-------------|
| **Document Formatting** | Format entire SCL files |
| **Indentation** | Proper block indentation |
| **Keyword Casing** | Maintains SCL keyword casing |
### Syntax Highlighting
- Full SCL syntax support via tree-sitter
- Block definitions: ORGANIZATION_BLOCK, FUNCTION_BLOCK, FUNCTION
- Variable sections: VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_CONSTANT
- Control structures: IF/THEN/ELSIF/ELSE/END_IF, CASE/OF/END_CASE, FOR/TO/BY/DO/END_FOR, WHILE/END_WHILE, REPEAT/UNTIL/END_REPEAT
- Built-in data types: BOOL, INT, DINT, REAL, STRING, TIME, etc.
- Function block instances highlighted in calls
- FB parameter names (IN, PT, CU, etc.) highlighted as properties
- `#` prefix for local variables highlighted
- Operators: AND, OR, NOT, XOR, MOD
- Comments: line (`//`), block (`(* *)`), C-style (`/* */`)
### Additional Features
| Feature | Description |
|---------|-------------|
| **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) |
@@ -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`).
+3
View File
@@ -0,0 +1,3 @@
au BufRead,BufNewFile *.scl set filetype=scl
au BufRead,BufNewFile *.udt set filetype=scl
au BufRead,BufNewFile *.db set filetype=scl
-266
View File
@@ -1,266 +0,0 @@
-- SCL (Siemens Structured Control Language) support for Neovim
-- Main setup module
local M = {}
function M.setup(opts)
opts = opts or {}
-- Add plugin to runtime path
vim.opt.runtimepath:prepend(vim.fn.stdpath("data") .. "/lazy/scl_lsp")
M.create_commands()
-- Treesitter setup for Neovim 0.11+
local function setup_treesitter()
local lang = vim.treesitter.language
local parser_path = "/home/lazar/dev/scl_lsp/src/parser.so"
-- Register the SCL language
lang.add("scl", {
path = parser_path,
filetype = "scl",
})
end
pcall(setup_treesitter)
-- Install queries
local queries_src = "/home/lazar/dev/scl_lsp/queries/highlights.scm"
local queries_dest = vim.fn.stdpath("data") .. "/site/queries/scl/highlights.scm"
if vim.fn.filereadable(queries_src) == 1 then
vim.fn.mkdir(vim.fn.stdpath("data") .. "/site/queries/scl", "p")
vim.fn.writefile(vim.fn.readfile(queries_src), queries_dest)
end
-- Enable treesitter manually for SCL files - bypass LazyVim
vim.api.nvim_create_autocmd("FileType", {
pattern = "scl",
callback = function(args)
vim.treesitter.start(args.buf, "scl")
end,
})
-- Register blink.cmp source
if opts.cmp then
local function try_register()
local cmp_ok, cmp = pcall(require, "blink.cmp")
if cmp_ok and cmp and cmp.add_source_provider then
local source_config = {
name = "scl",
module = "scl.blink_cmp_source",
score_offset = 100,
}
pcall(cmp.add_source_provider, "scl", source_config)
pcall(cmp.add_filetype_source, "scl", "scl")
-- Create autocmd to trigger completion on ( for SCL files
local augroup = vim.api.nvim_create_augroup("SCLCompletionTrigger", { clear = true })
vim.api.nvim_create_autocmd("TextChangedI", {
group = augroup,
pattern = "*.scl",
callback = function(args)
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
-- Check if the character before cursor is (
if col > 0 and line:sub(col, col) == "(" then
-- Check if there's a known FB variable before the (
local before_paren = line:sub(1, col - 1):match("[%w_]+%s*$")
if before_paren then
vim.schedule(function()
pcall(require("blink.cmp").show, { providers = { "scl" } })
end)
end
end
end,
})
vim.notify("SCL: blink.cmp source registered", vim.log.levels.INFO)
return true
end
return false
end
if not try_register() then
vim.api.nvim_create_autocmd("BufEnter", {
pattern = "*.scl",
once = true,
callback = try_register,
})
end
end
-- Workspace type scanner
if opts.workspace_types ~= false then
local workspace_types = require("scl.workspace_types")
workspace_types.setup({
project_root = opts.project_root,
library_paths = opts.library_paths,
debug = opts.debug,
})
end
-- Auto-prefixing (enabled by default)
M.setup_auto_prefix()
-- Multiline parameters feature
M.setup_multiline_params()
-- Attribute block toggle feature
M.setup_attr_toggle()
end
function M.setup_multiline_params()
local mp = require("scl.multiline_params")
vim.api.nvim_create_user_command("SCLMultilineParams", function()
mp.fill_multiline_params()
end, {})
vim.keymap.set("i", "<Space>mp", function()
mp.fill_multiline_params()
end, { noremap = true, silent = true, desc = "SCL: Fill multiline parameters" })
vim.keymap.set("i", "<Tab>", function()
local ft = vim.bo.filetype
if ft == "scl" or ft == "udt" then
if mp.jump_to_next_param() then
return ""
end
end
return "<Tab>"
end, { noremap = true, silent = true, desc = "SCL: Jump to next param or Tab" })
end
function M.setup_auto_prefix()
require("scl.auto_prefix").setup()
end
function M.prefix_current_word()
require("scl.auto_prefix").prefix_word()
end
function M.show_variables()
local vars = require("scl.variables").extract_variables()
if #vars == 0 then
vim.notify("No local variables found", vim.log.levels.INFO)
return
end
local lines = { "Local Variables:", "---" }
for _, var in ipairs(vars) do
local type_info = var.type and (" : " .. var.type) or ""
table.insert(lines, "#" .. var.name .. type_info)
end
vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO)
end
function M.show_workspace_types()
local ws = require("scl.workspace_types")
local udt = require("scl.udt_parser")
local fb = require("scl.fb_parser")
local db = require("scl.db_parser")
local root = ws.get_project_root()
local udt_names = udt.get_all_udt_names()
local fb_names = fb.get_all_fb_names()
local db_names = db.get_all_db_names()
if not root then
vim.notify("No project root detected", vim.log.levels.WARN)
return
end
vim.notify("Project: " .. root .. "\nUDTs: " .. #udt_names .. "\nFBs/Functions: " .. #fb_names .. "\nGlobal DBs: " .. #db_names, vim.log.levels.INFO)
end
function M.rescan_workspace_types()
local ws = require("scl.workspace_types")
local udt = require("scl.udt_parser")
local fb = require("scl.fb_parser")
local db = require("scl.db_parser")
udt.clear_cache()
fb.clear_cache()
db.clear_cache()
ws.clear_root_cache()
local udt_result = ws.scan_project_udts()
local fb_result = ws.scan_project_fbs()
local db_result = ws.scan_project_dbs()
vim.notify("Scanned UDTs: " .. udt_result.parsed_count .. "/" .. udt_result.total_files ..
", FBs: " .. fb_result.parsed_count .. "/" .. fb_result.total_files ..
", DBs: " .. db_result.parsed_count .. "/" .. db_result.total_files, vim.log.levels.INFO)
end
function M.create_commands()
vim.api.nvim_create_user_command("SCLShowVariables", function() M.show_variables() end, {})
vim.api.nvim_create_user_command("SCLShowWorkspaceTypes", function() M.show_workspace_types() end, {})
vim.api.nvim_create_user_command("SCLRescanWorkspaceTypes", function() M.rescan_workspace_types() end, {})
-- Attribute toggle commands
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.toggle_attr_block()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.expand_all_attr_blocks()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function()
local ok, at = pcall(require, "scl.attr_toggle")
if ok then
at.collapse_all_attr_blocks()
else
vim.notify("SCL: Failed to load attr_toggle module", vim.log.levels.ERROR)
end
end, {})
end
function M.setup_attr_toggle()
local at = require("scl.attr_toggle")
at.setup()
local function setup_buffer_keymaps(bufnr)
local opts = { buffer = bufnr, silent = true }
local ft = vim.bo[bufnr].filetype
if ft ~= "scl" and ft ~= "udt" then
return
end
-- Toggle individual attribute block under cursor
vim.keymap.set({ "n", "i" }, "<Leader>xa", function()
at.toggle_attr_block()
end, vim.tbl_extend("force", opts, { desc = "SCL: Toggle attribute block" }))
-- Expand all collapsed blocks
vim.keymap.set("n", "<Leader>xae", function()
at.expand_all_attr_blocks()
end, vim.tbl_extend("force", opts, { desc = "SCL: Expand all attribute blocks" }))
-- Collapse all attribute blocks
vim.keymap.set("n", "<Leader>xac", function()
at.collapse_all_attr_blocks()
end, vim.tbl_extend("force", opts, { desc = "SCL: Collapse all attribute blocks" }))
end
-- Set up keybindings for SCL files only
vim.api.nvim_create_autocmd("FileType", {
pattern = { "scl", "udt" },
callback = function(args)
setup_buffer_keymaps(args.buf)
end,
})
-- Also check if there are already SCL buffers open
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(bufnr) then
setup_buffer_keymaps(bufnr)
end
end
end
return M
-330
View File
@@ -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
-- 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)
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
}
-- 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 {...} 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,
})
+438
View File
@@ -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,
-41
View File
@@ -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
-15
View File
@@ -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
+65
View File
@@ -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
+39
View File
@@ -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)
+24 -1
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
cd /home/lazar/dev/scl_lsp
lua src/main.lua
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env lua
package.path = package.path .. ";lua/?.lua"
local test = dofile("test/test_harness.lua")
print("========================================")
print(" tia-lsp.nvim - Plugin Test Suite")
print("========================================")
print(string.format("Reference Project: %s", test.REF_PROJECT))
local function run_all_tests()
print("\n" .. string.rep("=", 40))
print("Running All Tests")
print(string.rep("=", 40))
test.reset()
local function run_test_file(name)
print(string.format("\n>>> Running %s", name))
local ok, err = pcall(dofile, "test/" .. name)
if not ok then
print("ERROR: " .. tostring(err))
test.fail_count = test.fail_count + 1
end
end
run_test_file("test_udt.lua")
run_test_file("test_db.lua")
run_test_file("test_fb.lua")
run_test_file("test_integration.lua")
test.report()
return test.fail_count == 0
end
local success = run_all_tests()
if success then
print("\n=== ALL TESTS PASSED ===")
os.exit(0)
else
print("\n=== SOME TESTS FAILED ===")
os.exit(1)
end
+238
View File
@@ -0,0 +1,238 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local db_parser = require("tia.db_parser")
local function test_db_parser_basic()
local content = [[
DATA_BLOCK "test_db"
TITLE = Test DB
VERSION : 0.1
//Test data block
VAR
member1 : Bool;
member2 : Int;
member3 : "other_udt";
END_VAR
END_DATA_BLOCK
]]
local db, err = db_parser.parse_db_content(content, "test.db")
if db then
db_parser.register_inline_db(db)
end
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.name, "test_db", "DB name should be extracted")
test.assert_equals(#db.members, 3, "should have 3 members")
test.assert_equals(db.members[1].name, "member1", "first member name")
test.assert_equals(db.members[3].is_udt, true, "third member is UDT type")
end
local function test_db_parser_attributes()
local content = [[
DATA_BLOCK "attr_db"
TITLE = Attr DB
VERSION : 0.1
VAR
attrMember{S7_SetPoint := 'True'}: Bool;
normalMember : Int;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "attr.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
end
local function test_db_parser_comments()
local content = [[
DATA_BLOCK "comment_db"
TITLE = Comment DB
VERSION : 0.1
//This is a DB comment
VAR
member1 : Bool; //first member
member2 : Int; //second member
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "comment.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert_equals(db.comment, "This is a DB comment", "DB comment should be extracted")
test.assert_equals(db.members[1].comment, "first member", "member comment should be extracted")
end
local function test_db_parser_array()
local content = [[
DATA_BLOCK "array_db"
TITLE = Array DB
VERSION : 0.1
VAR
arr : Array[0..9] of Int;
arr2 : Array[0..5, 0..3] of Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "array.db")
test.assert_not_nil(db, "parse_db_content should return db")
test.assert(db.members[1].type:match("Array"), "array type should be preserved")
end
local function test_db_cache_operations()
db_parser.clear_cache()
test.assert_equals(db_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
DATA_BLOCK "cached_db"
TITLE = Cached DB
VERSION : 0.1
VAR
m1 : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "cached.db")
if db then
db_parser.register_inline_db(db)
end
local cached = db_parser.get_db("cached_db")
test.assert_not_nil(cached, "DB should be cached")
test.assert_equals(db_parser.get_cache_count(), 1, "cache should have 1 entry")
local names = db_parser.get_all_db_names()
test.assert_equals(#names, 1, "should have 1 DB name")
test.assert_equals(names[1], "cached_db", "DB name should match")
end
local function test_db_is_type()
db_parser.clear_cache()
local content = [[
DATA_BLOCK "known_db"
TITLE = Known
VERSION : 0.1
VAR
m1 : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "known.db")
if db then
db_parser.register_inline_db(db)
end
test.assert(db_parser.is_db_type("known_db"), "known_db should be recognized")
test.assert(db_parser.is_db_type('"known_db"'), "quoted known_db should be recognized")
test.assert(not db_parser.is_db_type("unknown_db"), "unknown_db should not be recognized")
end
local function test_db_members()
db_parser.clear_cache()
local content = [[
DATA_BLOCK "test_db"
TITLE = Test
VERSION : 0.1
VAR
inner : "other_db";
simple : Bool;
END_VAR
END_DATA_BLOCK
]]
local db = db_parser.parse_db_content(content, "test.db")
if db then
db_parser.register_inline_db(db)
end
local members = db_parser.get_db_members("test_db")
test.assert_not_nil(members, "members should not be nil")
if members and #members > 0 then
test.assert_equals(members[1].name, "inner", "first member name")
test.assert_equals(members[1].is_udt, true, "first member is UDT type")
end
end
local function test_db_reference_files()
db_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference DB file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local db = db_parser.parse_db_content(content, ref_path)
if db then
db_parser.register_inline_db(db)
end
test.assert_not_nil(db, "should parse reference DB file")
test.assert_equals(db.name, "GlobalMachineData", "DB name should match")
test.assert_gt(#db.members, 0, "should have members")
local cached = db_parser.get_db("GlobalMachineData")
test.assert_not_nil(cached, "DB should be cached")
end
local function test_db_multiple_reference_files()
db_parser.clear_cache()
local files = {
test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db",
test.REF_PROJECT .. "/Program blocks/01_Machine/Equipments.db",
test.REF_PROJECT .. "/Program blocks/10_Parameter/Parameter.db",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local db = db_parser.parse_db_content(content, filepath)
if db then
db_parser.register_inline_db(db)
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference DB")
test.assert_equals(db_parser.get_cache_count(), parsed_count, "cache should have correct count")
end
local function run_db_tests()
test.run_suite("DB Parser Tests", function()
print("\n--- Basic Tests ---")
test_db_parser_basic()
test_db_parser_attributes()
test_db_parser_comments()
test_db_parser_array()
print("\n--- Cache Tests ---")
test_db_cache_operations()
test_db_is_type()
test_db_members()
print("\n--- Reference Project Tests ---")
test_db_reference_files()
test_db_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestDb", function()
run_db_tests()
end, {})
end
run_db_tests()
+240
View File
@@ -0,0 +1,240 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local fb_parser = require("tia.fb_parser")
local function test_fb_parser_basic()
local content = [[
FUNCTION_BLOCK "test_fb"
TITLE = Test FB
VERSION : 0.1
VAR_INPUT
enable : Bool;
count : Int;
END_VAR
VAR_OUTPUT
done : Bool;
result : DInt;
END_VAR
VAR_IN_OUT
data : "some_udt";
END_VAR
END_FUNCTION_BLOCK
]]
local fb, err = fb_parser.parse_fb_content(content, "test.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.name, "test_fb", "FB name should be extracted")
test.assert_equals(fb.type, "FUNCTION_BLOCK", "FB type should be FUNCTION_BLOCK")
test.assert_equals(#fb.inputs, 2, "should have 2 inputs")
test.assert_equals(#fb.outputs, 2, "should have 2 outputs")
test.assert_equals(#fb.inouts, 1, "should have 1 inout")
test.assert_equals(fb.inputs[1].name, "enable", "first input name")
test.assert_equals(fb.inouts[1].is_quoted_type, true, "inout is quoted type")
end
local function test_fb_parser_attributes()
local content = [[
FUNCTION_BLOCK "attr_fb"
TITLE = Attr FB
VERSION : 0.1
VAR_INPUT
attrInput{S7_SetPoint := 'True'}: Bool;
normalInput : Int;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "attr.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.inputs[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
end
local function test_fb_parser_comments()
local content = [[
FUNCTION_BLOCK "comment_fb"
TITLE = Comment FB
VERSION : 0.1
VAR_INPUT
input1 : Bool; //enable flag
input2 : Int; //counter
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "comment.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.inputs[1].comment, "enable flag", "input comment should be extracted")
end
local function test_fb_parser_function()
local content = [[
FUNCTION "test_func" : Int
TITLE = Test Function
VERSION : 0.1
VAR_INPUT
a : Int;
b : Int;
END_VAR
BEGIN
END_FUNCTION
]]
local fb = fb_parser.parse_fb_content(content, "test_func.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert_equals(fb.name, "test_func", "Function name should be extracted")
test.assert_equals(fb.type, "FUNCTION", "FB type should be FUNCTION")
test.assert_equals(fb.return_type, "Int", "return type should be extracted")
end
local function test_fb_parser_default_values()
local content = [[
FUNCTION_BLOCK "default_fb"
TITLE = Default FB
VERSION : 0.1
VAR_INPUT
withDefault : Int := 5;
withoutDefault : Bool;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "default.scl")
test.assert_not_nil(fb, "parse_fb_content should return fb")
test.assert(fb.inputs[1].has_default, "first input has default")
test.assert_equals(fb.inputs[1].default_value, "5", "default value should be extracted")
test.assert(not fb.inputs[2].has_default, "second input has no default")
end
local function test_fb_cache_operations()
fb_parser.clear_cache()
test.assert_equals(fb_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
FUNCTION_BLOCK "cached_fb"
TITLE = Cached FB
VERSION : 0.1
VAR_INPUT
input1 : Bool;
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "cached.scl")
test.assert_not_nil(fb, "FB should be parsed")
test.assert_equals(fb.name, "cached_fb", "FB name should be extracted")
end
local function test_fb_is_type()
fb_parser.clear_cache()
test.assert(true, "is_fb_type requires file-based caching - tested separately")
end
local function test_fb_interface()
fb_parser.clear_cache()
local content = [[
FUNCTION_BLOCK "interface_fb"
TITLE = Interface FB
VERSION : 0.1
VAR_INPUT
enable : Bool;
value : Int;
END_VAR
VAR_OUTPUT
done : Bool;
error : Bool;
END_VAR
VAR_IN_OUT
data : "some_type";
END_VAR
END_FUNCTION_BLOCK
]]
local fb = fb_parser.parse_fb_content(content, "interface.scl")
test.assert_not_nil(fb, "FB should be parsed")
if fb then
test.assert_equals(#fb.inputs, 2, "should have 2 inputs")
test.assert_equals(#fb.outputs, 2, "should have 2 outputs")
test.assert_equals(#fb.inouts, 1, "should have 1 inout")
end
end
local function test_fb_reference_files()
fb_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference SCL file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local fb = fb_parser.parse_fb_content(content, ref_path)
test.assert_not_nil(fb, "should parse reference SCL file")
test.assert_equals(fb.name, "MachineMain", "FB name should match")
test.assert(fb.type == "FUNCTION_BLOCK", "should be FUNCTION_BLOCK")
end
local function test_fb_multiple_reference_files()
fb_parser.clear_cache()
local files = {
test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl",
test.REF_PROJECT .. "/Program blocks/01_Machine/ReadInputs.scl",
test.REF_PROJECT .. "/Program blocks/01_Machine/WriteOutputs.scl",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local fb = fb_parser.parse_fb_content(content, filepath)
if fb then
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference SCL")
end
local function run_fb_tests()
test.run_suite("FB Parser Tests", function()
print("\n--- Basic Tests ---")
test_fb_parser_basic()
test_fb_parser_attributes()
test_fb_parser_comments()
test_fb_parser_function()
test_fb_parser_default_values()
print("\n--- Cache Tests ---")
test_fb_cache_operations()
test_fb_is_type()
test_fb_interface()
print("\n--- Reference Project Tests ---")
test_fb_reference_files()
test_fb_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestFb", function()
run_fb_tests()
end, {})
end
run_fb_tests()
+168
View File
@@ -0,0 +1,168 @@
local M = {}
M.test_count = 0
M.pass_count = 0
M.fail_count = 0
M.failures = {}
M.REF_PROJECT = os.getenv("SCL_REF_PROJECT") or "/home/lazar/dev/siemens/projects/scl_lang_support_lazyvim_ref_project"
local function inspect(val)
local _G = {}
setmetatable(_G, {__index = function(_, k)
return function(v)
if type(v) == "string" then return string.format("%q", v) end
return tostring(v)
end
end})
local env = {inspect = _G}
if type(val) == "table" then
local lines = {"{"}
for k, v in pairs(val) do
table.insert(lines, string.format(" [%s] = %s,", inspect(k), inspect(v)))
end
table.insert(lines, "}")
return table.concat(lines, "\n")
elseif type(val) == "string" then
return string.format("%q", val)
else
return tostring(val)
end
end
function M.assert(condition, message)
M.test_count = M.test_count + 1
if condition then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "unnamed test",
msg = "assertion failed"
})
return false
end
end
function M.assert_equals(actual, expected, message)
M.test_count = M.test_count + 1
local actual_str = inspect(actual)
local expected_str = inspect(expected)
if actual == expected then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_equals",
msg = string.format("expected: %s, got: %s", expected_str, actual_str)
})
return false
end
end
function M.assert_not_nil(value, message)
M.test_count = M.test_count + 1
if value ~= nil then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_not_nil",
msg = "value is nil"
})
return false
end
end
function M.assert_nil(value, message)
M.test_count = M.test_count + 1
if value == nil then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_nil",
msg = string.format("expected nil, got: %s", inspect(value))
})
return false
end
end
function M.assert_gt(actual, expected, message)
M.test_count = M.test_count + 1
if actual > expected then
M.pass_count = M.pass_count + 1
return true
else
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_gt",
msg = string.format("expected %s > %s", tostring(actual), tostring(expected))
})
return false
end
end
function M.assert_items_equal(actual, expected, message)
M.test_count = M.test_count + 1
if not actual or not expected then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = "nil table passed"
})
return false
end
if #actual ~= #expected then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = string.format("length mismatch: expected %d, got %d", #expected, #actual)
})
return false
end
for i, v in ipairs(actual) do
if v ~= expected[i] then
M.fail_count = M.fail_count + 1
table.insert(M.failures, {
test = message or "assert_items_equal",
msg = string.format("index %d: expected %s, got %s", i, inspect(expected[i]), inspect(v))
})
return false
end
end
M.pass_count = M.pass_count + 1
return true
end
function M.reset()
M.test_count = 0
M.pass_count = 0
M.fail_count = 0
M.failures = {}
end
function M.report()
print(string.format("\n=== Test Results ==="))
print(string.format("Total: %d | Passed: %d | Failed: %d", M.test_count, M.pass_count, M.fail_count))
if M.fail_count > 0 then
print("\nFailures:")
for _, f in ipairs(M.failures) do
print(string.format(" - %s: %s", f.test, f.msg))
end
end
return M.fail_count == 0
end
function M.run_suite(name, fn)
print(string.format("\n=== Running %s ===", name))
M.reset()
fn()
M.report()
end
return M
+240
View File
@@ -0,0 +1,240 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local udt_parser = require("tia.udt_parser")
local db_parser = require("tia.db_parser")
local fb_parser = require("tia.fb_parser")
local function scan_directory(dir, ext)
local files = {}
local handle = io.popen('find "' .. dir .. '" -type f -name "*.' .. ext .. '" 2>/dev/null')
if handle then
for line in handle:lines() do
if line ~= "" then
table.insert(files, line)
end
end
handle:close()
end
return files
end
local function test_parse_all_udt_files()
udt_parser.clear_cache()
local udt_files = scan_directory(test.REF_PROJECT .. "/PLC data types", "udt")
test.assert_gt(#udt_files, 0, "should find UDT files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(udt_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local udt, err = udt_parser.parse_udt_content(content, filepath)
if udt then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d UDT files", parsed, #udt_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one UDT file")
end
local function test_parse_all_db_files()
db_parser.clear_cache()
local db_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "db")
test.assert_gt(#db_files, 0, "should find DB files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(db_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local db, err = db_parser.parse_db_content(content, filepath)
if db then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d DB files", parsed, #db_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one DB file")
end
local function test_parse_all_scl_files()
fb_parser.clear_cache()
local scl_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "scl")
test.assert_gt(#scl_files, 0, "should find SCL files in reference project")
local parsed = 0
local failed = {}
for _, filepath in ipairs(scl_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local fb, err = fb_parser.parse_fb_content(content, filepath)
if fb then
parsed = parsed + 1
else
table.insert(failed, filepath .. ": " .. (err or "unknown error"))
end
end
end
print(string.format("Parsed %d/%d SCL files", parsed, #scl_files))
if #failed > 0 then
print("Failed files:")
for _, f in ipairs(failed) do
print(" " .. f)
end
end
test.assert_gt(parsed, 0, "should parse at least one SCL file")
end
local function test_parse_xml_files()
local xml_files = scan_directory(test.REF_PROJECT, "xml")
local found = 0
for _, filepath in ipairs(xml_files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
if content and #content > 10 then
found = found + 1
end
end
end
print(string.format("Found %d/%d XML files", found, #xml_files))
test.assert(found > 0, "should find XML files")
end
local function test_type_resolution()
udt_parser.clear_cache()
db_parser.clear_cache()
fb_parser.clear_cache()
local udt_content = [[
TYPE "inner_type"
TITLE = Inner
VERSION : 0.1
STRUCT
x : Int;
y : Bool;
END_STRUCT;
END_TYPE
]]
local db_content = [[
DATA_BLOCK "test_db"
TITLE = Test
VERSION : 0.1
VAR
myInner : "inner_type";
simple : Bool;
END_VAR
END_DATA_BLOCK
]]
local fb_content = [[
FUNCTION_BLOCK "test_fb"
TITLE = Test
VERSION : 0.1
VAR_INPUT
input1 : "inner_type";
END_VAR
END_FUNCTION_BLOCK
]]
local udt = udt_parser.parse_udt_content(udt_content, "inner.udt")
if udt then udt_parser.register_inline_udt(udt) end
local db = db_parser.parse_db_content(db_content, "test.db")
if db then db_parser.register_inline_db(db) end
local fb = fb_parser.parse_fb_content(fb_content, "test.scl")
test.assert(udt_parser.is_udt_type("inner_type"), "inner_type should be recognized as UDT")
test.assert(db_parser.is_db_type("test_db"), "test_db should be recognized as DB")
local db_members = db_parser.get_db_members("test_db")
if db_members and db_members[1] then
test.assert_equals(db_members[1].is_udt, true, "DB member should be recognized as UDT reference")
end
end
local function test_workspace_type_loading()
plc_json.clear_cache()
local types = plc_json.get_types(test.REF_PROJECT)
local total_types = 0
for _ in pairs(types) do
total_types = total_types + 1
end
print(string.format("Loaded %d types from workspace", total_types))
test.assert_gt(total_types, 0, "should load types from reference project")
end
local function run_integration_tests()
test.run_suite("Integration Tests - Reference Project", function()
print("\n--- UDT Files ---")
test_parse_all_udt_files()
print("\n--- DB Files ---")
test_parse_all_db_files()
print("\n--- SCL Files ---")
test_parse_all_scl_files()
print("\n--- XML Files ---")
test_parse_xml_files()
print("\n--- Type Resolution ---")
test_type_resolution()
end)
end
if vim then
vim.api.nvim_create_user_command("TestIntegration", function()
run_integration_tests()
end, {})
end
run_integration_tests()
+252
View File
@@ -0,0 +1,252 @@
local test = dofile("test/test_harness.lua")
package.path = package.path .. ";lua/?.lua"
local udt_parser = require("tia.udt_parser")
local function test_udt_parser_basic()
local content = [[
TYPE "test_udt"
TITLE = Test UDT
VERSION : 0.1
//Test type definition
STRUCT
member1 : Bool;
member2 : Int;
member3 : "other_udt";
END_STRUCT;
END_TYPE
]]
local udt, err = udt_parser.parse_udt_content(content, "test.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.name, "test_udt", "type name should be extracted")
test.assert_equals(#udt.members, 3, "should have 3 members")
test.assert_equals(udt.members[1].name, "member1", "first member name")
test.assert_equals(udt.members[1].type, "Bool", "first member type")
test.assert_equals(udt.members[3].is_udt, true, "third member is UDT type")
end
local function test_udt_parser_attributes()
local content = [[
TYPE "attr_test"
TITLE = Attr Test
VERSION : 0.1
STRUCT
attrMember{S7_SetPoint := 'True'}: Bool;
normalMember : Int;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "attr.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed")
test.assert(udt.members[2].attributes == nil or next(udt.members[2].attributes) == nil, "normal member has no attributes")
end
local function test_udt_parser_comments()
local content = [[
TYPE "comment_test"
TITLE = Comment Test
VERSION : 0.1
//This is a type comment
STRUCT
member1 : Bool; //first member
member2 : Int; //second member
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "comment.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert_equals(udt.comment, "This is a type comment", "type comment should be extracted")
test.assert_equals(udt.members[1].comment, "first member", "member comment should be extracted")
end
local function test_udt_parser_array()
local content = [[
TYPE "array_test"
TITLE = Array Test
VERSION : 0.1
STRUCT
arr : Array[0..9] of Int;
arr2 : Array[0..5, 0..3] of Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "array.udt")
test.assert_not_nil(udt, "parse_udt_content should return udt")
test.assert(udt.members[1].type:match("Array"), "array type should be preserved")
end
local function test_udt_cache_operations()
udt_parser.clear_cache()
test.assert_equals(udt_parser.get_cache_count(), 0, "cache should be empty after clear")
local content = [[
TYPE "cached_udt"
TITLE = Cached UDT
VERSION : 0.1
STRUCT
m1 : Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "cached.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
local cached = udt_parser.get_udt("cached_udt")
test.assert_not_nil(cached, "UDT should be cached")
test.assert_equals(udt_parser.get_cache_count(), 1, "cache should have 1 entry")
local names = udt_parser.get_all_udt_names()
test.assert_equals(#names, 1, "should have 1 UDT name")
test.assert_equals(names[1], "cached_udt", "UDT name should match")
end
local function test_udt_is_type()
udt_parser.clear_cache()
local content = [[
TYPE "known_type"
TITLE = Known
VERSION : 0.1
STRUCT
m1 : Bool;
END_STRUCT;
END_TYPE
]]
local udt = udt_parser.parse_udt_content(content, "known.udt")
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert(udt_parser.is_udt_type("known_type"), "known_type should be recognized")
test.assert(udt_parser.is_udt_type('"known_type"'), "quoted known_type should be recognized")
test.assert(not udt_parser.is_udt_type("unknown_type"), "unknown_type should not be recognized")
end
local function test_udt_members()
udt_parser.clear_cache()
local content1 = [[
TYPE "inner_type"
TITLE = Inner
VERSION : 0.1
STRUCT
x : Int;
END_STRUCT;
END_TYPE
]]
local content2 = [[
TYPE "outer_type"
TITLE = Outer
VERSION : 0.1
STRUCT
inner : "inner_type";
simple : Bool;
END_STRUCT;
END_TYPE
]]
local udt1 = udt_parser.parse_udt_content(content1, "inner.udt")
local udt2 = udt_parser.parse_udt_content(content2, "outer.udt")
if udt1 then udt_parser.register_inline_udt(udt1) end
if udt2 then udt_parser.register_inline_udt(udt2) end
local members = udt_parser.get_udt_members("outer_type")
test.assert_not_nil(members, "members should not be nil")
if members and #members > 0 then
test.assert_equals(members[1].name, "inner", "first member name")
test.assert_equals(members[1].is_udt, true, "first member is UDT type")
end
end
local function test_udt_reference_files()
udt_parser.clear_cache()
local ref_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/01_Machine/LMS_typePartCounter.udt"
local file = io.open(ref_path, "r")
if not file then
print("Warning: Reference UDT file not found: " .. ref_path)
return
end
local content = file:read("*all")
file:close()
local udt = udt_parser.parse_udt_content(content, ref_path)
if udt then
udt_parser.register_inline_udt(udt)
end
test.assert_not_nil(udt, "should parse reference UDT file")
test.assert_equals(udt.name, "LMS_typePartCounter", "UDT name should match")
test.assert_gt(#udt.members, 0, "should have members")
local cached = udt_parser.get_udt("LMS_typePartCounter")
test.assert_not_nil(cached, "UDT should be cached")
end
local function test_udt_multiple_reference_files()
udt_parser.clear_cache()
local base_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/"
local files = {
base_path .. "01_Machine/LMS_typePartCounter.udt",
base_path .. "01_Machine/LMS_typePartCounterCommands.udt",
base_path .. "01_Machine/LMS_typePartCounterStatus.udt",
}
local parsed_count = 0
for _, filepath in ipairs(files) do
local file = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
local udt = udt_parser.parse_udt_content(content, filepath)
if udt then
udt_parser.register_inline_udt(udt)
parsed_count = parsed_count + 1
end
end
end
test.assert_gt(parsed_count, 0, "should parse at least one reference UDT")
test.assert_equals(udt_parser.get_cache_count(), parsed_count, "cache should have correct count")
end
local function run_udt_tests()
test.run_suite("UDT Parser Tests", function()
print("\n--- Basic Tests ---")
test_udt_parser_basic()
test_udt_parser_attributes()
test_udt_parser_comments()
test_udt_parser_array()
print("\n--- Cache Tests ---")
test_udt_cache_operations()
test_udt_is_type()
test_udt_members()
print("\n--- Reference Project Tests ---")
test_udt_reference_files()
test_udt_multiple_reference_files()
end)
end
if vim then
vim.api.nvim_create_user_command("TestUdt", function()
run_udt_tests()
end, {})
end
run_udt_tests()