refactor: rename scl_lsp → tia_lsp (project umbrella for future STL/LAD/FBD)

Rename the project from scl_lsp to tia_lsp to future-proof for additional
TIA Portal languages (STL/AWL, LAD, FBD). The 'scl' language name is kept
as-is for the SCL filetype and tree-sitter parser; future languages get
their own names (stl, lad, fbd).

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

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

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

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

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
This commit is contained in:
2026-07-18 11:30:55 +02:00
parent 137eda3e77
commit 8c6a050efb
20 changed files with 194 additions and 388 deletions
+240
View File
@@ -0,0 +1,240 @@
-- Attribute block toggle - Interactive expand/collapse of {...} blocks
local M = {}
-- Store expanded content per buffer
-- Format: { [bufnr] = { [line_num] = { collapsed = "...", expanded = "actual content", is_collapsed = boolean } } }
local buffer_attr_state = {}
-- Find attribute block under cursor
-- Returns: start_col, end_col, content (or nil if not found)
function M.find_attr_block_under_cursor()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2] + 1 -- 1-indexed
-- Find all {...} blocks in the line
local pos = 1
while pos <= #line do
local start_pos, end_pos = line:find("{(.-)}", pos)
if not start_pos then
break
end
-- Check if cursor is within this block (including braces)
if col >= start_pos and col <= end_pos then
local content = line:sub(start_pos + 1, end_pos - 1)
return start_pos, end_pos, content
end
pos = end_pos + 1
end
return nil, nil, nil
end
-- Toggle the attribute block under cursor
function M.toggle_attr_block()
local bufnr = vim.api.nvim_get_current_buf()
local line_num = vim.api.nvim_win_get_cursor(0)[1]
local line = vim.api.nvim_get_current_line()
local start_col, end_col, content = M.find_attr_block_under_cursor()
if not start_col then
vim.notify("No attribute block found under cursor", vim.log.levels.WARN)
return
end
-- Initialize buffer state if needed
if not buffer_attr_state[bufnr] then
buffer_attr_state[bufnr] = {}
end
local state_key = line_num .. "_" .. start_col
local state = buffer_attr_state[bufnr][state_key]
-- Check if this is currently collapsed
local is_collapsed = content == "..."
if is_collapsed then
-- Expand: restore original content
if state and state.expanded then
local new_line = line:sub(1, start_col) .. state.expanded .. line:sub(end_col)
vim.api.nvim_set_current_line(new_line)
buffer_attr_state[bufnr][state_key].is_collapsed = false
vim.notify("Attribute block expanded", vim.log.levels.INFO)
else
vim.notify("Cannot expand: original content not stored", vim.log.levels.WARN)
end
else
-- Collapse: store original and show {...}
local new_line = line:sub(1, start_col) .. "..." .. line:sub(end_col)
vim.api.nvim_set_current_line(new_line)
buffer_attr_state[bufnr][state_key] = {
collapsed = "...",
expanded = content,
is_collapsed = true,
line_num = line_num,
start_col = start_col,
}
vim.notify("Attribute block collapsed", vim.log.levels.INFO)
end
end
-- Expand all collapsed blocks in current buffer
function M.expand_all_attr_blocks(opts)
opts = opts or {}
local bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local count = 0
if not buffer_attr_state[bufnr] then
if not opts.silent then
vim.notify("No collapsed blocks to expand", vim.log.levels.INFO)
end
return
end
for state_key, state in pairs(buffer_attr_state[bufnr]) do
if state.is_collapsed then
local line = lines[state.line_num]
if line then
local new_line = line:sub(1, state.start_col) .. state.expanded .. line:sub(state.start_col + 4)
lines[state.line_num] = new_line
state.is_collapsed = false
count = count + 1
end
end
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
if not opts.silent and count > 0 then
vim.notify("Expanded " .. count .. " attribute block(s)", vim.log.levels.INFO)
end
end
-- Helper to make a pattern case-insensitive
local function case_insensitive_pattern(pattern)
return pattern:gsub("(%a)", function(c)
return "[" .. c:upper() .. c:lower() .. "]"
end)
end
-- Collapse all attribute blocks matching patterns in current buffer
function M.collapse_all_attr_blocks(patterns)
patterns = patterns or {
case_insensitive_pattern("^%s*EXTERNAL"), -- EXTERNALACCESSIBLE, EXTERNALVISIBLE
case_insensitive_pattern("^%s*S7_"), -- S7_Optimized_Access
case_insensitive_pattern("^%s*NonRetain"), -- NonRetain
case_insensitive_pattern("^%s*Retain"), -- Retain (but not NonRetain)
"^%s*%w+%s*:=", -- key := value
"^%s*%w+%s*$", -- single word attribute
}
local bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local count = 0
-- Initialize buffer state
if not buffer_attr_state[bufnr] then
buffer_attr_state[bufnr] = {}
end
for line_idx, line in ipairs(lines) do
local pos = 1
while pos <= #line do
local start_pos, end_pos, capture = line:find("{(.-)}", pos)
if not start_pos then
break
end
local content = line:sub(start_pos + 1, end_pos - 1)
-- Check if content matches any pattern
local should_collapse = false
for _, pattern in ipairs(patterns) do
if content:match(pattern) then
should_collapse = true
break
end
end
-- Don't collapse if already collapsed
if should_collapse and content ~= "..." then
local state_key = line_idx .. "_" .. start_pos
buffer_attr_state[bufnr][state_key] = {
collapsed = "...",
expanded = content,
is_collapsed = true,
line_num = line_idx,
start_col = start_pos,
}
-- Replace in line
line = line:sub(1, start_pos) .. "..." .. line:sub(end_pos)
count = count + 1
-- Update position for next search
pos = start_pos + 4
else
pos = end_pos + 1
end
end
lines[line_idx] = line
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.notify("Collapsed " .. count .. " attribute block(s)", vim.log.levels.INFO)
end
-- Clear state when buffer is unloaded
function M.clear_buffer_state(bufnr)
buffer_attr_state[bufnr] = nil
end
-- Setup autocmd to clear state on buffer unload
function M.setup()
local augroup = vim.api.nvim_create_augroup("SCLAttrToggle", { clear = true })
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
pattern = { "*.scl", "*.udt", "*.db" },
callback = function(args)
if buffer_attr_state[args.buf] then
local has_collapsed = false
for _, state in pairs(buffer_attr_state[args.buf]) do
if state.is_collapsed then
has_collapsed = true
break
end
end
if has_collapsed then
M.expand_all_attr_blocks({ silent = true })
end
end
end,
})
vim.api.nvim_create_autocmd("BufUnload", {
group = augroup,
pattern = { "*.scl", "*.udt", "*.db" },
callback = function(args)
M.clear_buffer_state(args.buf)
end,
})
-- Create commands
vim.api.nvim_create_user_command("SCLToggleAttrBlock", function()
M.toggle_attr_block()
end, {})
vim.api.nvim_create_user_command("SCLExpandAllAttrBlocks", function()
M.expand_all_attr_blocks()
end, {})
vim.api.nvim_create_user_command("SCLCollapseAllAttrBlocks", function()
M.collapse_all_attr_blocks()
end, {})
end
return M
+658
View File
@@ -0,0 +1,658 @@
-- 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", "TYPE", "END_TYPE", "STRUCT", "END_STRUCT",
"DATA_BLOCK", "END_DATA_BLOCK",
"CONSTANT", "RETAIN", "NON_RETAIN",
}
-- Keywords that need semicolon after them (control structure ends only)
local end_keywords = {
"END_IF", "END_FOR", "END_WHILE", "END_REPEAT", "END_CASE",
}
-- Map lowercase to uppercase for auto-conversion
local keyword_map = {}
for _, kw in ipairs(keywords) do
keyword_map[kw:lower()] = kw
end
-- 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("tia.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 inside an FB/Function call parameter list
local function is_in_fb_call_params()
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
local line = vim.api.nvim_get_current_line()
-- If line ends with ");" or similar, we're at end of params
if line:match("%s*%);?%s*$") then
return false
end
-- Count parens before cursor position
local open_count = 0
local close_count = 0
for i = 1, cursor_col do
local c = line:sub(i, i)
if c == "(" then
open_count = open_count + 1
elseif c == ")" then
close_count = close_count + 1
end
end
-- If there are unclosed parens before cursor, we're inside params
if open_count > close_count then
return true
end
return false
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
-- Don't prefix if inside FB/Function call parameters
if is_in_fb_call_params() 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
-- Don't prefix if inside FB/Function call parameters
if is_in_fb_call_params() 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
-- Auto-uppercase keywords when typing space
local function auto_uppercase_keyword()
-- Only in SCL files
local ft = vim.bo.filetype
if ft ~= "scl" and ft ~= "udt" and ft ~= "db" 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]
-- Check if we just typed a space
if col < 1 or line:sub(col, col) ~= " " then
return
end
-- Find word before the space
local word_end = col - 1
local word_start = word_end
while word_start > 0 and line:sub(word_start, word_start):match("[%w_]") do
word_start = word_start - 1
end
word_start = word_start + 1
if word_start > word_end then
return
end
local word = line:sub(word_start, word_end)
local lower_word = word:lower()
-- Check if it's a keyword that needs uppercase
if keyword_map[lower_word] and word ~= keyword_map[lower_word] then
local upper_word = keyword_map[lower_word]
vim.api.nvim_buf_set_text(bufnr, row - 1, word_start - 1, row - 1, word_end, { upper_word })
end
end
-- Auto-add semicolon after END_* keywords
local function auto_add_semicolon()
-- Only in SCL files
local ft = vim.bo.filetype
if ft ~= "scl" and ft ~= "udt" and ft ~= "db" 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]
local trimmed = line:match("^%s*(.-)%s*$")
-- Check if line ends with an END_* keyword (without semicolon)
for _, end_kw in ipairs(end_keywords) do
if trimmed:upper() == end_kw then
-- Check if already has semicolon
if not line:match(";%s*$") then
local new_line = line .. ";"
vim.api.nvim_buf_set_lines(bufnr, row - 1, row, false, { new_line })
-- Move cursor to end of line
vim.api.nvim_win_set_cursor(0, { row, #new_line })
end
break
end
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()
local ft = vim.bo.filetype
if ft == "scl" or ft == "udt" or ft == "db" then
-- Auto-add semicolon after END_* keywords
auto_add_semicolon()
-- Variable prefixing (only for scl)
if ft == "scl" then
auto_prefix_current_line()
end
end
end,
})
-- Trigger on space character in insert mode
vim.api.nvim_create_autocmd("TextChangedI", {
group = augroup,
callback = function()
local ft = vim.bo.filetype
if ft == "scl" or ft == "udt" or ft == "db" 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-uppercase keywords
auto_uppercase_keyword()
-- Variable prefixing (only for scl)
if ft == "scl" then
auto_prefix_current_line()
end
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("tia.udt_parser")
local is_udt = udt_parser.is_udt_type(type_name)
if is_udt and not before_semicolon:match(":%s*\"" .. type_name .. "\"") then
local new_before = before_semicolon:gsub(":%s*" .. type_name .. "%s*$", ':"' .. type_name .. '"')
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,
})
-- Auto-add semicolon when pressing Enter after END_* keyword
vim.api.nvim_create_autocmd("TextChangedI", {
group = augroup,
callback = function()
local ft = vim.bo.filetype
if ft ~= "scl" and ft ~= "udt" and ft ~= "db" 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]
-- Check if we're on a new/empty line (Enter was pressed)
if line and line:match("^%s*$") and row > 1 then
local prev_line = vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1]
if prev_line then
local trimmed = prev_line:match("^%s*(.-)%s*$")
-- Check if previous line is an END_* keyword without semicolon
for _, end_kw in ipairs(end_keywords) do
if trimmed:upper() == end_kw and not prev_line:match(";%s*$") then
-- Add semicolon to previous line
local new_prev = prev_line .. ";"
vim.api.nvim_buf_set_lines(bufnr, row - 2, row - 1, false, { new_prev })
break
end
end
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
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
-- TIA Portal built-in instructions for SCL Neovim plugin
-- Defines functions, function blocks, and their parameters for completion
local M = {}
-- Built-in functions (return values, no state)
M.FUNCTIONS = {
ADD = true,
SUB = true,
MUL = true,
DIV = true,
MIN = true,
MAX = true,
LIMIT = true,
MOVE = true,
CONVERT = true,
ROUND = true,
TRUNC = true,
CEIL = true,
FLOOR = true,
SQR = true,
SQRT = true,
SIN = true,
COS = true,
TAN = true,
ASIN = true,
ACOS = true,
ATAN = true,
LEN = true,
CONCAT = true,
LEFT = true,
RIGHT = true,
MID = true,
AND = true,
OR = true,
XOR = true,
SHL = true,
SHR = true,
ROL = true,
ROR = true,
BCD_I = true,
I_BCD = true,
}
-- Built-in function blocks (have state, need instance)
M.FUNCTION_BLOCKS = {
TON = true,
TOF = true,
TP = true,
TONR = true,
CTU = true,
CTD = true,
CTUD = true,
IEC_TIMER = true,
IEC_TON = true,
IEC_TOF = true,
IEC_TP = true,
IEC_COUNTER = true,
IEC_CTU = true,
IEC_CTD = true,
IEC_CTUD = true,
R_TRIG = true,
F_TRIG = true,
SR = true,
RS = true,
SCHEDULE = true,
BPM = true,
LOG = true,
DATALOG = true,
}
-- Parameter definitions for built-in instructions
-- Format: { inputs = { { name, type }, ... }, outputs = { { name, type }, ... } }
M.PARAMETERS = {
TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
TOF = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
TP = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
TONR = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" }, { name = "R", type = "BOOL" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
CTU = { inputs = { { name = "CU", type = "BOOL" }, { name = "R", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "CV", type = "INT" } } },
CTD = { inputs = { { name = "CD", type = "BOOL" }, { name = "LD", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "CV", type = "INT" } } },
CTUD = { inputs = { { name = "CU", type = "BOOL" }, { name = "CD", type = "BOOL" }, { name = "R", type = "BOOL" }, { name = "LD", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "QU", type = "BOOL" }, { name = "QD", type = "BOOL" }, { name = "CV", type = "INT" } } },
R_TRIG = { inputs = { { name = "CLK", type = "BOOL" } }, outputs = { { name = "Q", type = "BOOL" } } },
F_TRIG = { inputs = { { name = "CLK", type = "BOOL" } }, outputs = { { name = "Q", type = "BOOL" } } },
SR = { inputs = { { name = "S", type = "BOOL" }, { name = "R1", type = "BOOL" } }, outputs = { { name = "Q1", type = "BOOL" } } },
RS = { inputs = { { name = "S1", type = "BOOL" }, { name = "R", type = "BOOL" } }, outputs = { { name = "Q1", type = "BOOL" } } },
IEC_TIMER = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
IEC_TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
IEC_TOF = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
IEC_TP = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } },
IEC_COUNTER = { inputs = { { name = "CU", type = "BOOL" }, { name = "CD", type = "BOOL" }, { name = "R", type = "BOOL" }, { name = "LD", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "QU", type = "BOOL" }, { name = "QD", type = "BOOL" }, { name = "CV", type = "INT" } } },
IEC_CTU = { inputs = { { name = "CU", type = "BOOL" }, { name = "R", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "CV", type = "INT" } } },
IEC_CTD = { inputs = { { name = "CD", type = "BOOL" }, { name = "LD", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "CV", type = "INT" } } },
IEC_CTUD = { inputs = { { name = "CU", type = "BOOL" }, { name = "CD", type = "BOOL" }, { name = "R", type = "BOOL" }, { name = "LD", type = "BOOL" }, { name = "PV", type = "INT" } }, outputs = { { name = "QU", type = "BOOL" }, { name = "QD", type = "BOOL" }, { name = "CV", type = "INT" } } },
ADD = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
SUB = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
MUL = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
DIV = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
MIN = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
MAX = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
LIMIT = { inputs = { { name = "MN" }, { name = "IN" }, { name = "MX" } }, outputs = { { name = "OUT" } } },
MOVE = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
CONVERT = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
ROUND = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
TRUNC = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
CEIL = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
FLOOR = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
SQR = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
SQRT = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
SIN = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
COS = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
TAN = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
ASIN = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
ACOS = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
ATAN = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
LEN = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
CONCAT = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
LEFT = { inputs = { { name = "IN" }, { name = "L" } }, outputs = { { name = "OUT" } } },
RIGHT = { inputs = { { name = "IN" }, { name = "L" } }, outputs = { { name = "OUT" } } },
MID = { inputs = { { name = "IN" }, { name = "L" }, { name = "P" } }, outputs = { { name = "OUT" } } },
AND = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
OR = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
XOR = { inputs = { { name = "IN1" }, { name = "IN2" } }, outputs = { { name = "OUT" } } },
SHL = { inputs = { { name = "IN" }, { name = "N" } }, outputs = { { name = "OUT" } } },
SHR = { inputs = { { name = "IN" }, { name = "N" } }, outputs = { { name = "OUT" } } },
ROL = { inputs = { { name = "IN" }, { name = "N" } }, outputs = { { name = "OUT" } } },
ROR = { inputs = { { name = "IN" }, { name = "N" } }, outputs = { { name = "OUT" } } },
BCD_I = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
I_BCD = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } },
}
--- Get parameter info for a built-in instruction
--- @param name string The instruction name
--- @return table|nil Parameter definition with inputs and outputs arrays
function M.get_parameters(name)
return M.PARAMETERS[name:upper()]
end
--- Check if name is a known function block
--- @param name string The name to check
--- @return boolean True if known function block
function M.is_known_function_block(name)
return M.FUNCTION_BLOCKS[name:upper()] or false
end
--- Check if name is a known function
--- @param name string The name to check
--- @return boolean True if known function
function M.is_known_function(name)
return M.FUNCTIONS[name:upper()] or false
end
return M
+419
View File
@@ -0,0 +1,419 @@
-- DB Parser for SCL
-- Parses .db files to extract global data block definitions and member structures
local M = {}
-- Cache for parsed DBs: { [db_name] = db_definition }
local db_cache = {}
-- Inline DB definitions (from comments): { [db_name] = db_definition }
local inline_dbs = {}
-- DB Definition structure:
-- {
-- name = "DBName",
-- title = "Data Block Title",
-- version = "0.1",
-- comment = "User comment",
-- members = {
-- {
-- name = "memberName",
-- type = "Bool" or '"OtherUdt"',
-- attributes = "{ S7_SetPoint := 'True' }",
-- comment = "Member comment",
-- is_udt = false -- true if type is a quoted UDT reference
-- }
-- }
-- }
-- Register an inline DB definition (from comments or other sources)
function M.register_inline_db(db)
if db and db.name then
inline_dbs[db.name] = db
db_cache[db.name] = db
end
end
function M.parse_inline_db_comment(comment_lines)
local db_name = nil
local members = {}
for _, line in ipairs(comment_lines) do
local name = line:match("@db%s+(%w+)")
if name then
db_name = name
break
end
end
if not db_name then
return nil
end
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 = db_name,
title = "Inline DB",
version = "0.1",
comment = "",
members = members
}
end
function M.clear_cache()
for k in pairs(db_cache) do
db_cache[k] = nil
end
for k in pairs(inline_dbs) do
inline_dbs[k] = nil
end
end
function M.get_db(db_name)
return db_cache[db_name]
end
function M.get_all_db_names()
local names = {}
for name, _ in pairs(db_cache) do
table.insert(names, name)
end
return names
end
function M.get_cache_count()
local count = 0
for _ in pairs(db_cache) do
count = count + 1
end
return count
end
function M.is_db_type(type_name)
if not type_name then
return false
end
local clean_name = type_name:gsub('"', '')
return db_cache[clean_name] ~= nil or inline_dbs[clean_name] ~= nil
end
local function parse_attributes(attr_str)
if not attr_str or attr_str == "" then
return {}
end
local attrs = {}
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
function M.parse_db_content(content, filename)
local db = {
filename = filename,
members = {}
}
local db_name = content:match('DATA_BLOCK%s+"([^"]+)"')
if not db_name then
return nil, "No DATA_BLOCK definition found"
end
db.name = db_name
db.title = content:match('TITLE%s*=%s*([^\n]+)') or ""
db.title = db.title:gsub("^%s*", ""):gsub("%s*$", "")
db.version = content:match('VERSION%s*:%s*([0-9.]+)') or "0.1"
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("^%s*VAR") then
in_header = false
end
end
end
db.comment = table.concat(comment_lines, " ")
local all_var_content = {}
local var_section_pattern = "VAR%s*(.-)%s*END_VAR"
for var_section in content:gmatch(var_section_pattern) do
table.insert(all_var_content, var_section)
end
for _, var_content in ipairs(all_var_content) do
for line in var_content:gmatch("[^\r\n]+") do
line = line:gsub("^%s*", ""):gsub("%s*$", "")
if line ~= "" and not line:match("^//") then
local comment = line:match("//%s*(.+)$") or ""
line = line:gsub("//.*$", ""):gsub("%s*$", "")
local name, attrs_str, type_str = line:match('^([%w_]+)%s*(%b{})%s*:%s*([^;]+)')
if not name then
name, type_str = line:match('^([%w_]+)%s*:%s*([^;]+)')
end
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('"', '')
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(db.members, member)
end
end
end
end
return db
end
function M.parse_db_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 db, err = M.parse_db_content(content, filepath)
if not db then
return nil, err
end
db_cache[db.name] = db
return db
end
function M.get_db_members(db_name, visited)
visited = visited or {}
if not db_name then
return {}
end
db_name = db_name:gsub('"', '')
if visited[db_name] then
return {}
end
visited[db_name] = true
local db = db_cache[db_name]
if not db then
return {}
end
local members = {}
for _, member in ipairs(db.members) do
table.insert(members, member)
if member.is_udt and member.type then
member.nested_members = M.get_db_members(member.type, visited)
end
end
return members
end
function M.resolve_db_member_path(db_name, path)
if not db_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_db_name = db_name:gsub('"', '')
local current_member = nil
for i, part in ipairs(parts) do
local db = db_cache[current_db_name]
if not db then
return nil, table.concat(parts, ".", i)
end
local found = false
for _, member in ipairs(db.members) do
if member.name == part then
current_member = member
if member.is_udt then
current_db_name = member.type
else
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
function M.get_db_member_completions(db_name, prefix)
local members = M.get_db_members(db_name)
local items = {}
for _, member in ipairs(members) do
local label = prefix and (prefix .. "." .. member.name) or member.name
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
function M.get_db_type_completions(params)
local items = {}
for name, db in pairs(db_cache) do
local quoted_name = '"' .. name .. '"'
local item = {
label = quoted_name,
kind = vim.lsp.protocol.CompletionItemKind.Struct,
detail = db.title ~= "" and db.title or "Global DB",
documentation = db.comment ~= "" and db.comment or nil,
}
if params then
local line = params.context.cursor_before_line
local col = params.context.cursor.col
local start_col = col
for i = col, 1, -1 do
local char = line:sub(i, i)
if char == '"' then
start_col = i - 1
break
elseif char == ":" then
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
item.insertText = quoted_name
end
table.insert(items, item)
end
return items
end
function M.scan_buffer_for_inline_dbs(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
local content = line:gsub("^%s*//%s*", "")
table.insert(current_comment_block, content)
else
if #current_comment_block > 0 then
local db = M.parse_inline_db_comment(current_comment_block)
if db then
M.register_inline_db(db)
end
current_comment_block = {}
end
end
end
if #current_comment_block > 0 then
local db = M.parse_inline_db_comment(current_comment_block)
if db then
M.register_inline_db(db)
end
end
end
function M.debug_print_cache()
print("=== DB Cache Contents ===")
print("Total DBs: " .. M.get_cache_count())
for name, db in pairs(db_cache) do
print(string.format("DB: %s (v%s)", name, db.version))
print(string.format(" Title: %s", db.title))
print(string.format(" File: %s", db.filename or "unknown"))
print(string.format(" Members: %d", #db.members))
for _, member in ipairs(db.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
+295
View File
@@ -0,0 +1,295 @@
-- 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/OB 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+)')
else
-- Try ORGANIZATION_BLOCK
fb_name = content:match('ORGANIZATION_BLOCK%s+"([^"]+)"')
if fb_name then
fb_type = "ORGANIZATION_BLOCK"
end
end
end
if not fb_name then
return nil, "No FUNCTION_BLOCK, FUNCTION, or ORGANIZATION_BLOCK 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
+318
View File
@@ -0,0 +1,318 @@
-- Multiline parameter filling for SCL function block calls
-- Automatically expands FB calls to multi-line format with all parameters
local M = {}
--- Get interface for built-in FB types (TON, TOF, etc.)
--- @param var_type string The FB type name
--- @return table|nil Interface with inputs, outputs, inouts
local function get_builtin_interface(var_type)
local builtin = require("tia.builtin_instructions")
local clean_type = var_type:gsub('"', ''):gsub("#", ""):upper()
local params_info = builtin.get_parameters(clean_type)
if params_info then
return {
inputs = params_info.inputs or {},
outputs = params_info.outputs or {},
inouts = {},
}
end
return nil
end
--- Get interface for custom FB types from workspace
--- @param var_type string The FB type name
--- @return table|nil Interface with inputs, outputs, inouts
local function get_fb_interface(var_type)
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.get_fb_interface(clean_type)
end
--- Check if a type is a known FB type
--- @param var_type string The type name to check
--- @return boolean True if type is an FB
local function is_fb_type(var_type)
local fb_parser = require("tia.fb_parser")
local clean_type = var_type:gsub('"', '')
if fb_parser.get_cache_count() == 0 then
local ws = require("tia.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.is_fb_type(clean_type)
end
--- Get list of known local variable names
--- @param bufnr number Buffer number
--- @return table Set of variable names
local function get_known_variables(bufnr)
local scl_vars = require("tia.variables")
local variables = scl_vars.extract_variables(bufnr)
local known = {}
for _, v in ipairs(variables) do
known[v.name] = true
end
return known
end
--- Get the type of a variable by name
--- @param var_name string Variable name
--- @param bufnr number Buffer number
--- @return string|nil Variable type, or nil if not found
local function get_variable_type(var_name, bufnr)
local scl_vars = require("tia.variables")
return scl_vars.get_variable_type(var_name, bufnr)
end
--- Fill multiline parameters for FB call at cursor
--- Expands single-line call to multi-line with all parameters
function M.fill_multiline_params()
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
local col = cursor[2]
local line = vim.api.nvim_get_current_line()
local line_before_cursor = line:sub(1, col)
local known_vars = get_known_variables(bufnr)
local builtin = require("tia.builtin_instructions")
local fb_name = nil
local fb_prefix = ""
local is_builtin_function = false
local prefix, var_name = line_before_cursor:match("(#?)([%w_]+)%s*%(%s*$")
if var_name then
if known_vars[var_name] then
fb_prefix = prefix or ""
fb_name = var_name
elseif builtin.is_known_function(var_name) then
fb_prefix = prefix or ""
fb_name = var_name
is_builtin_function = true
end
end
if not fb_name then
prefix, var_name = line_before_cursor:match("(#?)([%w_]+)%s*%([^)]-,%s*$")
if var_name then
if known_vars[var_name] then
fb_prefix = prefix or ""
fb_name = var_name
elseif builtin.is_known_function(var_name) then
fb_prefix = prefix or ""
fb_name = var_name
is_builtin_function = true
end
end
end
if not fb_name then
vim.notify("No FB instance or known function found before cursor", vim.log.levels.WARN)
return
end
local fb_interface = nil
if is_builtin_function then
fb_interface = get_builtin_interface(fb_name)
else
local var_type = get_variable_type(fb_name, bufnr)
if not var_type then
vim.notify("Cannot find type for: " .. fb_name, vim.log.levels.WARN)
return
end
local clean_type = var_type:gsub('"', '')
fb_interface = get_builtin_interface(clean_type)
if not fb_interface then
if not is_fb_type(clean_type) then
vim.notify(clean_type .. " is not an FB/Function type", vim.log.levels.WARN)
return
end
fb_interface = get_fb_interface(clean_type)
end
end
if not fb_interface then
vim.notify("Cannot get interface for: " .. (clean_type or fb_name), vim.log.levels.WARN)
return
end
local params = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
table.insert(params, inp.name .. " := ")
end
for _, inout in ipairs(fb_interface.inouts or {}) do
table.insert(params, inout.name .. " := ")
end
for _, out in ipairs(fb_interface.outputs or {}) do
table.insert(params, out.name .. " => ")
end
if #params == 0 then
vim.notify("No parameters found for: " .. (clean_type or fb_name), vim.log.levels.WARN)
return
end
local indent = line:match("^%s*") or ""
local continuation_indent = indent .. string.rep(" ", #fb_name + 2)
local lines_to_insert = {}
local first_line = indent .. fb_prefix .. fb_name .. "(" .. params[1] .. ","
table.insert(lines_to_insert, first_line)
for i = 2, #params do
local p = params[i]
local closing = (i == #params) and ");" or ","
local line_content = continuation_indent .. p .. closing
table.insert(lines_to_insert, line_content)
end
vim.api.nvim_buf_set_lines(bufnr, row - 1, row, false, lines_to_insert)
local new_cursor_row = row
local new_cursor_col = #indent + #fb_prefix + #fb_name + 1 + #params[1]
vim.api.nvim_win_set_cursor(0, { new_cursor_row, new_cursor_col })
end
--- Check if cursor is inside an FB call parameter list
--- @param bufnr number Buffer number
--- @param cursor_row number Current row (1-based)
--- @param cursor_col number Current column (1-based)
--- @return boolean True if inside FB call params
local function is_inside_fb_call(bufnr, cursor_row, cursor_col)
local function get_line_text(rownum)
return vim.api.nvim_buf_get_lines(bufnr, rownum - 1, rownum, false)[1] or ""
end
local known_vars = get_known_variables(bufnr)
local paren_depth = 0
local found_fb_call_start = false
for i = cursor_row, 1, -1 do
local line = get_line_text(i)
local line_to_cursor = (i == cursor_row) and line:sub(1, cursor_col) or line
for j = #line_to_cursor, 1, -1 do
local char = line_to_cursor:sub(j, j)
if char == ")" then
paren_depth = paren_depth + 1
elseif char == "(" then
if paren_depth > 0 then
paren_depth = paren_depth - 1
else
local before_paren = line_to_cursor:sub(1, j - 1)
local var_match = before_paren:match("#?([%w_]+)%s*$")
if var_match and known_vars[var_match] then
return true
end
found_fb_call_start = true
break
end
end
end
if found_fb_call_start then
break
end
if line:match("^%s*END_") or line:match("^%s*BEGIN") then
break
end
end
return false
end
--- Jump to next parameter in FB call
--- Used for Tab navigation within parameter lists
--- @return boolean True if jumped to next param, false otherwise
function M.jump_to_next_param()
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
local col = cursor[2]
local bufnr = vim.api.nvim_get_current_buf()
if not is_inside_fb_call(bufnr, row, col) then
return false
end
local total_lines = vim.api.nvim_buf_line_count(bufnr)
local function get_line_text(rownum)
return vim.api.nvim_buf_get_lines(bufnr, rownum - 1, rownum, false)[1] or ""
end
local function find_next_param_on_line(start_row)
for i = start_row, total_lines do
local line = get_line_text(i)
local param_match = line:match("[%w_]+%s*:=") or line:match("[%w_]+%s*=>")
if param_match then
local op_end = line:find(":=") or line:find("=>")
vim.api.nvim_win_set_cursor(0, { i, op_end + 2 })
return true
end
if line:match("%)") then
break
end
end
return false
end
local current_line = get_line_text(row)
local text_after_cursor = current_line:sub(col + 1)
local next_param = text_after_cursor:match("([%w_]+)%s*:=") or text_after_cursor:match("([%w_]+)%s*=>")
if next_param then
local op_end = text_after_cursor:find(":=") or text_after_cursor:find("=>")
local new_col = col + op_end + 2
vim.api.nvim_win_set_cursor(0, { row, new_col })
return true
end
if not find_next_param_on_line(row + 1) then
local all_params = {}
local all_text = ""
for i = row, total_lines do
local l = get_line_text(i)
all_text = all_text .. l .. "\n"
if l:match("%)") then
break
end
end
for param in all_text:gmatch("([%w_]+)%s*:=") do
table.insert(all_params, param)
end
for param in all_text:gmatch("([%w_]+)%s*=>") do
table.insert(all_params, param)
end
if #all_params > 0 then
vim.api.nvim_feedkeys("> ", "m", false)
return true
end
else
return true
end
return false
end
return M
+471
View File
@@ -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
+204
View File
@@ -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
+536
View File
@@ -0,0 +1,536 @@
-- 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,
library_paths = {},
type_extensions = { "udt" },
fb_extensions = { "scl" },
db_extensions = { "db" },
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
-- Scan directory recursively for Global DB files
local function scan_for_dbs(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
if not name:match("^%.") and
name ~= "node_modules" and
name ~= ".git" and
name ~= "__pycache__" then
scan_for_dbs(full_path, depth + 1, results)
end
elseif type == "file" then
local ext = name:match("%.([^.]+)$")
if ext then
ext = ext:lower()
for _, allowed_ext in ipairs(config.db_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
-- Get all Global DB files in project
function M.get_project_db_files(bufnr)
local root = M.get_project_root(bufnr)
if not root then
return {}
end
return scan_for_dbs(root, 0)
end
-- Get Global DB files from library paths
function M.get_library_db_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_dbs(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("tia.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("tia.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
-- Parse all Global DB files in project and libraries
function M.scan_project_dbs(bufnr)
local db_parser = require("tia.db_parser")
local files = M.get_project_db_files(bufnr)
local lib_files = M.get_library_db_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 db, err = db_parser.parse_db_file(filepath)
if db 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
if opts.db_extensions then
config.db_extensions = opts.db_extensions
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("tia.udt_parser")
local fb_parser = require("tia.fb_parser")
local db_parser = require("tia.db_parser")
udt_parser.clear_cache()
fb_parser.clear_cache()
db_parser.clear_cache()
M.clear_root_cache()
M.scan_project_udts(args.buf)
M.scan_project_fbs(args.buf)
M.scan_project_dbs(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("tia.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("tia.fb_parser")
fb_parser.parse_fb_file(args.file)
end,
})
-- Watch for Global DB .db file changes
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
group = augroup,
pattern = "*.db",
callback = function(args)
local db_parser = require("tia.db_parser")
db_parser.parse_db_file(args.file)
end,
})
end
return M