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

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

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

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

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

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
2026-07-18 11:30:55 +02:00

296 lines
7.6 KiB
Lua

-- 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