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

472 lines
12 KiB
Lua

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