- Add test/ directory with test harness and test files - Add UDT, DB, FB parser tests - Add PLC JSON and integration tests - Test against reference project (239 UDTs, 12 DBs, 37 SCL, 13 XML) - Fix attribute parsing to handle underscores (S7_SetPoint) - Add ORGANIZATION_BLOCK support to FB parser
472 lines
12 KiB
Lua
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
|