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