From 1606685a20814205d50d86254209741c44a85f5c Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Sat, 21 Feb 2026 00:13:52 +0100 Subject: [PATCH] feat: add hover support for member access with comments - Add member access chain resolution in hover handler - Parse #var.member.submember notation and resolve type chain - Show member comments in hover tooltip - Extract comments from UDT and DB file fields - Combine all type sources (doc.types, external_types, builtin) --- src/main.lua | 159 +++++++++++++++++++++++++++++++++++++++++++++++ src/plc_json.lua | 4 ++ 2 files changed, 163 insertions(+) diff --git a/src/main.lua b/src/main.lua index 6d69d4b..3436ca8 100644 --- a/src/main.lua +++ b/src/main.lua @@ -313,6 +313,165 @@ function handlers.textDocument_hover(params) local line = lines[line_idx] local col = params.position.character + 1 + -- Helper to resolve member path and get member info with comments + local function resolve_member_path(root_type, member_path, types) + if not root_type or not member_path or member_path == "" then + return nil + end + + local parts = {} + for part in member_path:gmatch("[^.]+") do + table.insert(parts, part) + end + + if #parts == 0 then + return nil + end + + local current_type = root_type:gsub('"', '') + local current_member = nil + + for i, part in ipairs(parts) do + local type_info = types and types[current_type] + if not type_info then + -- Try built-in types + local builtin_params = builtin.get_parameters(current_type) + if builtin_params then + local fields = builtin_params.inputs or {} + for _, field in ipairs(fields) do + if field.name == part then + current_member = field + current_type = field.type and field.type:gsub('"', '') or current_type + break + end + end + if current_member then + -- Continue to next part + else + return nil + end + else + return nil + end + else + local found = false + local fields = type_info.fields or type_info.inputs or {} + for _, field in ipairs(fields) do + if field.name == part then + current_member = field + current_type = field.type and field.type:gsub('"', '') or current_type + found = true + break + end + end + + if not found then + return nil + end + end + end + + return current_member + end + + -- Check if we're in a member access chain (e.g., #var.member1.member2) + local function find_member_access_chain() + local chain_start = col + while chain_start > 1 do + local ch = line:sub(chain_start - 1, chain_start - 1) + if ch:match("[%w_.#]") then + chain_start = chain_start - 1 + else + break + end + end + + local chain_end = col + while chain_end <= #line do + local ch = line:sub(chain_end, chain_end) + if ch:match("[%w_.]") then + chain_end = chain_end + 1 + else + break + end + end + + local chain = line:sub(chain_start, chain_end - 1) + + -- Check if this is a member access (contains .) + if not chain:match("%.") then + return nil, nil, chain_start, chain_end + end + + -- Parse the chain: #var.member1.member2 + local root_var = chain:match("#([%w_]+)") + if not root_var then + -- Try without # (for global DB access) + root_var = chain:match("^([%w_]+)%.") + end + + if root_var then + local member_path = chain:match("%.(.+)$") + return root_var, member_path, chain_start, chain_end + end + + return nil, nil, chain_start, chain_end + end + + -- Try member access first + local root_var, member_path, chain_start, chain_end = find_member_access_chain() + if root_var and member_path and doc.variables and doc.variables[root_var] then + local var_info = doc.variables[root_var] + local root_type = var_info.data_type + + if root_type then + root_type = root_type:gsub('"', '') + local all_types = {} + + -- Combine all type sources + if doc.types then + for k, v in pairs(doc.types) do + all_types[k] = v + end + end + + local external_types = plc_json.get_types(doc.root_dir or "") + for k, v in pairs(external_types) do + if not all_types[k] then + all_types[k] = v + end + end + + local member = resolve_member_path(root_type, member_path, all_types) + if member then + local detail = string.format("**Member**: `%s`\nType: `%s`", + member.name, member.type or "unknown") + if member.comment and member.comment ~= "" then + detail = detail .. "\n\n" .. member.comment + end + detail = detail .. string.format("\n\n*From: `#%s.%s`*", root_var, member_path) + + -- Find the actual word range for highlighting + local member_word_start = col + while member_word_start > 1 and line:sub(member_word_start - 1, member_word_start - 1):match("[%w_]") do + member_word_start = member_word_start - 1 + end + local member_word_end = col + while member_word_end <= #line and line:sub(member_word_end, member_word_end):match("[%w_]") do + member_word_end = member_word_end + 1 + end + + return { + contents = { kind = "markdown", value = detail }, + range = { + start = { line = params.position.line, character = member_word_start - 1 }, + ["end"] = { line = params.position.line, character = member_word_end - 1 }, + }, + } + end + end + end + local word_start = col while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do word_start = word_start - 1 diff --git a/src/plc_json.lua b/src/plc_json.lua index fe9a7c4..27797f2 100644 --- a/src/plc_json.lua +++ b/src/plc_json.lua @@ -212,9 +212,11 @@ local function parse_scl_type_file(content) local field_name = line:match("^%s*(%w+)%s*:") if field_name and field_name ~= "END_STRUCT" then local field_type = line:match(":%s*([%w_]+)") + local field_comment = line:match("//%s*(.+)$") or line:match("%(%*(.-)%*%)") table.insert(types[current_type_name].fields, { name = field_name, type = field_type or "unknown", + comment = field_comment, }) end end @@ -254,10 +256,12 @@ local function parse_db_file(content) local field_name = line:match("^%s*(%w+)%s*:") if field_name then local field_type = line:match(":%s*([%w_]+)") + local field_comment = line:match("//%s*(.+)$") or line:match("%(%*(.-)%*%)") if field_type then table.insert(types[current_type_name].fields, { name = field_name, type = field_type, + comment = field_comment, }) end end