Inintal commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# AGENTS.md - SCL Language Server (Unified)
|
||||||
|
|
||||||
|
This is a unified Neovim/LazyVim plugin for Siemens SCL language support.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
The unified `scl_lsp` project provides:
|
||||||
|
- **LSP Server** - Language Server Protocol implementation
|
||||||
|
- **Linter** - Diagnostics and code validation
|
||||||
|
- **Formatter** - Document formatting
|
||||||
|
- **Syntax Highlighting** - Tree-sitter based
|
||||||
|
- **Auto-completion** - blink.cmp integration
|
||||||
|
- **Auto-prefix** - Automatic `#` prefixing for variables
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
scl_lsp/
|
||||||
|
├── src/ # LSP server implementation
|
||||||
|
│ ├── main.lua # Entry point, protocol handling
|
||||||
|
│ ├── parser.lua # Regex-based SCL parser
|
||||||
|
│ ├── treesitter.lua # Tree-sitter integration
|
||||||
|
│ ├── diagnostics.lua # Linter diagnostics
|
||||||
|
│ ├── formatter.lua # Document formatter
|
||||||
|
│ ├── json.lua # JSON encoder/decoder
|
||||||
|
│ └── plc_json.lua # External UDT loading
|
||||||
|
├── lua/
|
||||||
|
│ ├── scl/ # Neovim plugin modules (from scl_syntax_nvim)
|
||||||
|
│ │ ├── init.lua
|
||||||
|
│ │ ├── auto_prefix.lua
|
||||||
|
│ │ ├── blink_cmp_source.lua
|
||||||
|
│ │ ├── fb_parser.lua
|
||||||
|
│ │ ├── udt_parser.lua
|
||||||
|
│ │ ├── variables.lua
|
||||||
|
│ │ └── workspace_types.lua
|
||||||
|
│ └── scl_lsp/
|
||||||
|
│ ├── init.lua # Unified plugin setup
|
||||||
|
│ └── plugins/
|
||||||
|
│ └── scl.lua # LazyVim plugin spec
|
||||||
|
├── queries/ # Tree-sitter queries
|
||||||
|
│ ├── highlights.scm
|
||||||
|
│ ├── indents.scm
|
||||||
|
│ ├── folds.scm
|
||||||
|
│ ├── locals.scm
|
||||||
|
│ └── tags.scm
|
||||||
|
├── grammar.js # Tree-sitter grammar
|
||||||
|
└── tree-sitter.json # Parser config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running the LSP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Direct execution
|
||||||
|
lua src/main.lua
|
||||||
|
|
||||||
|
# Via shell script
|
||||||
|
./scl_lsp.sh
|
||||||
|
|
||||||
|
# With test mode
|
||||||
|
lua src/main.lua --test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tree-sitter parser
|
||||||
|
npm test
|
||||||
|
tree-sitter generate
|
||||||
|
|
||||||
|
# Test file parsing
|
||||||
|
tree-sitter parse corpus/test/main_org_block.scl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
- **LSP Protocol**: JSON-RPC over stdin/stdout in `src/main.lua`
|
||||||
|
- **Diagnostics**: `src/diagnostics.lua` - validates SCL code
|
||||||
|
- **Formatter**: `src/formatter.lua` - formats SCL documents
|
||||||
|
- **Syntax**: Tree-sitter queries in `queries/`
|
||||||
|
- **Plugin Setup**: `lua/scl_lsp/init.lua` - LazyVim integration
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See `README.md` for configuration options and usage.
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# SCL Language Server - Unified Plugin for Siemens SCL
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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 variable declaration |
|
||||||
|
| **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
|
||||||
|
- ORGANIZATION_BLOCK, FUNCTION_BLOCK, FUNCTION definitions
|
||||||
|
- Variable declarations (VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, etc.)
|
||||||
|
- Control structures (IF/THEN/ELSE, CASE, FOR, WHILE, REPEAT)
|
||||||
|
- Code regions and comments
|
||||||
|
- All SCL data types and operators
|
||||||
|
|
||||||
|
### Additional Features
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Auto-Prefix** | Automatically adds `#` prefix to local variables (like TIA Portal) |
|
||||||
|
| **Workspace UDT Scanner** | Scans project for user-defined types |
|
||||||
|
| **blink.cmp Integration** | Smart completion source for blink.cmp |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### LazyVim
|
||||||
|
```lua
|
||||||
|
-- ~/.config/nvim/lua/plugins/scl.lua
|
||||||
|
return {
|
||||||
|
"lazar/scl_lsp",
|
||||||
|
ft = "scl",
|
||||||
|
lazy = true,
|
||||||
|
dependencies = {
|
||||||
|
"neovim/nvim-lspconfig",
|
||||||
|
"nvim-treesitter/nvim-treesitter",
|
||||||
|
"saghen/blink.cmp",
|
||||||
|
},
|
||||||
|
config = function()
|
||||||
|
require("scl_lsp").setup({
|
||||||
|
-- LSP options
|
||||||
|
lsp = {
|
||||||
|
on_attach = function(client, bufnr)
|
||||||
|
-- custom keymaps
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
cmp = true, -- Enable blink.cmp integration
|
||||||
|
workspace_types = true, -- Enable workspace UDT scanning
|
||||||
|
auto_prefix = true, -- Enable auto-# prefixing
|
||||||
|
debug = false,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
```lua
|
||||||
|
-- In your init.lua or plugin file
|
||||||
|
require("scl_lsp").setup({
|
||||||
|
server_path = "/path/to/scl_lsp/src/main.lua",
|
||||||
|
cmp = true,
|
||||||
|
workspace_types = true,
|
||||||
|
auto_prefix = true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `server_path` | string | `~/dev/scl_lsp/src/main.lua` | Path to LSP server |
|
||||||
|
| `lsp.on_attach` | function | `nil` | Custom LSP attach callback |
|
||||||
|
| `lsp.formatting` | boolean | `false` | Enable format command |
|
||||||
|
| `cmp` | boolean | `true` | Enable blink.cmp integration |
|
||||||
|
| `workspace_types` | boolean | `true` | Enable workspace UDT scanning |
|
||||||
|
| `auto_prefix` | boolean | `true` | Enable auto-# prefixing |
|
||||||
|
| `project_root` | string | `nil` | Custom project root |
|
||||||
|
| `library_paths` | table | `{}` | Additional library paths |
|
||||||
|
| `debug` | boolean | `false` | Enable debug logging |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `:SCLShowVariables` | Show local variables in current file |
|
||||||
|
| `:SCLShowWorkspaceTypes` | Show workspace UDTs and FB count |
|
||||||
|
| `:SCLRescanWorkspaceTypes` | Rescan workspace for types |
|
||||||
|
| `:SCLPrefixWord` | Manually prefix current word with `#` |
|
||||||
|
| `:LspSCLFormat` | Format current SCL file |
|
||||||
|
| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ |
|
||||||
|
|
||||||
|
## Keybindings
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `K` | Hover (show variable info) |
|
||||||
|
| `gd` | Go to Definition |
|
||||||
|
| `<leader>s` | Document Symbols |
|
||||||
|
| `<leader>w` | Workspace Symbols |
|
||||||
|
| `<leader>fw` | Workspace Symbols (Telescope) |
|
||||||
|
| `<leader>ca` | Code Actions |
|
||||||
|
|
||||||
|
## External UDT Support
|
||||||
|
|
||||||
|
### Option 1: Create `.plc.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dataTypes": [
|
||||||
|
{
|
||||||
|
"name": "equipmentData",
|
||||||
|
"struct": [
|
||||||
|
{"name": "id", "dataTypeName": "INT"},
|
||||||
|
{"name": "status", "dataTypeName": "BOOL"},
|
||||||
|
{"name": "temperature", "dataTypeName": "REAL"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
scl_lsp/
|
||||||
|
├── README.md # This file
|
||||||
|
├── package.json # NPM scripts (tree-sitter)
|
||||||
|
├── Makefile # Build commands
|
||||||
|
├── grammar.js # Tree-sitter grammar
|
||||||
|
├── tree-sitter.json # Parser config
|
||||||
|
├── queries/ # Tree-sitter queries
|
||||||
|
│ ├── highlights.scm # Syntax highlighting
|
||||||
|
│ ├── indents.scm # Indentation
|
||||||
|
│ ├── folds.scm # Code folding
|
||||||
|
│ ├── locals.scm # Variable scoping
|
||||||
|
│ └── tags.scm # Navigation tags
|
||||||
|
├── src/ # LSP server
|
||||||
|
│ ├── main.lua # Main entry point
|
||||||
|
│ ├── parser.lua # Regex-based parser
|
||||||
|
│ ├── treesitter.lua # Tree-sitter integration
|
||||||
|
│ ├── json.lua # JSON encoder/decoder
|
||||||
|
│ ├── plc_json.lua # External UDT loading
|
||||||
|
│ ├── diagnostics.lua # Linter diagnostics
|
||||||
|
│ └── formatter.lua # Document formatter
|
||||||
|
└── lua/
|
||||||
|
└── scl_lsp/
|
||||||
|
├── init.lua # Main plugin setup
|
||||||
|
├── plugins/
|
||||||
|
│ └── scl.lua # LazyVim plugin spec
|
||||||
|
└── scl/ # Syntax/completion modules
|
||||||
|
├── init.lua
|
||||||
|
├── auto_prefix.lua
|
||||||
|
├── blink_cmp_source.lua
|
||||||
|
├── fb_parser.lua
|
||||||
|
├── udt_parser.lua
|
||||||
|
├── variables.lua
|
||||||
|
└── workspace_types.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building Tree-sitter Parser
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate parser from grammar.js
|
||||||
|
npm run build
|
||||||
|
# or: tree-sitter generate
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
# or: tree-sitter test
|
||||||
|
```
|
||||||
|
|
||||||
|
## SCL Code Example
|
||||||
|
|
||||||
|
```pascal
|
||||||
|
FUNCTION_BLOCK "Em101Sequence"
|
||||||
|
VAR
|
||||||
|
x : INT;
|
||||||
|
myVar : equipmentData; // User-defined type
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
IF x > 5 THEN
|
||||||
|
myVar.id := 10;
|
||||||
|
END_IF
|
||||||
|
|
||||||
|
myVar.status := TRUE;
|
||||||
|
myVar.temperature := 25.5;
|
||||||
|
|
||||||
|
END_FUNCTION_BLOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Neovim 0.9+**
|
||||||
|
- **nvim-lspconfig** - LSP client
|
||||||
|
- **nvim-treesitter** - Syntax highlighting
|
||||||
|
- **blink.cmp** - Optional, for completion
|
||||||
|
- **Lua 5.1+** - LSP server runtime
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
-- Auto-prefixing module for SCL
|
||||||
|
-- Automatically adds # prefix to local variables when typed outside VAR sections
|
||||||
|
-- Replicates TIA Portal SCL behavior
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local ts = vim.treesitter
|
||||||
|
|
||||||
|
-- Cache for local variables to avoid reparsing
|
||||||
|
local var_cache = {}
|
||||||
|
local cache_bufnr = nil
|
||||||
|
local cache_timestamp = nil
|
||||||
|
|
||||||
|
-- Clear cache when buffer changes
|
||||||
|
local function clear_cache()
|
||||||
|
var_cache = {}
|
||||||
|
cache_bufnr = nil
|
||||||
|
cache_timestamp = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keywords that should never be prefixed
|
||||||
|
local keywords = {
|
||||||
|
"TRUE", "FALSE", "NOT", "AND", "OR", "XOR", "MOD",
|
||||||
|
"IF", "THEN", "ELSE", "ELSIF", "END_IF",
|
||||||
|
"FOR", "TO", "BY", "DO", "END_FOR",
|
||||||
|
"WHILE", "END_WHILE",
|
||||||
|
"REPEAT", "UNTIL", "END_REPEAT",
|
||||||
|
"CASE", "OF", "END_CASE",
|
||||||
|
"EXIT", "CONTINUE",
|
||||||
|
"REGION", "END_REGION",
|
||||||
|
"BEGIN", "VAR", "VAR_INPUT", "VAR_OUTPUT", "VAR_IN_OUT",
|
||||||
|
"VAR_TEMP", "VAR_CONSTANT", "VAR_RETAIN", "VAR_NON_RETAIN", "END_VAR",
|
||||||
|
"ORGANIZATION_BLOCK", "END_ORGANIZATION_BLOCK",
|
||||||
|
"FUNCTION_BLOCK", "END_FUNCTION_BLOCK",
|
||||||
|
"FUNCTION", "END_FUNCTION",
|
||||||
|
"RETURN",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Check if word is a keyword
|
||||||
|
local function is_keyword(word)
|
||||||
|
for _, kw in ipairs(keywords) do
|
||||||
|
if word == kw then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get list of local variable names (with caching)
|
||||||
|
local function get_local_variables()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
|
||||||
|
-- Return cached variables if available for current buffer
|
||||||
|
if cache_bufnr == bufnr and cache_timestamp then
|
||||||
|
local current_time = vim.loop.now()
|
||||||
|
if current_time - cache_timestamp < 5000 then -- 5 second cache
|
||||||
|
return var_cache
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local scl_vars = require("scl.variables")
|
||||||
|
local vars = scl_vars.extract_variables(bufnr)
|
||||||
|
|
||||||
|
var_cache = {}
|
||||||
|
for _, var in ipairs(vars) do
|
||||||
|
var_cache[var.name] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
cache_bufnr = bufnr
|
||||||
|
cache_timestamp = vim.loop.now()
|
||||||
|
return var_cache
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we're inside a VAR declaration section
|
||||||
|
local function is_in_var_section()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||||
|
|
||||||
|
-- Try treesitter first
|
||||||
|
local ok, parser = pcall(ts.get_parser, bufnr, "scl")
|
||||||
|
if ok and parser then
|
||||||
|
local tree = parser:parse()[1]
|
||||||
|
if tree then
|
||||||
|
local root = tree:root()
|
||||||
|
local query_ok, query = pcall(ts.query.parse, "scl", [[
|
||||||
|
(var_declaration) @var
|
||||||
|
]])
|
||||||
|
if query_ok then
|
||||||
|
for _, match, _ in query:iter_matches(root, bufnr, 0, -1) do
|
||||||
|
for _, nodes in pairs(match) do
|
||||||
|
local node_list = type(nodes) == "table" and nodes or { nodes }
|
||||||
|
for _, node in ipairs(node_list) do
|
||||||
|
if node and type(node.range) == "function" then
|
||||||
|
local start_row, _, end_row, _ = node:range()
|
||||||
|
if cursor_row >= start_row and cursor_row <= end_row then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fallback: simple text-based detection
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_row + 1, false)
|
||||||
|
local in_var = false
|
||||||
|
for i = 1, cursor_row + 1 do
|
||||||
|
local line = lines[i] or ""
|
||||||
|
if line:match("^%s*VAR") then
|
||||||
|
in_var = true
|
||||||
|
elseif line:match("^%s*END_VAR") then
|
||||||
|
in_var = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return in_var
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we're after BEGIN keyword
|
||||||
|
local function is_after_begin()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||||
|
|
||||||
|
-- Search backwards from current line to find BEGIN
|
||||||
|
for i = cursor_row, 0, -1 do
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ""
|
||||||
|
if line:match("^%s*BEGIN%s*$") or line:match("^%s*BEGIN%s*;") then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- If we find another block start, stop searching
|
||||||
|
if line:match("^%s*ORGANIZATION_BLOCK%s+") or
|
||||||
|
line:match("^%s*FUNCTION_BLOCK%s+") or
|
||||||
|
line:match("^%s*FUNCTION%s+") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extract base variable from a word (handles member access like "equipmentData.cmCylinder")
|
||||||
|
local function get_base_variable(word)
|
||||||
|
-- Remove leading # if present
|
||||||
|
local clean_word = word:gsub("^#", "")
|
||||||
|
|
||||||
|
-- Find first identifier before any dot
|
||||||
|
local base = clean_word:match("^([%w_]+)")
|
||||||
|
return base
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find the last identifier in the line (before cursor) that needs prefixing
|
||||||
|
-- Only prefixes BASE variables (first in member chain), not member names
|
||||||
|
local function find_last_variable_to_prefix(line)
|
||||||
|
local vars = get_local_variables()
|
||||||
|
|
||||||
|
-- Find all identifiers in the line
|
||||||
|
local candidates = {}
|
||||||
|
|
||||||
|
-- Find all word patterns and their positions using string.find
|
||||||
|
local pos = 1
|
||||||
|
while pos <= #line do
|
||||||
|
local start_pos, end_pos, match = line:find("([%w_]+)", pos)
|
||||||
|
if not start_pos then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if not is_keyword(match) and vars[match] then
|
||||||
|
-- Skip if this identifier is part of a member chain (preceded by ".")
|
||||||
|
-- Skip if already prefixed with #
|
||||||
|
local should_skip = false
|
||||||
|
if start_pos > 1 then
|
||||||
|
local char_before = line:sub(start_pos - 1, start_pos - 1)
|
||||||
|
if char_before == "." or char_before == "#" then
|
||||||
|
should_skip = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not should_skip then
|
||||||
|
table.insert(candidates, {
|
||||||
|
name = match,
|
||||||
|
start = start_pos,
|
||||||
|
finish = end_pos,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pos = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if #candidates == 0 then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sort by position (last one first)
|
||||||
|
table.sort(candidates, function(a, b)
|
||||||
|
return a.finish > b.finish
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Check each candidate from end to start
|
||||||
|
-- We want the LAST one that should be prefixed
|
||||||
|
for _, cand in ipairs(candidates) do
|
||||||
|
local start_pos = cand.start
|
||||||
|
local end_pos = cand.finish
|
||||||
|
local name = cand.name
|
||||||
|
|
||||||
|
-- Double-check: never prefix if preceded by . or #
|
||||||
|
if start_pos > 1 then
|
||||||
|
local char_before = line:sub(start_pos - 1, start_pos - 1)
|
||||||
|
if char_before ~= "." and char_before ~= "#" then
|
||||||
|
-- This is a base variable - return it
|
||||||
|
return name, start_pos, end_pos
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- At start of line - this is a base variable
|
||||||
|
return name, start_pos, end_pos
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find base variable that ends at a specific column
|
||||||
|
-- Used when typing dot - finds variable before the dot
|
||||||
|
local function find_variable_before_dot(line, dot_col)
|
||||||
|
local vars = get_local_variables()
|
||||||
|
|
||||||
|
-- Go backwards from dot position to find word boundary
|
||||||
|
local word_end = dot_col - 1
|
||||||
|
while word_end > 0 and line:sub(word_end, word_end):match("[%w_]") do
|
||||||
|
word_end = word_end - 1
|
||||||
|
end
|
||||||
|
word_end = word_end + 1
|
||||||
|
|
||||||
|
local word = line:sub(word_end, dot_col - 1)
|
||||||
|
if word == "" then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if already prefixed
|
||||||
|
if word:sub(1, 1) == "#" then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get base variable (before any member access)
|
||||||
|
local base_var = get_base_variable(word)
|
||||||
|
if not base_var then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if it's a known local variable
|
||||||
|
if not vars[base_var] then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if already preceded by # (shouldn't happen due to check above, but be safe)
|
||||||
|
if word_end > 1 then
|
||||||
|
local prev_char = line:sub(word_end - 1, word_end - 1)
|
||||||
|
if prev_char == "#" then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return base_var, word_end, word_end + #word - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add # prefix to a variable at given position
|
||||||
|
local function add_prefix(bufnr, row, start_col, var_name)
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1]
|
||||||
|
local current_var = line:sub(start_col, start_col + #var_name - 1)
|
||||||
|
|
||||||
|
-- Check if already prefixed
|
||||||
|
if current_var:sub(1, 1) == "#" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extra safety: check if the character before is . or #
|
||||||
|
if start_col > 1 then
|
||||||
|
local char_before = line:sub(start_col - 1, start_col - 1)
|
||||||
|
if char_before == "." or char_before == "#" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Insert # before the variable
|
||||||
|
vim.api.nvim_buf_set_text(bufnr, row - 1, start_col - 1, row - 1, start_col - 1, {"#"})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Main auto-prefix function for space/semicolon triggers
|
||||||
|
local function auto_prefix_current_line()
|
||||||
|
-- Check if auto-prefixing is enabled
|
||||||
|
if not (vim.g.scl_auto_prefix or false) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only in SCL files
|
||||||
|
if vim.bo.filetype ~= "scl" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Don't prefix if inside VAR section
|
||||||
|
if is_in_var_section() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only prefix after BEGIN
|
||||||
|
if not is_after_begin() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1]
|
||||||
|
|
||||||
|
-- Find the last variable that needs prefixing
|
||||||
|
local var_name, start_col, end_col = find_last_variable_to_prefix(line)
|
||||||
|
|
||||||
|
if var_name then
|
||||||
|
add_prefix(bufnr, row, start_col, var_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto-prefix triggered on dot character
|
||||||
|
local function on_dot_typed()
|
||||||
|
-- Check if auto-prefixing is enabled
|
||||||
|
if not (vim.g.scl_auto_prefix or false) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only in SCL files
|
||||||
|
if vim.bo.filetype ~= "scl" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Don't prefix if inside VAR section
|
||||||
|
if is_in_var_section() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only prefix after BEGIN
|
||||||
|
if not is_after_begin() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local col = vim.api.nvim_win_get_cursor(0)[2]
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1]
|
||||||
|
|
||||||
|
-- Find variable before the dot (cursor is on the dot)
|
||||||
|
local var_name, start_col, _ = find_variable_before_dot(line, col)
|
||||||
|
|
||||||
|
if var_name then
|
||||||
|
add_prefix(bufnr, row, start_col, var_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Setup auto-prefixing on specific triggers
|
||||||
|
function M.setup()
|
||||||
|
local augroup = vim.api.nvim_create_augroup("SCLAutoPrefix", { clear = true })
|
||||||
|
|
||||||
|
-- Enable by default
|
||||||
|
vim.g.scl_auto_prefix = true
|
||||||
|
|
||||||
|
-- Clear cache on text changes
|
||||||
|
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufEnter" }, {
|
||||||
|
group = augroup,
|
||||||
|
callback = function()
|
||||||
|
if vim.bo.filetype == "scl" then
|
||||||
|
clear_cache()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Trigger on InsertLeave
|
||||||
|
vim.api.nvim_create_autocmd("InsertLeave", {
|
||||||
|
group = augroup,
|
||||||
|
callback = function()
|
||||||
|
if vim.bo.filetype == "scl" then
|
||||||
|
auto_prefix_current_line()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Trigger on space character in insert mode
|
||||||
|
vim.api.nvim_create_autocmd("TextChangedI", {
|
||||||
|
group = augroup,
|
||||||
|
callback = function()
|
||||||
|
if vim.bo.filetype == "scl" then
|
||||||
|
local line = vim.api.nvim_get_current_line()
|
||||||
|
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
|
||||||
|
|
||||||
|
if cursor_col > 0 and line:sub(cursor_col, cursor_col) == " " then
|
||||||
|
auto_prefix_current_line()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Trigger on semicolon character in insert mode
|
||||||
|
vim.api.nvim_create_autocmd("TextChangedI", {
|
||||||
|
group = augroup,
|
||||||
|
callback = function()
|
||||||
|
if vim.bo.filetype == "scl" then
|
||||||
|
vim.schedule(function()
|
||||||
|
local line = vim.api.nvim_get_current_line()
|
||||||
|
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
|
||||||
|
local line_len = #line
|
||||||
|
|
||||||
|
if cursor_col > 0 and cursor_col <= line_len and line:sub(cursor_col, cursor_col) == ";" then
|
||||||
|
-- UDT type auto-quoting in VAR section
|
||||||
|
local in_var = is_in_var_section()
|
||||||
|
if in_var then
|
||||||
|
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 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 .. '"')
|
||||||
|
vim.api.nvim_set_current_line(new_before .. ";")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Variable prefixing
|
||||||
|
auto_prefix_current_line()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Trigger on dot character in insert mode
|
||||||
|
vim.api.nvim_create_autocmd("TextChangedI", {
|
||||||
|
group = augroup,
|
||||||
|
callback = function()
|
||||||
|
if vim.bo.filetype == "scl" then
|
||||||
|
local line = vim.api.nvim_get_current_line()
|
||||||
|
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
|
||||||
|
|
||||||
|
-- Check if the character at cursor is a dot
|
||||||
|
if cursor_col > 0 and line:sub(cursor_col, cursor_col) == "." then
|
||||||
|
on_dot_typed()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Manual trigger function
|
||||||
|
function M.prefix_word()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local col = vim.api.nvim_win_get_cursor(0)[2] + 1
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1]
|
||||||
|
|
||||||
|
-- Find word under cursor
|
||||||
|
local start_col = col
|
||||||
|
while start_col > 1 and line:sub(start_col - 1, start_col - 1):match("[%w_]") do
|
||||||
|
start_col = start_col - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local end_col = col - 1
|
||||||
|
while end_col <= #line and line:sub(end_col, end_col):match("[%w_]") do
|
||||||
|
end_col = end_col + 1
|
||||||
|
end
|
||||||
|
end_col = end_col - 1
|
||||||
|
|
||||||
|
local word = line:sub(start_col, end_col)
|
||||||
|
if word == "" or word:sub(1, 1) == "#" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local base_var = get_base_variable(word)
|
||||||
|
if not base_var then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
add_prefix(bufnr, row, start_col, word)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get prefix enabled state
|
||||||
|
function M.is_enabled()
|
||||||
|
return vim.g.scl_auto_prefix or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Toggle prefix enabled state
|
||||||
|
function M.toggle()
|
||||||
|
vim.g.scl_auto_prefix = not (vim.g.scl_auto_prefix or false)
|
||||||
|
local status = vim.g.scl_auto_prefix and "enabled" or "disabled"
|
||||||
|
vim.notify("SCL auto-prefix " .. status, vim.log.levels.INFO)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
-- blink.cmp completion source for SCL
|
||||||
|
-- Provides completion for UDT types, members, and variables
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.new(opts)
|
||||||
|
return setmetatable({ opts = opts or {} }, { __index = M })
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:get_trigger_characters()
|
||||||
|
return { ".", "(", "#", " " }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.is_available(context)
|
||||||
|
local ft = vim.bo[context.bufnr].filetype
|
||||||
|
if ft ~= "scl" and ft ~= "udt" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get current line directly from buffer
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row = cursor[1]
|
||||||
|
local col = cursor[2]
|
||||||
|
local line = vim.api.nvim_buf_get_lines(context.bufnr, row - 1, row, false)[1] or ""
|
||||||
|
|
||||||
|
-- Don't trigger on empty line (entering insert mode with o, i, etc.)
|
||||||
|
if line == "" or line:match("^%s*$") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Trigger on specific characters
|
||||||
|
if col > 0 then
|
||||||
|
local char_at = line:sub(col, col)
|
||||||
|
local prev_char = col > 1 and line:sub(col - 1, col - 1) or ""
|
||||||
|
|
||||||
|
-- Only trigger on our specific characters
|
||||||
|
if char_at == "." or char_at == "(" or char_at == "#" or
|
||||||
|
prev_char == "." or prev_char == "(" or prev_char == "#" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.get_completion_types()
|
||||||
|
return { "syntax", "words", "line", "trigger" }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we're after BEGIN keyword
|
||||||
|
local function is_after_begin(bufnr)
|
||||||
|
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
|
||||||
|
|
||||||
|
for i = cursor_row, 0, -1 do
|
||||||
|
local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ""
|
||||||
|
if line:match("^%s*BEGIN%s*$") or line:match("^%s*BEGIN%s*;") then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if line:match("^%s*ORGANIZATION_BLOCK%s+") or
|
||||||
|
line:match("^%s*FUNCTION_BLOCK%s+") or
|
||||||
|
line:match("^%s*FUNCTION%s+") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we're inside a VAR declaration section
|
||||||
|
local function is_in_var_section(bufnr)
|
||||||
|
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_row + 1, false)
|
||||||
|
local in_var = false
|
||||||
|
for i = 1, cursor_row + 1 do
|
||||||
|
local line = lines[i] or ""
|
||||||
|
if line:match("^%s*VAR") then
|
||||||
|
in_var = true
|
||||||
|
elseif line:match("^%s*END_VAR") then
|
||||||
|
in_var = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return in_var
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all known local variables
|
||||||
|
local function get_known_variables(bufnr)
|
||||||
|
local scl_vars = require("scl.variables")
|
||||||
|
local variables = scl_vars.extract_variables(bufnr)
|
||||||
|
local known = {}
|
||||||
|
for _, v in ipairs(variables) do
|
||||||
|
known[v.name] = true
|
||||||
|
end
|
||||||
|
return known
|
||||||
|
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 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")
|
||||||
|
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 an FB/Function
|
||||||
|
local function is_fb_type(var_type)
|
||||||
|
local fb_parser = require("scl.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")
|
||||||
|
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
|
||||||
|
end
|
||||||
|
|
||||||
|
return fb_parser.is_fb_type(clean_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:get_completions(context, callback)
|
||||||
|
local bufnr = context.bufnr
|
||||||
|
local line = context.line
|
||||||
|
local cursor = context.cursor
|
||||||
|
local col = cursor[2] or cursor[1] or 0
|
||||||
|
|
||||||
|
-- Check context for FB parameter completion
|
||||||
|
-- We want to show FB params when:
|
||||||
|
-- - Just opened paren: fbName(
|
||||||
|
-- - After a comma: fbName(param1 := ,
|
||||||
|
-- - At start of parameter: fbName(param (before :)
|
||||||
|
--
|
||||||
|
-- We DON'T want to show FB params when:
|
||||||
|
-- - After := or => with an existing value: fbName(param1 := myVar
|
||||||
|
-- - After := or => with trailing value: fbName(param1 := value,
|
||||||
|
|
||||||
|
local line_before_cursor = line:sub(1, col)
|
||||||
|
|
||||||
|
-- Check if we're after a comma (ready for next parameter)
|
||||||
|
local is_after_comma = line_before_cursor:match(",%s*$") ~= nil
|
||||||
|
|
||||||
|
-- Check if we're typing a parameter value (after := or => with content)
|
||||||
|
-- This is true if we have ":= something" or "=> something" without a comma after
|
||||||
|
local has_param_assignment = line:match("%w+%s*:=%s*%S") ~= nil or line:match("%w+%s*=>%s*%S") ~= nil
|
||||||
|
|
||||||
|
-- If we have a param assignment but NOT after a comma, we're typing a value
|
||||||
|
local is_after_param_assignment = has_param_assignment and not is_after_comma
|
||||||
|
|
||||||
|
-- Check filetype
|
||||||
|
local ft = vim.bo[bufnr].filetype
|
||||||
|
if ft ~= "scl" and ft ~= "udt" then
|
||||||
|
callback({ items = {} })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local scl_vars = require("scl.variables")
|
||||||
|
local variables = scl_vars.extract_variables(bufnr)
|
||||||
|
|
||||||
|
-- Get all known local variables and their types
|
||||||
|
local known_vars = {}
|
||||||
|
local var_types = {}
|
||||||
|
for _, v in ipairs(variables) do
|
||||||
|
known_vars[v.name] = true
|
||||||
|
var_types[v.name] = v.type
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Skip FB parameter completions if user is typing a parameter value (after := or =>)
|
||||||
|
if not is_after_param_assignment then
|
||||||
|
-- Search for any FB call pattern in the entire line
|
||||||
|
-- This handles cases where completion triggers before full text is typed
|
||||||
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
-- Find all potential FB calls in the line
|
||||||
|
for fb_name in trimmed:gmatch("#?([%w_]+)%s*%(%s*%)?") do
|
||||||
|
if known_vars[fb_name] then
|
||||||
|
local var_type = var_types[fb_name]
|
||||||
|
if var_type then
|
||||||
|
local clean_type = var_type:gsub('"', '')
|
||||||
|
if is_fb_type(clean_type) then
|
||||||
|
local fb_interface = get_fb_interface(clean_type)
|
||||||
|
if fb_interface then
|
||||||
|
local wrapped_callback = vim.schedule_wrap(function(response)
|
||||||
|
callback({
|
||||||
|
items = response.items,
|
||||||
|
is_incomplete_backward = false,
|
||||||
|
is_incomplete_forward = false,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
local items = {}
|
||||||
|
for _, inp in ipairs(fb_interface.inputs or {}) do
|
||||||
|
local doc_value = inp.type
|
||||||
|
if inp.comment and inp.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. inp.comment
|
||||||
|
end
|
||||||
|
if inp.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. inp.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = inp.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inp.type .. " (Input)",
|
||||||
|
insertText = inp.name .. " := ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, inout in ipairs(fb_interface.inouts or {}) do
|
||||||
|
local doc_value = inout.type
|
||||||
|
if inout.comment and inout.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. inout.comment
|
||||||
|
end
|
||||||
|
if inout.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. inout.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = inout.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inout.type .. " (InOut)",
|
||||||
|
insertText = inout.name .. " := ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, out in ipairs(fb_interface.outputs or {}) do
|
||||||
|
local doc_value = out.type
|
||||||
|
if out.comment and out.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. out.comment
|
||||||
|
end
|
||||||
|
if out.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. out.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = out.name .. " =>",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = out.type .. " (Output)",
|
||||||
|
insertText = out.name .. " => ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end -- if not is_after_param_assignment
|
||||||
|
|
||||||
|
-- If no FB call found on current line, search previous lines
|
||||||
|
-- This handles multi-line parameter completion
|
||||||
|
-- Skip if user is typing a parameter value (after := or =>)
|
||||||
|
if not is_after_param_assignment then
|
||||||
|
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
|
||||||
|
local prev_lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_row, false)
|
||||||
|
|
||||||
|
-- Check if any previous line has a closed FB call (ending with ");")
|
||||||
|
-- If so, don't provide completions
|
||||||
|
local has_closed_call = false
|
||||||
|
for i = 1, #prev_lines do
|
||||||
|
if prev_lines[i]:match("%);%s*$") then
|
||||||
|
has_closed_call = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not has_closed_call then
|
||||||
|
for i = #prev_lines, 1, -1 do
|
||||||
|
local prev_line = prev_lines[i]:gsub("%s+$", "")
|
||||||
|
for fb_name in prev_line:gmatch("#?([%w_]+)%s*%(%s*%)?") do
|
||||||
|
if known_vars[fb_name] then
|
||||||
|
local var_type = var_types[fb_name]
|
||||||
|
if var_type then
|
||||||
|
local clean_type = var_type:gsub('"', '')
|
||||||
|
if is_fb_type(clean_type) then
|
||||||
|
local fb_interface = get_fb_interface(clean_type)
|
||||||
|
if fb_interface then
|
||||||
|
local wrapped_callback = vim.schedule_wrap(function(response)
|
||||||
|
callback({
|
||||||
|
items = response.items,
|
||||||
|
is_incomplete_backward = false,
|
||||||
|
is_incomplete_forward = false,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
local items = {}
|
||||||
|
for _, inp in ipairs(fb_interface.inputs or {}) do
|
||||||
|
local doc_value = inp.type
|
||||||
|
if inp.comment and inp.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. inp.comment
|
||||||
|
end
|
||||||
|
if inp.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. inp.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = inp.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inp.type .. " (Input)",
|
||||||
|
insertText = inp.name .. " := ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, inout in ipairs(fb_interface.inouts or {}) do
|
||||||
|
local doc_value = inout.type
|
||||||
|
if inout.comment and inout.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. inout.comment
|
||||||
|
end
|
||||||
|
if inout.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. inout.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = inout.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inout.type .. " (InOut)",
|
||||||
|
insertText = inout.name .. " := ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, out in ipairs(fb_interface.outputs or {}) do
|
||||||
|
local doc_value = out.type
|
||||||
|
if out.comment and out.comment ~= "" then
|
||||||
|
doc_value = doc_value .. "\n\n" .. out.comment
|
||||||
|
end
|
||||||
|
if out.default_value then
|
||||||
|
doc_value = doc_value .. "\n\nDefault: " .. out.default_value
|
||||||
|
end
|
||||||
|
table.insert(items, {
|
||||||
|
label = out.name .. " =>",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = out.type .. " (Output)",
|
||||||
|
insertText = out.name .. " => ",
|
||||||
|
documentation = {
|
||||||
|
kind = "markdown",
|
||||||
|
value = doc_value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local base_var = nil
|
||||||
|
local member_path = nil
|
||||||
|
|
||||||
|
-- Strip leading whitespace for regex matching
|
||||||
|
local trim_line = line:gsub("^%s+", "")
|
||||||
|
|
||||||
|
-- Note: known_vars is already defined above
|
||||||
|
|
||||||
|
-- Function to extract base_var and member_path from a var.member... pattern
|
||||||
|
local function extract_from_pattern(full_text)
|
||||||
|
if not full_text then return nil, nil end
|
||||||
|
|
||||||
|
-- Handle # prefixed
|
||||||
|
local var_pattern = full_text:match("^#([%w_]+)")
|
||||||
|
if var_pattern then
|
||||||
|
local after = full_text:sub(#var_pattern + 2)
|
||||||
|
-- Remove leading dot if present
|
||||||
|
if after:sub(1, 1) == "." then
|
||||||
|
after = after:sub(2)
|
||||||
|
end
|
||||||
|
-- Remove trailing dot if present
|
||||||
|
if after:sub(-1) == "." then
|
||||||
|
after = after:sub(1, -2)
|
||||||
|
end
|
||||||
|
return var_pattern, after
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Non-prefixed - extract first identifier as variable
|
||||||
|
local var_name = full_text:match("^([%w_]+)")
|
||||||
|
if var_name and known_vars[var_name] then
|
||||||
|
local after = full_text:sub(#var_name + 2)
|
||||||
|
-- Remove leading dot if present
|
||||||
|
if after:sub(1, 1) == "." then
|
||||||
|
after = after:sub(2)
|
||||||
|
end
|
||||||
|
-- Remove trailing dot if present
|
||||||
|
if after:sub(-1) == "." then
|
||||||
|
after = after:sub(1, -2)
|
||||||
|
end
|
||||||
|
return var_name, after
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find the LAST occurrence of a member access pattern (handles "AND var.member" cases)
|
||||||
|
-- First try # prefixed patterns - handle both "#var.member" and "#var.member."
|
||||||
|
local search_pos = 1
|
||||||
|
while search_pos <= #trim_line do
|
||||||
|
local hash_pos = trim_line:find("#", search_pos)
|
||||||
|
if not hash_pos then break end
|
||||||
|
|
||||||
|
-- Find the variable name after #
|
||||||
|
local var_end = hash_pos + 1
|
||||||
|
while var_end <= #trim_line and trim_line:sub(var_end, var_end):match("[%w_]") do
|
||||||
|
var_end = var_end + 1
|
||||||
|
end
|
||||||
|
var_end = var_end - 1
|
||||||
|
|
||||||
|
if var_end > hash_pos then
|
||||||
|
local var_name = trim_line:sub(hash_pos + 1, var_end)
|
||||||
|
-- Check if followed by a dot
|
||||||
|
if var_end < #trim_line and trim_line:sub(var_end + 1, var_end + 1) == "." then
|
||||||
|
-- Extract the member path after the dot
|
||||||
|
local path_start = var_end + 2
|
||||||
|
local path_end = path_start - 1
|
||||||
|
while path_end < #trim_line and trim_line:sub(path_end + 1, path_end + 1):match("[%w_.]") do
|
||||||
|
path_end = path_end + 1
|
||||||
|
end
|
||||||
|
local path = trim_line:sub(path_start, path_end)
|
||||||
|
-- Remove trailing dot if present
|
||||||
|
if path:sub(-1) == "." then
|
||||||
|
path = path:sub(1, -2)
|
||||||
|
end
|
||||||
|
base_var = var_name
|
||||||
|
member_path = path
|
||||||
|
elseif var_end == #trim_line or trim_line:sub(var_end + 1, var_end + 1):match("[%s%)]") then
|
||||||
|
-- Variable at end of line or followed by space/paren
|
||||||
|
base_var = var_name
|
||||||
|
member_path = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
search_pos = hash_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Then try non-prefixed patterns - search backwards to find LAST occurrence
|
||||||
|
if not base_var then
|
||||||
|
local search_pos = 1
|
||||||
|
while search_pos <= #trim_line do
|
||||||
|
-- Find word boundaries for potential variable names
|
||||||
|
local word_start, word_end = trim_line:find("([%w_]+)", search_pos)
|
||||||
|
if not word_start then break end
|
||||||
|
|
||||||
|
local var_name = trim_line:sub(word_start, word_end)
|
||||||
|
if known_vars[var_name] then
|
||||||
|
-- Check if followed by a dot
|
||||||
|
if word_end < #trim_line and trim_line:sub(word_end + 1, word_end + 1) == "." then
|
||||||
|
local path_start = word_end + 2
|
||||||
|
local path_end = word_end + 1
|
||||||
|
while path_end < #trim_line and trim_line:sub(path_end + 1, path_end + 1):match("[%w_.]") do
|
||||||
|
path_end = path_end + 1
|
||||||
|
end
|
||||||
|
local path = trim_line:sub(path_start, path_end)
|
||||||
|
if path:sub(-1) == "." then
|
||||||
|
path = path:sub(1, -2)
|
||||||
|
end
|
||||||
|
base_var = var_name
|
||||||
|
member_path = path
|
||||||
|
elseif word_end == #trim_line then
|
||||||
|
base_var = var_name
|
||||||
|
member_path = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
search_pos = word_end + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fallback: just var at end of line or var followed by ( or ()
|
||||||
|
if not base_var then
|
||||||
|
local var_name = trim_line:match("([%w_]+)%s*%(%)$")
|
||||||
|
if var_name and known_vars[var_name] then
|
||||||
|
base_var = var_name
|
||||||
|
member_path = ""
|
||||||
|
else
|
||||||
|
var_name = trim_line:match("([%w_]+)%s*%($")
|
||||||
|
if var_name and known_vars[var_name] then
|
||||||
|
base_var = var_name
|
||||||
|
member_path = ""
|
||||||
|
else
|
||||||
|
var_name = trim_line:match("([%w_]+)%s*$")
|
||||||
|
if var_name and known_vars[var_name] then
|
||||||
|
base_var = var_name
|
||||||
|
member_path = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Also check for #varName() pattern (after auto-bracket completion)
|
||||||
|
if not base_var then
|
||||||
|
local hash_var = trim_line:match("#([%w_]+)%s*%(%)$")
|
||||||
|
if hash_var and known_vars[hash_var] then
|
||||||
|
base_var = hash_var
|
||||||
|
member_path = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Also check for #varName( pattern
|
||||||
|
if not base_var then
|
||||||
|
local hash_var = trim_line:match("#([%w_]+)%s*%($")
|
||||||
|
if hash_var and known_vars[hash_var] then
|
||||||
|
base_var = hash_var
|
||||||
|
member_path = ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local wrapped_callback = vim.schedule_wrap(function(response)
|
||||||
|
callback({
|
||||||
|
items = response.items,
|
||||||
|
is_incomplete_backward = false,
|
||||||
|
is_incomplete_forward = false,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.schedule(function()
|
||||||
|
local udt_parser = require("scl.udt_parser")
|
||||||
|
|
||||||
|
-- No member access = show local variables, UDT types, and basic types
|
||||||
|
if not base_var then
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
-- Add local variables from VAR sections
|
||||||
|
local in_var_section = is_in_var_section(bufnr)
|
||||||
|
for _, v in ipairs(variables) do
|
||||||
|
if in_var_section then
|
||||||
|
table.insert(items, {
|
||||||
|
label = v.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = v.type and (v.type .. " - Local variable") or "Local variable",
|
||||||
|
})
|
||||||
|
else
|
||||||
|
table.insert(items, {
|
||||||
|
label = "#" .. v.name,
|
||||||
|
insertText = "#" .. v.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = v.type and (v.type .. " - Local variable") or "Local variable",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add UDT types
|
||||||
|
for _, name in ipairs(udt_parser.get_all_udt_names()) do
|
||||||
|
table.insert(items, { label = '"' .. name .. '"', kind = 14 })
|
||||||
|
end
|
||||||
|
-- Add basic types
|
||||||
|
for _, t in ipairs({ "Bool", "Int", "DInt", "Real", "Word", "DWord", "Byte", "Time", "String", "SINT", "USINT", "UINT", "LREAL" }) do
|
||||||
|
table.insert(items, { label = t, kind = 19 })
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Member completion
|
||||||
|
local var_type = scl_vars.get_variable_type(base_var, bufnr)
|
||||||
|
-- If base_var is not a known variable, show all local variables instead
|
||||||
|
if not var_type then
|
||||||
|
local items = {}
|
||||||
|
local in_var_section = is_in_var_section(bufnr)
|
||||||
|
for _, v in ipairs(variables) do
|
||||||
|
if in_var_section then
|
||||||
|
table.insert(items, {
|
||||||
|
label = v.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = v.type and (v.type .. " - Local variable") or "Local variable",
|
||||||
|
})
|
||||||
|
else
|
||||||
|
table.insert(items, {
|
||||||
|
label = "#" .. v.name,
|
||||||
|
insertText = "#" .. v.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = v.type and (v.type .. " - Local variable") or "Local variable",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, name in ipairs(udt_parser.get_all_udt_names()) do
|
||||||
|
table.insert(items, { label = '"' .. name .. '"', kind = 14 })
|
||||||
|
end
|
||||||
|
for _, t in ipairs({ "Bool", "Int", "DInt", "Real", "Word", "DWord", "Byte", "Time", "String", "SINT", "USINT", "UINT", "LREAL" }) do
|
||||||
|
table.insert(items, { label = t, kind = 19 })
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
var_type = var_type:gsub('"', '')
|
||||||
|
|
||||||
|
local is_udt = udt_parser.is_udt_type(var_type)
|
||||||
|
local is_fb = is_fb_type(var_type)
|
||||||
|
local fb_interface = nil
|
||||||
|
|
||||||
|
if is_fb then
|
||||||
|
fb_interface = get_fb_interface(var_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not is_udt and not is_fb then
|
||||||
|
wrapped_callback({ items = {} })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle FB parameter completion (when user types instanceName() or instanceName(
|
||||||
|
local line_before_cursor = line:sub(1, col)
|
||||||
|
local is_fb_call = line_before_cursor:match("%(#?[%w_]+%s*%(%s*$") ~= nil or
|
||||||
|
line_before_cursor:match("%(#?[%w_]+%s*%)%s*$") ~= nil
|
||||||
|
|
||||||
|
-- Check if there's a ( somewhere after the variable (parameter call mode)
|
||||||
|
local has_param_paren = line:match("%(") ~= nil
|
||||||
|
|
||||||
|
if is_fb and fb_interface and (is_fb_call or (member_path == "" and has_param_paren)) then
|
||||||
|
local items = {}
|
||||||
|
for _, inp in ipairs(fb_interface.inputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inp.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inp.type .. " (Input)",
|
||||||
|
insertText = inp.name .. " := ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, inout in ipairs(fb_interface.inouts or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inout.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inout.type .. " (InOut)",
|
||||||
|
insertText = inout.name .. " := ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, out in ipairs(fb_interface.outputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = out.name .. " =>",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = out.type .. " (Output)",
|
||||||
|
insertText = out.name .. " => ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fallback: if base_var is an FB and we're after BEGIN (in code section), provide parameter completions
|
||||||
|
if is_fb and fb_interface and member_path == "" and is_after_begin(bufnr) then
|
||||||
|
local items = {}
|
||||||
|
for _, inp in ipairs(fb_interface.inputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inp.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inp.type .. " (Input)",
|
||||||
|
insertText = inp.name .. " := ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, inout in ipairs(fb_interface.inouts or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inout.name .. " :=",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = inout.type .. " (InOut)",
|
||||||
|
insertText = inout.name .. " := ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, out in ipairs(fb_interface.outputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = out.name .. " =>",
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Property,
|
||||||
|
detail = out.type .. " (Output)",
|
||||||
|
insertText = out.name .. " => ",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If it's an FB but we're accessing members with dot, treat as UDT-like completion
|
||||||
|
if is_fb and fb_interface and member_path == "" then
|
||||||
|
local items = {}
|
||||||
|
for _, inp in ipairs(fb_interface.inputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inp.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Field,
|
||||||
|
detail = inp.type .. " (Input)",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, inout in ipairs(fb_interface.inouts or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = inout.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Field,
|
||||||
|
detail = inout.type .. " (InOut)",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
for _, out in ipairs(fb_interface.outputs or {}) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = out.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Field,
|
||||||
|
detail = out.type .. " (Output)",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not is_udt then
|
||||||
|
wrapped_callback({ items = {} })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve nested path - iterate through each part to find the target type
|
||||||
|
local target_type = var_type
|
||||||
|
if member_path and member_path ~= "" then
|
||||||
|
local line_ends_with_dot = trim_line:match("%.$") ~= nil
|
||||||
|
|
||||||
|
local path_parts = {}
|
||||||
|
for part in member_path:gmatch("[^.]+") do
|
||||||
|
table.insert(path_parts, part)
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_type = var_type
|
||||||
|
local resolved_parts = 0
|
||||||
|
|
||||||
|
for i, part in ipairs(path_parts) do
|
||||||
|
local udt = udt_parser.get_udt(current_type)
|
||||||
|
if not udt then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
local found = false
|
||||||
|
for _, member in ipairs(udt.members) do
|
||||||
|
if member.name == part then
|
||||||
|
resolved_parts = i
|
||||||
|
if member.is_udt then
|
||||||
|
current_type = member.type
|
||||||
|
found = true
|
||||||
|
else
|
||||||
|
-- Not a UDT - if this is the last part, we found the target type
|
||||||
|
-- Otherwise, we can't continue the path
|
||||||
|
if i < #path_parts then
|
||||||
|
found = false
|
||||||
|
else
|
||||||
|
found = true
|
||||||
|
current_type = member.type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if line_ends_with_dot or resolved_parts == #path_parts then
|
||||||
|
target_type = current_type
|
||||||
|
else
|
||||||
|
wrapped_callback({ items = {} })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local members = udt_parser.get_udt_members(target_type)
|
||||||
|
local items = {}
|
||||||
|
for _, m in ipairs(members) do
|
||||||
|
table.insert(items, {
|
||||||
|
label = m.name,
|
||||||
|
kind = m.is_udt and vim.lsp.protocol.CompletionItemKind.Struct or vim.lsp.protocol.CompletionItemKind.Field,
|
||||||
|
detail = m.raw_type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
wrapped_callback({ items = items })
|
||||||
|
end)
|
||||||
|
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve callback to add # prefix for variables
|
||||||
|
function M:resolve(context, callback)
|
||||||
|
local item = context.item
|
||||||
|
local bufnr = context.bufnr
|
||||||
|
|
||||||
|
-- Check if we should add # prefix
|
||||||
|
local should_prefix = false
|
||||||
|
if item and item.label then
|
||||||
|
-- Only add # for variable kinds
|
||||||
|
local var_kinds = {
|
||||||
|
vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
vim.lsp.protocol.CompletionItemKind.Parameter,
|
||||||
|
}
|
||||||
|
|
||||||
|
local is_var = false
|
||||||
|
for _, kind in ipairs(var_kinds) do
|
||||||
|
if item.kind == kind then
|
||||||
|
is_var = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if is_var then
|
||||||
|
-- Check if we're after BEGIN and not in VAR section
|
||||||
|
if is_after_begin(bufnr) and not is_in_var_section(bufnr) then
|
||||||
|
-- Check if variable is in known variables list
|
||||||
|
local known_vars = get_known_variables(bufnr)
|
||||||
|
local var_name = item.label:gsub("^#", "")
|
||||||
|
if known_vars[var_name] and not item.label:match("^#") then
|
||||||
|
should_prefix = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if should_prefix then
|
||||||
|
item.label = "#" .. item.label
|
||||||
|
item.insertText = "#" .. (item.insertText or item.label)
|
||||||
|
if item.textEdit and item.textEdit.newText then
|
||||||
|
item.textEdit.newText = "#" .. item.textEdit.newText
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
callback({ item = item })
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
-- FB/Function Parser for SCL
|
||||||
|
-- Parses FUNCTION_BLOCK and FUNCTION definitions from .scl files
|
||||||
|
-- Extracts VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT sections with parameter details
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Cache for parsed FBs/Functions: { [fb_name] = fb_definition }
|
||||||
|
local fb_cache = {}
|
||||||
|
|
||||||
|
-- FB/Function Definition structure:
|
||||||
|
-- {
|
||||||
|
-- name = "LLT_CmMotorStarter_1",
|
||||||
|
-- type = "FUNCTION_BLOCK", -- or "FUNCTION"
|
||||||
|
-- filename = "path/to/file.scl",
|
||||||
|
-- inputs = {
|
||||||
|
-- { name = "enable", type = "Bool", comment = "TRUE: Enable...", has_default = false }
|
||||||
|
-- },
|
||||||
|
-- outputs = { ... },
|
||||||
|
-- inouts = { ... }
|
||||||
|
-- }
|
||||||
|
|
||||||
|
-- Clear the FB cache
|
||||||
|
function M.clear_cache()
|
||||||
|
for k in pairs(fb_cache) do
|
||||||
|
fb_cache[k] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get cached FB/Function
|
||||||
|
function M.get_fb(fb_name)
|
||||||
|
return fb_cache[fb_name]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if a name is a known FB/Function type
|
||||||
|
function M.is_fb_type(fb_name)
|
||||||
|
if not fb_name then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return fb_cache[fb_name] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get FB interface (inputs, outputs, inouts)
|
||||||
|
function M.get_fb_interface(fb_name)
|
||||||
|
local fb = fb_cache[fb_name]
|
||||||
|
if not fb then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
name = fb.name,
|
||||||
|
type = fb.type,
|
||||||
|
inputs = fb.inputs or {},
|
||||||
|
outputs = fb.outputs or {},
|
||||||
|
inouts = fb.inouts or {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse attributes from a string like: { S7_SetPoint := 'True'; ExternalAccessible := 'False' }
|
||||||
|
local function parse_attributes(attr_str)
|
||||||
|
if not attr_str or attr_str == "" then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local attrs = {}
|
||||||
|
-- Match key := 'value' or key := value patterns
|
||||||
|
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
|
||||||
|
attrs[key] = value
|
||||||
|
end
|
||||||
|
return attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a variable declaration line
|
||||||
|
-- Returns: name, type, has_default, comment
|
||||||
|
local function parse_var_declaration(line)
|
||||||
|
-- Remove leading/trailing whitespace
|
||||||
|
line = line:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Skip empty lines and pure comments
|
||||||
|
if line == "" or line:match("^//") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extract trailing comments (both // and (* *) style)
|
||||||
|
local comment = line:match("//%s*(.+)$") or ""
|
||||||
|
local decl_line = line:gsub("//.*$", "", 1):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Handle (* *) comments
|
||||||
|
if decl_line:match("%(%*.-%*%)") then
|
||||||
|
comment = decl_line:match("%(%*(.-)%*%)") or comment
|
||||||
|
decl_line = decl_line:gsub("%(%*.-%*%)", ""):gsub("%s*$", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern: name {attrs} : type [:= default];
|
||||||
|
-- Pattern: name : type [:= default];
|
||||||
|
local name, attrs_str, type_str, default_val =
|
||||||
|
decl_line:match('^([%w_]+)%s*(%b{})%s*:%s*([^;]-)%s*:=%s*([^;]+);?$')
|
||||||
|
|
||||||
|
if not name then
|
||||||
|
-- Try without default value
|
||||||
|
name, attrs_str, type_str =
|
||||||
|
decl_line:match('^([%w_]+)%s*(%b{})%s*:%s*([^;]+);?$')
|
||||||
|
end
|
||||||
|
|
||||||
|
if not name then
|
||||||
|
-- Try without attributes
|
||||||
|
name, type_str, default_val =
|
||||||
|
decl_line:match('^([%w_]+)%s*:%s*([^;]-)%s*:=%s*([^;]+);?$')
|
||||||
|
end
|
||||||
|
|
||||||
|
if not name then
|
||||||
|
-- Simplest form: name : type;
|
||||||
|
name, type_str = decl_line:match('^([%w_]+)%s*:%s*([^;]+);?$')
|
||||||
|
end
|
||||||
|
|
||||||
|
if not name or not type_str then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clean up type string
|
||||||
|
type_str = type_str:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Check if type is a quoted FB/UDT reference
|
||||||
|
local is_quoted = type_str:match('^".+"$') ~= nil
|
||||||
|
local clean_type = type_str:gsub('"', '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
name = name,
|
||||||
|
type = clean_type,
|
||||||
|
raw_type = type_str,
|
||||||
|
has_default = default_val ~= nil,
|
||||||
|
default_value = default_val,
|
||||||
|
comment = comment,
|
||||||
|
attributes = attrs_str and parse_attributes(attrs_str) or {},
|
||||||
|
is_quoted_type = is_quoted,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a VAR section (VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, etc.)
|
||||||
|
-- Returns array of variable definitions
|
||||||
|
local function parse_var_section(content, section_name)
|
||||||
|
local vars = {}
|
||||||
|
|
||||||
|
-- Find the section - match from section_name to END_VAR
|
||||||
|
local section_start = content:find(section_name, 1, true)
|
||||||
|
if not section_start then
|
||||||
|
return vars
|
||||||
|
end
|
||||||
|
|
||||||
|
local end_pos = content:find("END_VAR", section_start, true)
|
||||||
|
if not end_pos then
|
||||||
|
return vars
|
||||||
|
end
|
||||||
|
|
||||||
|
local section_content = content:sub(section_start + #section_name, end_pos - 1)
|
||||||
|
|
||||||
|
-- Parse each line in the section
|
||||||
|
for line in section_content:gmatch("[^\r\n]+") do
|
||||||
|
local var = parse_var_declaration(line)
|
||||||
|
if var then
|
||||||
|
table.insert(vars, var)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return vars
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse FB/Function content
|
||||||
|
function M.parse_fb_content(content, filename)
|
||||||
|
local fb = {
|
||||||
|
filename = filename,
|
||||||
|
inputs = {},
|
||||||
|
outputs = {},
|
||||||
|
inouts = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Determine type and name
|
||||||
|
local fb_type, fb_name
|
||||||
|
|
||||||
|
-- Try FUNCTION_BLOCK
|
||||||
|
fb_name = content:match('FUNCTION_BLOCK%s+"([^"]+)"')
|
||||||
|
if fb_name then
|
||||||
|
fb_type = "FUNCTION_BLOCK"
|
||||||
|
else
|
||||||
|
-- Try FUNCTION (with optional return type)
|
||||||
|
fb_name = content:match('FUNCTION%s+"([^"]+)"')
|
||||||
|
if fb_name then
|
||||||
|
fb_type = "FUNCTION"
|
||||||
|
-- Extract return type if present
|
||||||
|
fb.return_type = content:match('FUNCTION%s+"[^"]+"%s*:%s*(%w+)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not fb_name then
|
||||||
|
return nil, "No FUNCTION_BLOCK or FUNCTION definition found"
|
||||||
|
end
|
||||||
|
|
||||||
|
fb.name = fb_name
|
||||||
|
fb.type = fb_type
|
||||||
|
|
||||||
|
-- Extract TITLE
|
||||||
|
fb.title = content:match('TITLE%s*=%s*([^\n]+)') or ""
|
||||||
|
fb.title = fb.title:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Extract VERSION
|
||||||
|
fb.version = content:match('VERSION%s*:%s*([0-9.]+)') or "0.1"
|
||||||
|
|
||||||
|
-- Parse VAR sections
|
||||||
|
fb.inputs = parse_var_section(content, "VAR_INPUT")
|
||||||
|
fb.outputs = parse_var_section(content, "VAR_OUTPUT")
|
||||||
|
fb.inouts = parse_var_section(content, "VAR_IN_OUT")
|
||||||
|
|
||||||
|
-- VAR section (static variables) - not typically needed for completion
|
||||||
|
-- but could be useful for other features
|
||||||
|
fb.vars = parse_var_section(content, "VAR%s")
|
||||||
|
|
||||||
|
return fb
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a .scl file
|
||||||
|
function M.parse_fb_file(filepath)
|
||||||
|
local file = io.open(filepath, "r")
|
||||||
|
if not file then
|
||||||
|
return nil, "Cannot open file: " .. filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = file:read("*all")
|
||||||
|
file:close()
|
||||||
|
|
||||||
|
-- Check if this is an FB or Function file
|
||||||
|
if not content:match("FUNCTION_BLOCK") and not content:match("FUNCTION") then
|
||||||
|
return nil, "Not a FUNCTION_BLOCK or FUNCTION file"
|
||||||
|
end
|
||||||
|
|
||||||
|
local fb, err = M.parse_fb_content(content, filepath)
|
||||||
|
if not fb then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cache the FB/Function
|
||||||
|
fb_cache[fb.name] = fb
|
||||||
|
|
||||||
|
return fb
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all cached FB/Function names
|
||||||
|
function M.get_all_fb_names()
|
||||||
|
local names = {}
|
||||||
|
for name, _ in pairs(fb_cache) do
|
||||||
|
table.insert(names, name)
|
||||||
|
end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get count of cached FBs
|
||||||
|
function M.get_cache_count()
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(fb_cache) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Debug: Print cache contents
|
||||||
|
function M.debug_print_cache()
|
||||||
|
print("=== FB/Function Cache Contents ===")
|
||||||
|
print("Total FBs/Functions: " .. M.get_cache_count())
|
||||||
|
for name, fb in pairs(fb_cache) do
|
||||||
|
print(string.format("%s: %s (v%s)", fb.type, name, fb.version))
|
||||||
|
print(string.format(" Title: %s", fb.title))
|
||||||
|
print(string.format(" File: %s", fb.filename or "unknown"))
|
||||||
|
print(string.format(" Inputs: %d", #fb.inputs))
|
||||||
|
for _, input in ipairs(fb.inputs) do
|
||||||
|
print(string.format(" → %s: %s", input.name, input.raw_type))
|
||||||
|
end
|
||||||
|
print(string.format(" InOuts: %d", #fb.inouts))
|
||||||
|
for _, inout in ipairs(fb.inouts) do
|
||||||
|
print(string.format(" ↔ %s: %s", inout.name, inout.raw_type))
|
||||||
|
end
|
||||||
|
print(string.format(" Outputs: %d", #fb.outputs))
|
||||||
|
for _, output in ipairs(fb.outputs) do
|
||||||
|
print(string.format(" ← %s: %s", output.name, output.raw_type))
|
||||||
|
end
|
||||||
|
print("")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- SCL (Siemens Structured Control Language) support for Neovim
|
||||||
|
-- Main setup module
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.setup(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
|
||||||
|
M.create_commands()
|
||||||
|
|
||||||
|
-- Treesitter parser config
|
||||||
|
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 = {
|
||||||
|
files = { "src/parser.c" },
|
||||||
|
generate_requires_npm = false,
|
||||||
|
requires_generate_from_grammar = false,
|
||||||
|
},
|
||||||
|
filetype = "scl",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
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()
|
||||||
|
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 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 ws = require("scl.workspace_types")
|
||||||
|
local udt = require("scl.udt_parser")
|
||||||
|
local fb = require("scl.fb_parser")
|
||||||
|
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, {})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
-- UDT Parser for SCL
|
||||||
|
-- Parses .udt files to extract type definitions and member structures
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Cache for parsed UDTs: { [type_name] = udt_definition }
|
||||||
|
local udt_cache = {}
|
||||||
|
|
||||||
|
-- Inline UDT definitions (from comments): { [type_name] = udt_definition }
|
||||||
|
local inline_udts = {}
|
||||||
|
|
||||||
|
-- UDT Definition structure:
|
||||||
|
-- {
|
||||||
|
-- name = "TypeName",
|
||||||
|
-- title = "Type 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 UDT definition (from comments or other sources)
|
||||||
|
function M.register_inline_udt(udt)
|
||||||
|
if udt and udt.name then
|
||||||
|
inline_udts[udt.name] = udt
|
||||||
|
-- Also add to main cache for convenience
|
||||||
|
udt_cache[udt.name] = udt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse an inline UDT definition from a comment string
|
||||||
|
-- Format: // @udt TypeName
|
||||||
|
-- // members:
|
||||||
|
-- // member1 : INT;
|
||||||
|
-- // member2 : BOOL;
|
||||||
|
function M.parse_inline_udt_comment(comment_lines)
|
||||||
|
local type_name = nil
|
||||||
|
local members = {}
|
||||||
|
|
||||||
|
-- Look for @udt tag
|
||||||
|
for _, line in ipairs(comment_lines) do
|
||||||
|
local name = line:match("@udt%s+(%w+)")
|
||||||
|
if name then
|
||||||
|
type_name = name
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not type_name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse member lines (lines that look like declarations)
|
||||||
|
for _, line in ipairs(comment_lines) do
|
||||||
|
local name, type_str = line:match("^%s*([%w_]+)%s*:%s*([^;]+)")
|
||||||
|
if name and type_str then
|
||||||
|
type_str = type_str:gsub("%s*:=.*$", ""):gsub("%s*$", "")
|
||||||
|
local is_udt = type_str:match('^".+"$') ~= nil
|
||||||
|
local clean_type = type_str:gsub('"', '')
|
||||||
|
|
||||||
|
table.insert(members, {
|
||||||
|
name = name,
|
||||||
|
type = clean_type,
|
||||||
|
raw_type = type_str,
|
||||||
|
attributes = {},
|
||||||
|
comment = "",
|
||||||
|
is_udt = is_udt
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #members == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
name = type_name,
|
||||||
|
title = "Inline UDT",
|
||||||
|
version = "0.1",
|
||||||
|
comment = "",
|
||||||
|
members = members
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clear the UDT cache - clear contents, don't replace table
|
||||||
|
function M.clear_cache()
|
||||||
|
for k in pairs(udt_cache) do
|
||||||
|
udt_cache[k] = nil
|
||||||
|
end
|
||||||
|
for k in pairs(inline_udts) do
|
||||||
|
inline_udts[k] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get cached UDT
|
||||||
|
function M.get_udt(type_name)
|
||||||
|
return udt_cache[type_name]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all cached UDT names
|
||||||
|
function M.get_all_udt_names()
|
||||||
|
local names = {}
|
||||||
|
for name, _ in pairs(udt_cache) do
|
||||||
|
table.insert(names, name)
|
||||||
|
end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get count of cached UDTs
|
||||||
|
function M.get_cache_count()
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(udt_cache) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if a type is a UDT (exists in cache or inline)
|
||||||
|
function M.is_udt_type(type_name)
|
||||||
|
if not type_name then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- Remove quotes if present
|
||||||
|
local clean_name = type_name:gsub('"', '')
|
||||||
|
return udt_cache[clean_name] ~= nil or inline_udts[clean_name] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse attributes from a string like: { S7_SetPoint := 'True'; ExternalAccessible := 'False' }
|
||||||
|
local function parse_attributes(attr_str)
|
||||||
|
if not attr_str or attr_str == "" then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local attrs = {}
|
||||||
|
-- Match key := 'value' or key := value patterns
|
||||||
|
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
|
||||||
|
attrs[key] = value
|
||||||
|
end
|
||||||
|
return attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a single .udt file content
|
||||||
|
function M.parse_udt_content(content, filename)
|
||||||
|
local udt = {
|
||||||
|
filename = filename,
|
||||||
|
members = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Extract TYPE name
|
||||||
|
local type_name = content:match('TYPE%s+"([^"]+)"')
|
||||||
|
if not type_name then
|
||||||
|
return nil, "No TYPE definition found"
|
||||||
|
end
|
||||||
|
udt.name = type_name
|
||||||
|
|
||||||
|
-- Extract TITLE
|
||||||
|
udt.title = content:match('TITLE%s*=%s*([^\n]+)') or ""
|
||||||
|
udt.title = udt.title:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Extract VERSION
|
||||||
|
udt.version = content:match('VERSION%s*:%s*([0-9.]+)') or "0.1"
|
||||||
|
|
||||||
|
-- Extract comment after VERSION (lines starting with // before STRUCT)
|
||||||
|
local comment_lines = {}
|
||||||
|
local in_header = true
|
||||||
|
for line in content:gmatch("[^\r\n]+") do
|
||||||
|
if in_header then
|
||||||
|
local comment = line:match("^%s*//%s*(.+)$")
|
||||||
|
if comment then
|
||||||
|
table.insert(comment_lines, comment)
|
||||||
|
elseif line:match("STRUCT") then
|
||||||
|
in_header = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
udt.comment = table.concat(comment_lines, " ")
|
||||||
|
|
||||||
|
-- Extract STRUCT content (between STRUCT and END_STRUCT)
|
||||||
|
local struct_content = content:match("STRUCT%s*(.-)%s*END_STRUCT")
|
||||||
|
if not struct_content then
|
||||||
|
return nil, "No STRUCT definition found"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse members
|
||||||
|
-- Pattern: memberName {attributes} : Type; // comment
|
||||||
|
-- or: memberName : "UdtType"; // comment
|
||||||
|
-- or: memberName : Type := default; // comment
|
||||||
|
for line in struct_content:gmatch("[^\r\n]+") do
|
||||||
|
line = line:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Skip empty lines and pure comments
|
||||||
|
if line ~= "" and not line:match("^//") then
|
||||||
|
-- Extract trailing comment
|
||||||
|
local comment = line:match("//%s*(.+)$") or ""
|
||||||
|
line = line:gsub("//.*$", ""):gsub("%s*$", "")
|
||||||
|
|
||||||
|
-- Parse member declaration
|
||||||
|
-- Pattern: name {attrs} : type [:= default];
|
||||||
|
local name, attrs_str, type_str = line:match('^([%w_]+)%s*(%b{})%s*:%s*([^;]+)')
|
||||||
|
if not name then
|
||||||
|
-- Try without attributes
|
||||||
|
name, type_str = line:match('^([%w_]+)%s*:%s*([^;]+)')
|
||||||
|
end
|
||||||
|
|
||||||
|
if name and type_str then
|
||||||
|
type_str = type_str:gsub("%s*:=.*$", ""):gsub("%s*$", "") -- Remove default value
|
||||||
|
|
||||||
|
local is_udt = type_str:match('^".+"$') ~= nil
|
||||||
|
local clean_type = type_str:gsub('"', '')
|
||||||
|
|
||||||
|
local member = {
|
||||||
|
name = name,
|
||||||
|
type = clean_type,
|
||||||
|
raw_type = type_str,
|
||||||
|
attributes = attrs_str and parse_attributes(attrs_str) or {},
|
||||||
|
comment = comment,
|
||||||
|
is_udt = is_udt
|
||||||
|
}
|
||||||
|
|
||||||
|
table.insert(udt.members, member)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return udt
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse a .udt file
|
||||||
|
function M.parse_udt_file(filepath)
|
||||||
|
local file = io.open(filepath, "r")
|
||||||
|
if not file then
|
||||||
|
return nil, "Cannot open file: " .. filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = file:read("*all")
|
||||||
|
file:close()
|
||||||
|
|
||||||
|
local udt, err = M.parse_udt_content(content, filepath)
|
||||||
|
if not udt then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cache the UDT
|
||||||
|
udt_cache[udt.name] = udt
|
||||||
|
|
||||||
|
return udt
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get members of a UDT (with optional nesting)
|
||||||
|
function M.get_udt_members(type_name, visited)
|
||||||
|
visited = visited or {}
|
||||||
|
|
||||||
|
if not type_name then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove quotes if present
|
||||||
|
type_name = type_name:gsub('"', '')
|
||||||
|
|
||||||
|
-- Prevent infinite recursion
|
||||||
|
if visited[type_name] then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
visited[type_name] = true
|
||||||
|
|
||||||
|
local udt = udt_cache[type_name]
|
||||||
|
if not udt then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local members = {}
|
||||||
|
for _, member in ipairs(udt.members) do
|
||||||
|
table.insert(members, member)
|
||||||
|
|
||||||
|
-- If member is a UDT, include its members as nested structure
|
||||||
|
if member.is_udt and member.type then
|
||||||
|
member.nested_members = M.get_udt_members(member.type, visited)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return members
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve a member path like "member1.member2.member3"
|
||||||
|
-- Returns: member_info, remaining_path
|
||||||
|
function M.resolve_member_path(type_name, path)
|
||||||
|
if not type_name or not path then
|
||||||
|
return nil, path or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
for part in path:gmatch("[^.]+") do
|
||||||
|
table.insert(parts, part)
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_type = type_name:gsub('"', '')
|
||||||
|
local current_member = nil
|
||||||
|
|
||||||
|
for i, part in ipairs(parts) do
|
||||||
|
local udt = udt_cache[current_type]
|
||||||
|
if not udt then
|
||||||
|
return nil, table.concat(parts, ".", i)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find member
|
||||||
|
local found = false
|
||||||
|
for _, member in ipairs(udt.members) do
|
||||||
|
if member.name == part then
|
||||||
|
current_member = member
|
||||||
|
if member.is_udt then
|
||||||
|
current_type = member.type
|
||||||
|
else
|
||||||
|
-- Not a UDT, can't continue
|
||||||
|
if i < #parts then
|
||||||
|
return nil, table.concat(parts, ".", i + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return nil, table.concat(parts, ".", i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return current_member, ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get completion items for UDT members
|
||||||
|
function M.get_member_completions(type_name, prefix)
|
||||||
|
local members = M.get_udt_members(type_name)
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
for _, member in ipairs(members) do
|
||||||
|
-- Label shows the full path for display
|
||||||
|
local label = prefix and (prefix .. "." .. member.name) or member.name
|
||||||
|
-- But we only insert the member name (nvim-cmp will handle replacing the partial word)
|
||||||
|
local item = {
|
||||||
|
label = label,
|
||||||
|
insertText = member.name,
|
||||||
|
kind = member.is_udt and vim.lsp.protocol.CompletionItemKind.Struct or vim.lsp.protocol.CompletionItemKind.Field,
|
||||||
|
detail = member.raw_type .. (member.comment ~= "" and " - " .. member.comment or ""),
|
||||||
|
documentation = member.comment,
|
||||||
|
}
|
||||||
|
table.insert(items, item)
|
||||||
|
end
|
||||||
|
|
||||||
|
return items
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get completion items for available UDT types
|
||||||
|
function M.get_udt_type_completions(params)
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
for name, udt in pairs(udt_cache) do
|
||||||
|
local quoted_name = '"' .. name .. '"'
|
||||||
|
local item = {
|
||||||
|
label = quoted_name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Struct,
|
||||||
|
detail = udt.title ~= "" and udt.title or "UDT",
|
||||||
|
documentation = udt.comment ~= "" and udt.comment or nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Use textEdit for better compatibility with nvim-cmp
|
||||||
|
if params then
|
||||||
|
local line = params.context.cursor_before_line
|
||||||
|
local col = params.context.cursor.col
|
||||||
|
|
||||||
|
-- Find position to insert (after the opening quote)
|
||||||
|
local start_col = col
|
||||||
|
-- Search backwards for opening quote or colon
|
||||||
|
for i = col, 1, -1 do
|
||||||
|
local char = line:sub(i, i)
|
||||||
|
if char == '"' then
|
||||||
|
start_col = i - 1
|
||||||
|
break
|
||||||
|
elseif char == ":" then
|
||||||
|
-- After colon, we want to replace from opening quote
|
||||||
|
start_col = col - 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
item.textEdit = {
|
||||||
|
newText = quoted_name,
|
||||||
|
range = {
|
||||||
|
start = { line = params.context.cursor.line, character = start_col },
|
||||||
|
["end"] = { line = params.context.cursor.line, character = col },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else
|
||||||
|
-- Fallback to insertText if params not available
|
||||||
|
item.insertText = quoted_name
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(items, item)
|
||||||
|
end
|
||||||
|
|
||||||
|
return items
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Scan buffer for inline UDT definitions (comments with @udt tag)
|
||||||
|
function M.scan_buffer_for_inline_udts(bufnr)
|
||||||
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
|
||||||
|
local current_comment_block = {}
|
||||||
|
local in_scl_file = vim.bo[bufnr].filetype == "scl"
|
||||||
|
|
||||||
|
if not in_scl_file then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
if line:match("^%s*//") then
|
||||||
|
-- It's a comment line
|
||||||
|
local content = line:gsub("^%s*//%s*", "")
|
||||||
|
table.insert(current_comment_block, content)
|
||||||
|
else
|
||||||
|
-- Non-comment line - check if we have a complete UDT definition
|
||||||
|
if #current_comment_block > 0 then
|
||||||
|
local udt = M.parse_inline_udt_comment(current_comment_block)
|
||||||
|
if udt then
|
||||||
|
M.register_inline_udt(udt)
|
||||||
|
end
|
||||||
|
current_comment_block = {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check final block
|
||||||
|
if #current_comment_block > 0 then
|
||||||
|
local udt = M.parse_inline_udt_comment(current_comment_block)
|
||||||
|
if udt then
|
||||||
|
M.register_inline_udt(udt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Debug: Print cache contents
|
||||||
|
function M.debug_print_cache()
|
||||||
|
print("=== UDT Cache Contents ===")
|
||||||
|
print("Total UDTs: " .. M.get_cache_count())
|
||||||
|
for name, udt in pairs(udt_cache) do
|
||||||
|
print(string.format("UDT: %s (v%s)", name, udt.version))
|
||||||
|
print(string.format(" Title: %s", udt.title))
|
||||||
|
print(string.format(" File: %s", udt.filename or "unknown"))
|
||||||
|
print(string.format(" Members: %d", #udt.members))
|
||||||
|
for _, member in ipairs(udt.members) do
|
||||||
|
print(string.format(" - %s: %s%s",
|
||||||
|
member.name,
|
||||||
|
member.raw_type,
|
||||||
|
member.is_udt and " (UDT)" or ""))
|
||||||
|
end
|
||||||
|
print("")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
-- SCL Variable Extractor
|
||||||
|
-- Extracts local variables from VAR sections and provides them with # prefix
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local ts = vim.treesitter
|
||||||
|
|
||||||
|
-- Query to find variable declarations with their types
|
||||||
|
-- Simplified query that captures all var_item nodes regardless of parent type
|
||||||
|
local var_query_string = [[
|
||||||
|
(var_item
|
||||||
|
name: (identifier) @var_name
|
||||||
|
type: (_) @var_type)
|
||||||
|
]]
|
||||||
|
|
||||||
|
-- Check if cursor is inside a VAR section
|
||||||
|
local function is_in_var_section()
|
||||||
|
local cursor_node = ts.get_node()
|
||||||
|
if not cursor_node then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local node = cursor_node
|
||||||
|
while node do
|
||||||
|
local type = node:type()
|
||||||
|
if type == "var_declaration" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if type == "organization_block" or type == "function_block" then
|
||||||
|
-- Check if we're in the var declarations part (before BEGIN)
|
||||||
|
local parent = node
|
||||||
|
local children = {}
|
||||||
|
for child in parent:iter_children() do
|
||||||
|
table.insert(children, child)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, child in ipairs(children) do
|
||||||
|
if child:type() == "var_declaration" then
|
||||||
|
-- Check if cursor is before this var_declaration
|
||||||
|
local start_row = child:range()
|
||||||
|
local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||||
|
if cursor_row <= start_row then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if child:type() == "BEGIN" then
|
||||||
|
local start_row = child:range()
|
||||||
|
local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||||
|
if cursor_row < start_row then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
node = node:parent()
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extract all local variables from the buffer with their types
|
||||||
|
function M.extract_variables(bufnr)
|
||||||
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||||
|
|
||||||
|
local variables = {}
|
||||||
|
|
||||||
|
-- Try treesitter first
|
||||||
|
local ok, parser = pcall(ts.get_parser, bufnr, "scl")
|
||||||
|
if ok and parser then
|
||||||
|
local tree = parser:parse()[1]
|
||||||
|
if tree then
|
||||||
|
local root = tree:root()
|
||||||
|
local query_ok, query = pcall(ts.query.parse, "scl", var_query_string)
|
||||||
|
if query_ok then
|
||||||
|
for _, match, _ in query:iter_matches(root, bufnr, 0, -1) do
|
||||||
|
local var_name = nil
|
||||||
|
local var_type = nil
|
||||||
|
|
||||||
|
for id, nodes in pairs(match) do
|
||||||
|
-- nodes can be a single node or a table of nodes
|
||||||
|
local node_list = type(nodes) == "table" and nodes or { nodes }
|
||||||
|
for _, node in ipairs(node_list) do
|
||||||
|
if node and type(node.range) == "function" then
|
||||||
|
local text = ts.get_node_text(node, bufnr)
|
||||||
|
local capture_name = query.captures[id]
|
||||||
|
|
||||||
|
if capture_name == "var_name" then
|
||||||
|
var_name = text
|
||||||
|
elseif capture_name == "var_type" then
|
||||||
|
var_type = text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if var_name then
|
||||||
|
table.insert(variables, {
|
||||||
|
name = var_name,
|
||||||
|
type = var_type or "",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return variables
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fallback: simple regex-based extraction
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
local in_var = false
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
if line:match("^%s*VAR") or line:match("^%s*VAR_INPUT") or line:match("^%s*VAR_OUTPUT") or line:match("^%s*VAR_IN_OUT") then
|
||||||
|
in_var = true
|
||||||
|
elseif line:match("^%s*END_VAR") then
|
||||||
|
in_var = false
|
||||||
|
elseif in_var then
|
||||||
|
-- Match variable declarations like: "name : Type;" or "name : \"UdtType\";"
|
||||||
|
-- Handle both basic types and quoted UDT types
|
||||||
|
local var_name, var_type = line:match("^%s*([%w_]+)%s*:%s*([^;]+)")
|
||||||
|
if var_name then
|
||||||
|
-- Clean up type (remove attributes, default values, comments)
|
||||||
|
if var_type then
|
||||||
|
-- Remove attributes { ... }
|
||||||
|
var_type = var_type:gsub("%s*{.-}%s*", " ")
|
||||||
|
-- Remove default value := ...
|
||||||
|
var_type = var_type:gsub("%s*:=.*$", "")
|
||||||
|
-- Remove trailing comments
|
||||||
|
var_type = var_type:gsub("%s*//.*$", "")
|
||||||
|
-- Trim whitespace
|
||||||
|
var_type = var_type:gsub("^%s*", ""):gsub("%s*$", "")
|
||||||
|
end
|
||||||
|
table.insert(variables, {
|
||||||
|
name = var_name,
|
||||||
|
type = var_type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return variables
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get the type of a specific variable
|
||||||
|
function M.get_variable_type(var_name, bufnr)
|
||||||
|
local variables = M.extract_variables(bufnr)
|
||||||
|
for _, var in ipairs(variables) do
|
||||||
|
if var.name == var_name then
|
||||||
|
return var.type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all variables and their types as a map
|
||||||
|
function M.get_variable_types(bufnr)
|
||||||
|
local variables = M.extract_variables(bufnr)
|
||||||
|
local types = {}
|
||||||
|
for _, var in ipairs(variables) do
|
||||||
|
types[var.name] = var.type
|
||||||
|
end
|
||||||
|
return types
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get completion items based on context
|
||||||
|
function M.get_completions(bufnr)
|
||||||
|
local variables = M.extract_variables(bufnr)
|
||||||
|
local in_var_section = is_in_var_section()
|
||||||
|
|
||||||
|
local items = {}
|
||||||
|
for _, var in ipairs(variables) do
|
||||||
|
if in_var_section then
|
||||||
|
-- Inside VAR section: offer plain name
|
||||||
|
table.insert(items, {
|
||||||
|
label = var.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = var.type and (var.type .. " - Local variable") or "Local variable",
|
||||||
|
})
|
||||||
|
else
|
||||||
|
-- Outside VAR section: offer with # prefix
|
||||||
|
table.insert(items, {
|
||||||
|
label = "#" .. var.name,
|
||||||
|
insertText = "#" .. var.name,
|
||||||
|
kind = vim.lsp.protocol.CompletionItemKind.Variable,
|
||||||
|
detail = var.type and (var.type .. " - Local variable (prefixed)") or "Local variable (prefixed)",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return items
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we should provide completions
|
||||||
|
function M.should_complete()
|
||||||
|
local ft = vim.bo.filetype
|
||||||
|
if ft ~= "scl" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if treesitter parser is available
|
||||||
|
local ok, parser = pcall(ts.get_parser, 0, "scl")
|
||||||
|
return ok and parser ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
-- Workspace Type Scanner for SCL
|
||||||
|
-- Scans project directories for UDT definitions
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local scan = vim.loop.fs_scandir
|
||||||
|
local stat = vim.loop.fs_stat
|
||||||
|
|
||||||
|
-- Configuration
|
||||||
|
local config = {
|
||||||
|
enabled = true,
|
||||||
|
project_root = nil, -- nil = auto-detect
|
||||||
|
library_paths = {}, -- Additional paths to scan for UDTs (e.g., shared libraries)
|
||||||
|
type_extensions = { "udt" },
|
||||||
|
fb_extensions = { "scl" },
|
||||||
|
scan_depth = 10,
|
||||||
|
-- Folder names that indicate project root (case-insensitive)
|
||||||
|
project_markers = {
|
||||||
|
-- TIA Portal defaults
|
||||||
|
"program blocks",
|
||||||
|
"plc tags",
|
||||||
|
"plc tage",
|
||||||
|
"plc data types",
|
||||||
|
-- Common variations
|
||||||
|
"program_blocks",
|
||||||
|
"plc_tags",
|
||||||
|
"plc_data_types",
|
||||||
|
"data_types",
|
||||||
|
"types",
|
||||||
|
"udt",
|
||||||
|
-- Git marker
|
||||||
|
".git",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Cache for project roots: { [bufnr] = root_path }
|
||||||
|
local project_root_cache = {}
|
||||||
|
|
||||||
|
-- Check if a path contains any project marker
|
||||||
|
local function has_project_marker(path)
|
||||||
|
local handle = scan(path)
|
||||||
|
if not handle then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local name, type = vim.loop.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
if type == "directory" then
|
||||||
|
local lower_name = name:lower()
|
||||||
|
for _, marker in ipairs(config.project_markers) do
|
||||||
|
if lower_name == marker:lower() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif name == ".git" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find project root by searching upward
|
||||||
|
local function find_project_root(start_path)
|
||||||
|
local current = start_path
|
||||||
|
local depth = 0
|
||||||
|
|
||||||
|
while current and current ~= "/" and depth < 20 do
|
||||||
|
-- Check if current directory has project markers
|
||||||
|
if has_project_marker(current) then
|
||||||
|
return current
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for .udt files directly
|
||||||
|
local handle = scan(current)
|
||||||
|
if handle then
|
||||||
|
local has_udt = false
|
||||||
|
while true do
|
||||||
|
local name, type = vim.loop.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if type == "file" and name:match("%.udt$") then
|
||||||
|
has_udt = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if has_udt then
|
||||||
|
return current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Go up one level
|
||||||
|
current = vim.fn.fnamemodify(current, ":h")
|
||||||
|
depth = depth + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get project root for a buffer
|
||||||
|
function M.get_project_root(bufnr)
|
||||||
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||||
|
|
||||||
|
-- Return cached root
|
||||||
|
if project_root_cache[bufnr] then
|
||||||
|
return project_root_cache[bufnr]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Use configured root if set
|
||||||
|
if config.project_root then
|
||||||
|
local expanded = vim.fn.expand(config.project_root)
|
||||||
|
if vim.fn.isdirectory(expanded) == 1 then
|
||||||
|
project_root_cache[bufnr] = expanded
|
||||||
|
return expanded
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto-detect from buffer path
|
||||||
|
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||||
|
if bufname == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local bufdir = vim.fn.fnamemodify(bufname, ":h")
|
||||||
|
local root = find_project_root(bufdir)
|
||||||
|
|
||||||
|
if root then
|
||||||
|
project_root_cache[bufnr] = root
|
||||||
|
end
|
||||||
|
|
||||||
|
return root
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clear project root cache
|
||||||
|
function M.clear_root_cache()
|
||||||
|
project_root_cache = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Scan directory recursively for UDT files
|
||||||
|
local function scan_for_udts(dir, depth, results)
|
||||||
|
results = results or {}
|
||||||
|
depth = depth or 0
|
||||||
|
|
||||||
|
if depth > config.scan_depth then
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
local handle = scan(dir)
|
||||||
|
if not handle then
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local name, type = vim.loop.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
local full_path = dir .. "/" .. name
|
||||||
|
|
||||||
|
if type == "directory" then
|
||||||
|
-- Skip hidden directories and common non-project directories
|
||||||
|
if not name:match("^%.") and
|
||||||
|
name ~= "node_modules" and
|
||||||
|
name ~= ".git" and
|
||||||
|
name ~= "__pycache__" then
|
||||||
|
scan_for_udts(full_path, depth + 1, results)
|
||||||
|
end
|
||||||
|
elseif type == "file" then
|
||||||
|
-- Check if file has UDT extension
|
||||||
|
local ext = name:match("%.([^.]+)$")
|
||||||
|
if ext then
|
||||||
|
ext = ext:lower()
|
||||||
|
for _, allowed_ext in ipairs(config.type_extensions) do
|
||||||
|
if ext == allowed_ext:lower() then
|
||||||
|
table.insert(results, full_path)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Scan directory recursively for FB/Function .scl files
|
||||||
|
local function scan_for_fbs(dir, depth, results)
|
||||||
|
results = results or {}
|
||||||
|
depth = depth or 0
|
||||||
|
|
||||||
|
if depth > config.scan_depth then
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
local handle = scan(dir)
|
||||||
|
if not handle then
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local name, type = vim.loop.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
local full_path = dir .. "/" .. name
|
||||||
|
|
||||||
|
if type == "directory" then
|
||||||
|
-- Skip hidden directories and common non-project directories
|
||||||
|
if not name:match("^%.") and
|
||||||
|
name ~= "node_modules" and
|
||||||
|
name ~= ".git" and
|
||||||
|
name ~= "__pycache__" then
|
||||||
|
scan_for_fbs(full_path, depth + 1, results)
|
||||||
|
end
|
||||||
|
elseif type == "file" then
|
||||||
|
-- Check if file has FB extension and is a valid FB/Function file
|
||||||
|
local ext = name:match("%.([^.]+)$")
|
||||||
|
if ext then
|
||||||
|
ext = ext:lower()
|
||||||
|
for _, allowed_ext in ipairs(config.fb_extensions) do
|
||||||
|
if ext == allowed_ext:lower() then
|
||||||
|
table.insert(results, full_path)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all UDT files in project
|
||||||
|
function M.get_project_udt_files(bufnr)
|
||||||
|
local root = M.get_project_root(bufnr)
|
||||||
|
if not root then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return scan_for_udts(root, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get UDT files from library paths
|
||||||
|
function M.get_library_udt_files()
|
||||||
|
local all_files = {}
|
||||||
|
for _, lib_path in ipairs(config.library_paths) do
|
||||||
|
local expanded = vim.fn.expand(lib_path)
|
||||||
|
if vim.fn.isdirectory(expanded) == 1 then
|
||||||
|
local files = scan_for_udts(expanded, 0)
|
||||||
|
for _, f in ipairs(files) do
|
||||||
|
table.insert(all_files, f)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return all_files
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get all FB/Function files in project
|
||||||
|
function M.get_project_fb_files(bufnr)
|
||||||
|
local root = M.get_project_root(bufnr)
|
||||||
|
if not root then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return scan_for_fbs(root, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get FB/Function files from library paths
|
||||||
|
function M.get_library_fb_files()
|
||||||
|
local all_files = {}
|
||||||
|
for _, lib_path in ipairs(config.library_paths) do
|
||||||
|
local expanded = vim.fn.expand(lib_path)
|
||||||
|
if vim.fn.isdirectory(expanded) == 1 then
|
||||||
|
local files = scan_for_fbs(expanded, 0)
|
||||||
|
for _, f in ipairs(files) do
|
||||||
|
table.insert(all_files, f)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return all_files
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse all UDT files in project and libraries
|
||||||
|
function M.scan_project_udts(bufnr)
|
||||||
|
local udt_parser = require("scl.udt_parser")
|
||||||
|
local files = M.get_project_udt_files(bufnr)
|
||||||
|
local lib_files = M.get_library_udt_files()
|
||||||
|
|
||||||
|
-- Combine project and library files
|
||||||
|
for _, f in ipairs(lib_files) do
|
||||||
|
table.insert(files, f)
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed_count = 0
|
||||||
|
local errors = {}
|
||||||
|
|
||||||
|
for _, filepath in ipairs(files) do
|
||||||
|
local udt, err = udt_parser.parse_udt_file(filepath)
|
||||||
|
if udt then
|
||||||
|
parsed_count = parsed_count + 1
|
||||||
|
else
|
||||||
|
table.insert(errors, string.format("%s: %s", filepath, err or "unknown error"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsed_count = parsed_count,
|
||||||
|
total_files = #files,
|
||||||
|
errors = errors,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse all FB/Function files in project and libraries
|
||||||
|
function M.scan_project_fbs(bufnr)
|
||||||
|
local fb_parser = require("scl.fb_parser")
|
||||||
|
local files = M.get_project_fb_files(bufnr)
|
||||||
|
local lib_files = M.get_library_fb_files()
|
||||||
|
|
||||||
|
-- Combine project and library files
|
||||||
|
for _, f in ipairs(lib_files) do
|
||||||
|
table.insert(files, f)
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed_count = 0
|
||||||
|
local errors = {}
|
||||||
|
|
||||||
|
for _, filepath in ipairs(files) do
|
||||||
|
local fb, err = fb_parser.parse_fb_file(filepath)
|
||||||
|
if fb then
|
||||||
|
parsed_count = parsed_count + 1
|
||||||
|
else
|
||||||
|
table.insert(errors, string.format("%s: %s", filepath, err or "unknown error"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsed_count = parsed_count,
|
||||||
|
total_files = #files,
|
||||||
|
errors = errors,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Setup workspace scanner
|
||||||
|
function M.setup(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
|
||||||
|
-- Merge configuration
|
||||||
|
if opts.project_root then
|
||||||
|
config.project_root = opts.project_root
|
||||||
|
end
|
||||||
|
if opts.library_paths then
|
||||||
|
config.library_paths = opts.library_paths
|
||||||
|
end
|
||||||
|
if opts.type_extensions then
|
||||||
|
config.type_extensions = opts.type_extensions
|
||||||
|
end
|
||||||
|
if opts.scan_depth then
|
||||||
|
config.scan_depth = opts.scan_depth
|
||||||
|
end
|
||||||
|
if opts.project_markers then
|
||||||
|
config.project_markers = opts.project_markers
|
||||||
|
end
|
||||||
|
|
||||||
|
config.enabled = opts.workspace_types ~= false
|
||||||
|
|
||||||
|
if not config.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local augroup = vim.api.nvim_create_augroup("SCLWorkspaceScanner", { clear = true })
|
||||||
|
|
||||||
|
-- Scan when entering SCL buffer
|
||||||
|
vim.api.nvim_create_autocmd("BufEnter", {
|
||||||
|
group = augroup,
|
||||||
|
pattern = "*.scl",
|
||||||
|
callback = function(args)
|
||||||
|
-- Clear cache and rescan
|
||||||
|
local udt_parser = require("scl.udt_parser")
|
||||||
|
local fb_parser = require("scl.fb_parser")
|
||||||
|
udt_parser.clear_cache()
|
||||||
|
fb_parser.clear_cache()
|
||||||
|
M.clear_root_cache()
|
||||||
|
|
||||||
|
M.scan_project_udts(args.buf)
|
||||||
|
M.scan_project_fbs(args.buf)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Watch for UDT file changes
|
||||||
|
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
|
||||||
|
group = augroup,
|
||||||
|
pattern = "*.udt",
|
||||||
|
callback = function(args)
|
||||||
|
-- Re-parse the changed UDT file
|
||||||
|
local udt_parser = require("scl.udt_parser")
|
||||||
|
udt_parser.parse_udt_file(args.file)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Watch for FB/Function .scl file changes
|
||||||
|
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
|
||||||
|
group = augroup,
|
||||||
|
pattern = "*.scl",
|
||||||
|
callback = function(args)
|
||||||
|
-- Re-parse the changed FB file
|
||||||
|
local fb_parser = require("scl.fb_parser")
|
||||||
|
fb_parser.parse_fb_file(args.file)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
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 lspconfig_avail, lspconfig = pcall(require, "lspconfig")
|
||||||
|
if not lspconfig_avail then
|
||||||
|
vim.notify("lspconfig not found. Please install nvim-lspconfig.", vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
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 get_root_dir(fname)
|
||||||
|
local root = vim.fs.root(fname, root_patterns)
|
||||||
|
return root or vim.fn.fnamemodify(fname, ":h")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_attach(client, bufnr)
|
||||||
|
if opts.lsp and opts.lsp.on_attach then
|
||||||
|
opts.lsp.on_attach(client, bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.lsp.formatting then
|
||||||
|
vim.api.nvim_buf_create_user_command(bufnr, "LspSCLFormat", function()
|
||||||
|
vim.lsp.buf.format({ name = "scl-language-server" })
|
||||||
|
end, {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
lspconfig.scl_lsp = {
|
||||||
|
default_config = {
|
||||||
|
name = "scl-language-server",
|
||||||
|
cmd = { "lua", server_path },
|
||||||
|
filetypes = { "scl" },
|
||||||
|
root_dir = get_root_dir,
|
||||||
|
settings = {},
|
||||||
|
on_attach = on_attach,
|
||||||
|
},
|
||||||
|
docs = {
|
||||||
|
description = "Language server for Siemens SCL (LSP, Linter, Formatter)",
|
||||||
|
default_config = {
|
||||||
|
name = "scl-language-server",
|
||||||
|
cmd = { "lua", server_path },
|
||||||
|
filetypes = { "scl" },
|
||||||
|
root_dir = "root_pattern('.git', 'data_types', 'plc.data.json')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
M.setup_syntax(opts)
|
||||||
|
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.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, {})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
return {
|
||||||
|
"lazar/scl_lsp",
|
||||||
|
ft = "scl",
|
||||||
|
lazy = true,
|
||||||
|
dependencies = {
|
||||||
|
"neovim/nvim-lspconfig",
|
||||||
|
"nvim-treesitter/nvim-treesitter",
|
||||||
|
"saghen/blink.cmp",
|
||||||
|
},
|
||||||
|
config = function(_, opts)
|
||||||
|
require("scl_lsp").setup({
|
||||||
|
server_path = opts.server_path,
|
||||||
|
lsp = opts.lsp,
|
||||||
|
cmp = opts.cmp ~= false,
|
||||||
|
workspace_types = opts.workspace_types ~= false,
|
||||||
|
auto_prefix = opts.auto_prefix ~= false,
|
||||||
|
project_root = opts.project_root,
|
||||||
|
library_paths = opts.library_paths,
|
||||||
|
debug = opts.debug,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
; Folds for SCL code blocks
|
||||||
|
|
||||||
|
[
|
||||||
|
(organization_block)
|
||||||
|
(function_block)
|
||||||
|
(var_declaration)
|
||||||
|
(if_statement)
|
||||||
|
(case_statement)
|
||||||
|
(for_statement)
|
||||||
|
(while_statement)
|
||||||
|
(repeat_statement)
|
||||||
|
(region_statement)
|
||||||
|
(block_comment)
|
||||||
|
] @fold
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
; Comprehensive SCL highlight queries for Neovim
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Comments
|
||||||
|
; ============================================
|
||||||
|
(line_comment) @comment
|
||||||
|
(block_comment) @comment
|
||||||
|
(c_style_comment) @comment
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Literals
|
||||||
|
; ============================================
|
||||||
|
(number) @number
|
||||||
|
(hex_number) @number
|
||||||
|
(string) @string
|
||||||
|
(time_value) @number
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Constants
|
||||||
|
; ============================================
|
||||||
|
(
|
||||||
|
(identifier) @constant.builtin
|
||||||
|
(#match? @constant.builtin "^(TRUE|FALSE|TRUE#|FALSE#)$")
|
||||||
|
)
|
||||||
|
(
|
||||||
|
(type) @type.builtin
|
||||||
|
(#match? @type.builtin "^(TRUE|FALSE)$")
|
||||||
|
)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Identifiers
|
||||||
|
; ============================================
|
||||||
|
(identifier) @variable
|
||||||
|
(prefixed_identifier) @variable
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Types - Basic
|
||||||
|
; ============================================
|
||||||
|
(
|
||||||
|
(type) @type.builtin
|
||||||
|
(#match? @type.builtin "^(BOOL|Bool|INT|Int|DINT|DInt|SINT|SInt|USINT|USInt|UINT|UInt|WORD|Word|DWORD|DWord|BYTE|Byte|REAL|Real|LREAL|LReal|TIME|Time|TOD|Tod|DT|Dt|String|STRING|CHAR|Char|WSTRING|WString)$")
|
||||||
|
)
|
||||||
|
|
||||||
|
; Types - User defined (quoted strings like "UDT_Type")
|
||||||
|
(type
|
||||||
|
(string) @type
|
||||||
|
)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Block definitions
|
||||||
|
; ============================================
|
||||||
|
"ORGANIZATION_BLOCK" @keyword
|
||||||
|
"FUNCTION_BLOCK" @keyword
|
||||||
|
"FUNCTION" @keyword
|
||||||
|
"END_ORGANIZATION_BLOCK" @keyword
|
||||||
|
"END_FUNCTION_BLOCK" @keyword
|
||||||
|
"END_FUNCTION" @keyword
|
||||||
|
"BEGIN" @keyword
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Variable sections
|
||||||
|
; ============================================
|
||||||
|
"VAR_INPUT" @keyword
|
||||||
|
"VAR_OUTPUT" @keyword
|
||||||
|
"VAR_IN_OUT" @keyword
|
||||||
|
"VAR" @keyword
|
||||||
|
"VAR_TEMP" @keyword
|
||||||
|
"VAR_CONSTANT" @keyword
|
||||||
|
"END_VAR" @keyword
|
||||||
|
"CONSTANT" @keyword
|
||||||
|
"RETAIN" @keyword
|
||||||
|
"NON_RETAIN" @keyword
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Control flow
|
||||||
|
; ============================================
|
||||||
|
"IF" @conditional
|
||||||
|
"THEN" @conditional
|
||||||
|
"ELSIF" @conditional
|
||||||
|
"ELSE" @conditional
|
||||||
|
"END_IF" @conditional
|
||||||
|
"CASE" @conditional
|
||||||
|
"OF" @conditional
|
||||||
|
"END_CASE" @conditional
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Loops
|
||||||
|
; ============================================
|
||||||
|
"FOR" @repeat
|
||||||
|
"TO" @repeat
|
||||||
|
"BY" @repeat
|
||||||
|
"DO" @repeat
|
||||||
|
"END_FOR" @repeat
|
||||||
|
"WHILE" @repeat
|
||||||
|
"END_WHILE" @repeat
|
||||||
|
"REPEAT" @repeat
|
||||||
|
"UNTIL" @repeat
|
||||||
|
"END_REPEAT" @repeat
|
||||||
|
"EXIT" @keyword
|
||||||
|
"CONTINUE" @keyword
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Regions
|
||||||
|
; ============================================
|
||||||
|
"REGION" @namespace
|
||||||
|
"END_REGION" @namespace
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Keywords - Other
|
||||||
|
; ============================================
|
||||||
|
"TITLE" @keyword
|
||||||
|
"VERSION" @keyword
|
||||||
|
"ARRAY" @keyword
|
||||||
|
"OF" @keyword
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Fields
|
||||||
|
; ============================================
|
||||||
|
(block
|
||||||
|
name: (string) @namespace)
|
||||||
|
|
||||||
|
(block
|
||||||
|
name: (identifier) @namespace)
|
||||||
|
|
||||||
|
(var_item
|
||||||
|
name: (identifier) @property)
|
||||||
|
|
||||||
|
(var_item
|
||||||
|
type: (type) @type)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Functions and function calls
|
||||||
|
; ============================================
|
||||||
|
(function_call
|
||||||
|
(identifier) @function.call)
|
||||||
|
|
||||||
|
(function_call
|
||||||
|
(string) @function.call)
|
||||||
|
|
||||||
|
(parameter
|
||||||
|
(identifier) @parameter)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Array access
|
||||||
|
; ============================================
|
||||||
|
(array_access
|
||||||
|
"[" @punctuation.bracket
|
||||||
|
"]" @punctuation.bracket)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Field access
|
||||||
|
; ============================================
|
||||||
|
(field_access
|
||||||
|
"." @punctuation.delimiter)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Attribute lists
|
||||||
|
; ============================================
|
||||||
|
(attribute_list
|
||||||
|
"{" @punctuation.bracket
|
||||||
|
"}" @punctuation.bracket)
|
||||||
|
|
||||||
|
(attribute_list
|
||||||
|
"," @punctuation.delimiter)
|
||||||
|
|
||||||
|
(attribute_list
|
||||||
|
";" @punctuation.delimiter)
|
||||||
|
|
||||||
|
(attribute
|
||||||
|
(identifier) @property)
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Operators - Logical
|
||||||
|
; ============================================
|
||||||
|
"AND" @operator
|
||||||
|
"OR" @operator
|
||||||
|
"XOR" @operator
|
||||||
|
"NOT" @operator
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Operators - Comparison
|
||||||
|
; ============================================
|
||||||
|
"=" @operator
|
||||||
|
"<>" @operator
|
||||||
|
"<" @operator
|
||||||
|
">" @operator
|
||||||
|
"<=" @operator
|
||||||
|
">=" @operator
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Operators - Arithmetic
|
||||||
|
; ============================================
|
||||||
|
"+" @operator
|
||||||
|
"-" @operator
|
||||||
|
"*" @operator
|
||||||
|
"/" @operator
|
||||||
|
"MOD" @operator
|
||||||
|
"**" @operator
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Operators - Assignment
|
||||||
|
; ============================================
|
||||||
|
":=" @operator
|
||||||
|
"=>" @operator
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Punctuation
|
||||||
|
; ============================================
|
||||||
|
";" @punctuation.delimiter
|
||||||
|
":" @punctuation.delimiter
|
||||||
|
"," @punctuation.delimiter
|
||||||
|
"(" @punctuation.bracket
|
||||||
|
")" @punctuation.bracket
|
||||||
|
"[" @punctuation.bracket
|
||||||
|
"]" @punctuation.bracket
|
||||||
|
"." @punctuation.delimiter
|
||||||
|
".." @punctuation.delimiter
|
||||||
|
"#" @punctuation.special
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Binary and unary expressions
|
||||||
|
; ============================================
|
||||||
|
(binary_expression) @operator
|
||||||
|
(unary_expression) @operator
|
||||||
|
|
||||||
|
; ============================================
|
||||||
|
; Range expressions
|
||||||
|
; ============================================
|
||||||
|
(range
|
||||||
|
".." @punctuation.delimiter)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
; Tag queries for SCL - matches grammar.js structure
|
||||||
|
|
||||||
|
; Organization blocks
|
||||||
|
(organization_block
|
||||||
|
name: (string) @name) @definition.function
|
||||||
|
|
||||||
|
; Function blocks
|
||||||
|
(function_block
|
||||||
|
name: (string) @name) @definition.function
|
||||||
|
|
||||||
|
; Variable definitions within blocks
|
||||||
|
(var_item
|
||||||
|
name: (identifier) @name
|
||||||
|
type: (type) @type) @definition.variable
|
||||||
|
|
||||||
|
; Parameter definitions (inputs/outputs)
|
||||||
|
(var_declaration
|
||||||
|
(var_item
|
||||||
|
name: (identifier) @name
|
||||||
|
type: (type) @type)) @definition.variable
|
||||||
|
|
||||||
|
; Function calls (references)
|
||||||
|
(function_call
|
||||||
|
(identifier) @name) @reference.call
|
||||||
|
|
||||||
|
(function_call
|
||||||
|
(string) @name) @reference.call
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/lazar/dev/scl_lsp
|
||||||
|
lua src/main.lua
|
||||||
Reference in New Issue
Block a user