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