commit fbbd357ee29edc9c77c34acbf590be8dfa8acaa2 Author: Lazar Bulovan Date: Sat Feb 14 15:05:12 2026 +0100 Inintal commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..88c9924 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d65fbd --- /dev/null +++ b/README.md @@ -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 | +| `s` | Document Symbols | +| `w` | Workspace Symbols | +| `fw` | Workspace Symbols (Telescope) | +| `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 diff --git a/lua/scl/auto_prefix.lua b/lua/scl/auto_prefix.lua new file mode 100644 index 0000000..ea4bebe --- /dev/null +++ b/lua/scl/auto_prefix.lua @@ -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 diff --git a/lua/scl/blink_cmp_source.lua b/lua/scl/blink_cmp_source.lua new file mode 100644 index 0000000..1286a8e --- /dev/null +++ b/lua/scl/blink_cmp_source.lua @@ -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 diff --git a/lua/scl/fb_parser.lua b/lua/scl/fb_parser.lua new file mode 100644 index 0000000..0939aa5 --- /dev/null +++ b/lua/scl/fb_parser.lua @@ -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 diff --git a/lua/scl/init.lua b/lua/scl/init.lua new file mode 100644 index 0000000..6b55d59 --- /dev/null +++ b/lua/scl/init.lua @@ -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 diff --git a/lua/scl/udt_parser.lua b/lua/scl/udt_parser.lua new file mode 100644 index 0000000..412bf65 --- /dev/null +++ b/lua/scl/udt_parser.lua @@ -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 diff --git a/lua/scl/variables.lua b/lua/scl/variables.lua new file mode 100644 index 0000000..4ac8d06 --- /dev/null +++ b/lua/scl/variables.lua @@ -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 diff --git a/lua/scl/workspace_types.lua b/lua/scl/workspace_types.lua new file mode 100644 index 0000000..ca20dc6 --- /dev/null +++ b/lua/scl/workspace_types.lua @@ -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 diff --git a/lua/scl_lsp/init.lua b/lua/scl_lsp/init.lua new file mode 100644 index 0000000..be158bf --- /dev/null +++ b/lua/scl_lsp/init.lua @@ -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 diff --git a/lua/scl_lsp/plugins/scl.lua b/lua/scl_lsp/plugins/scl.lua new file mode 100644 index 0000000..57af9f5 --- /dev/null +++ b/lua/scl_lsp/plugins/scl.lua @@ -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, +} diff --git a/queries/folds.scm b/queries/folds.scm new file mode 100644 index 0000000..09a87c5 --- /dev/null +++ b/queries/folds.scm @@ -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 diff --git a/queries/highlights.scm b/queries/highlights.scm new file mode 100644 index 0000000..375556c --- /dev/null +++ b/queries/highlights.scm @@ -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) diff --git a/queries/indents.scm b/queries/indents.scm new file mode 100644 index 0000000..cbc57f0 --- /dev/null +++ b/queries/indents.scm @@ -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 diff --git a/queries/locals.scm b/queries/locals.scm new file mode 100644 index 0000000..e3b5d2c --- /dev/null +++ b/queries/locals.scm @@ -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 diff --git a/queries/tags.scm b/queries/tags.scm new file mode 100644 index 0000000..0f9aebc --- /dev/null +++ b/queries/tags.scm @@ -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 diff --git a/scl_lsp.sh b/scl_lsp.sh new file mode 100755 index 0000000..4361bf8 --- /dev/null +++ b/scl_lsp.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/lazar/dev/scl_lsp +lua src/main.lua