feat(lsp): add goto declaration for FB, DB, and UDT types
- Add declaration provider (gD) that finds definitions for: - Functions in .scl files - Function Blocks (FB) in .scl files - Data Blocks (DB) in .scl and .db files - User-Defined Types (UDT) in .scl and .udt files - Implement project root detection for cross-directory searches - Add duplicate location filtering to prevent multiple results - Load UDT types from .udt files in plc_json - Update documentation with goto declaration feature
This commit is contained in:
+264
-2
@@ -62,6 +62,7 @@ local capabilities = {
|
||||
},
|
||||
definitionProvider = true,
|
||||
referencesProvider = true,
|
||||
declarationProvider = true,
|
||||
documentSymbolProvider = true,
|
||||
diagnosticProvider = { relatedDocuments = {} },
|
||||
textDocumentSync = 1,
|
||||
@@ -99,7 +100,7 @@ local function scan_scl_files(root_dir)
|
||||
if not root_dir or root_dir == "" then
|
||||
return files
|
||||
end
|
||||
local result = run_command("find " .. root_dir .. ' -type f -name "*.scl" 2>/dev/null')
|
||||
local result = run_command('find "' .. root_dir .. '" -type f -name "*.scl" 2>/dev/null')
|
||||
if result then
|
||||
for f in result:gmatch("[^\n]+") do
|
||||
if f and f ~= "" then
|
||||
@@ -165,6 +166,45 @@ function handlers.shutdown(params)
|
||||
return nil
|
||||
end
|
||||
|
||||
local project_markers = {
|
||||
"Program blocks",
|
||||
"PLC data types",
|
||||
"PLC tags",
|
||||
"program blocks",
|
||||
"plc data types",
|
||||
"plc tags",
|
||||
".git",
|
||||
}
|
||||
|
||||
local function find_project_root(start_path)
|
||||
if not start_path or start_path == "" then
|
||||
return nil
|
||||
end
|
||||
local current = start_path
|
||||
local depth = 0
|
||||
while current and current ~= "/" and current ~= "" and depth < 20 do
|
||||
local handle = io.popen('ls -a "' .. current .. '" 2>/dev/null')
|
||||
if handle then
|
||||
local entries = {}
|
||||
for line in handle:lines() do
|
||||
table.insert(entries, line)
|
||||
end
|
||||
handle:close()
|
||||
local found_marker = false
|
||||
for _, marker in ipairs(project_markers) do
|
||||
for _, entry in ipairs(entries) do
|
||||
if entry == marker then
|
||||
return current
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
current = current:match("(.*)/") or ""
|
||||
depth = depth + 1
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function handlers.exit(params)
|
||||
os.exit(0)
|
||||
end
|
||||
@@ -173,7 +213,8 @@ function handlers.textDocument_didOpen(params)
|
||||
local uri = params.textDocument.uri
|
||||
local content = params.textDocument.text
|
||||
local path = uri_to_path(uri)
|
||||
local root_dir = path:match("(.*/)") or "."
|
||||
local file_dir = path:match("(.*/)") or "."
|
||||
local root_dir = find_project_root(file_dir) or file_dir
|
||||
|
||||
local variables, var_positions = parser.extract_variables(content)
|
||||
local functions = parser.extract_functions(content)
|
||||
@@ -457,6 +498,226 @@ function handlers.textDocument_definition(params)
|
||||
return nil
|
||||
end
|
||||
|
||||
function handlers.textDocument_declaration(params)
|
||||
local uri = params.textDocument.uri
|
||||
local doc = documents[uri]
|
||||
if not doc then
|
||||
return nil
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
for line in doc.content:gmatch("([^\n]*)\n") do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
if params.position.line + 1 > #lines then
|
||||
return nil
|
||||
end
|
||||
|
||||
local line = lines[params.position.line + 1]
|
||||
local col = params.position.character + 1
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
local word_end = col
|
||||
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
|
||||
word_end = word_end + 1
|
||||
end
|
||||
|
||||
local word = line:sub(word_start, word_end - 1):gsub("^#", "")
|
||||
if not word or word == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local function make_location(doc_uri, doc_line, doc_col, name_len)
|
||||
name_len = name_len or 0
|
||||
return {
|
||||
uri = doc_uri,
|
||||
range = {
|
||||
start = { line = doc_line, character = doc_col },
|
||||
["end"] = { line = doc_line, character = doc_col + name_len },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local locations = {}
|
||||
local seen_locations = {}
|
||||
local search_names = { word }
|
||||
|
||||
local function add_location(loc)
|
||||
local key = loc.uri .. ":" .. tostring(loc.range.start.line) .. ":" .. tostring(loc.range.start.character)
|
||||
if not seen_locations[key] then
|
||||
seen_locations[key] = true
|
||||
table.insert(locations, loc)
|
||||
end
|
||||
end
|
||||
|
||||
if doc.variables and doc.variables[word] then
|
||||
local var_type = doc.variables[word].data_type
|
||||
if var_type then
|
||||
var_type = var_type:gsub("%s*;.*$", ""):gsub("%s*:=.*$", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local type_name = var_type:match("([%w_]+)")
|
||||
if type_name and type_name ~= word then
|
||||
table.insert(search_names, type_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for check_uri, check_doc in pairs(documents) do
|
||||
if check_doc.functions then
|
||||
for _, func in ipairs(check_doc.functions) do
|
||||
for _, search_name in ipairs(search_names) do
|
||||
if func.name == search_name then
|
||||
add_location(make_location(check_uri, func.start, 0, #search_name))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local root_dirs = {}
|
||||
for _, d in pairs(documents) do
|
||||
if d.root_dir and d.root_dir ~= "" then
|
||||
root_dirs[d.root_dir] = true
|
||||
end
|
||||
end
|
||||
|
||||
for root_dir, _ in pairs(root_dirs) do
|
||||
local scl_files = scan_scl_files(root_dir)
|
||||
for _, filepath in ipairs(scl_files) do
|
||||
local cached = workspace_symbol_cache[":file:" .. filepath]
|
||||
if not cached then
|
||||
local variables, functions, types = parse_scl_file(filepath)
|
||||
cached = { functions = functions, types = types }
|
||||
workspace_symbol_cache[":file:" .. filepath] = cached
|
||||
end
|
||||
|
||||
if cached.functions then
|
||||
for _, func in ipairs(cached.functions) do
|
||||
for _, search_name in ipairs(search_names) do
|
||||
if func.name == search_name then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
add_location(make_location(file_uri, func.start, 0, #search_name))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local handle = io.open(filepath, "r")
|
||||
if handle then
|
||||
local content = handle:read("*a")
|
||||
handle:close()
|
||||
for _, search_name in ipairs(search_names) do
|
||||
local fb_pattern = 'FUNCTION_BLOCK%s+"' .. search_name .. '"'
|
||||
local fb_plain = "^FUNCTION_BLOCK%s+" .. search_name .. "$"
|
||||
if content:match(fb_pattern) or content:match(fb_plain) then
|
||||
for line in content:gmatch("([^\n]*)\n") do
|
||||
if line:match(fb_pattern) or line:match(fb_plain) then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
add_location(make_location(file_uri, 0, 0, #search_name))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local db_pattern = 'DATA_BLOCK%s+"' .. search_name .. '"'
|
||||
local db_plain = "^DATA_BLOCK%s+" .. search_name .. "$"
|
||||
if content:match(db_pattern) or content:match(db_plain) then
|
||||
for line in content:gmatch("([^\n]*)\n") do
|
||||
if line:match(db_pattern) or line:match(db_plain) then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
add_location(make_location(file_uri, 0, 0, #search_name))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if cached.types then
|
||||
for type_name, type_info in pairs(cached.types) do
|
||||
for _, search_name in ipairs(search_names) do
|
||||
if type_name == search_name then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
local start_line = type_info.start_line or 0
|
||||
add_location(make_location(file_uri, start_line, 0, #search_name))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for root_dir, _ in pairs(root_dirs) do
|
||||
local udt_result = run_command('find "' .. root_dir .. '" -type f -name "*.udt" 2>/dev/null')
|
||||
if udt_result then
|
||||
for filepath in udt_result:gmatch("[^\n]+") do
|
||||
if filepath and filepath ~= "" then
|
||||
local handle = io.open(filepath, "r")
|
||||
if handle then
|
||||
local file_content = handle:read("*a")
|
||||
handle:close()
|
||||
for _, search_name in ipairs(search_names) do
|
||||
local type_line = file_content:match('TYPE%s+"' .. search_name .. '"')
|
||||
if not type_line then
|
||||
type_line = file_content:match("TYPE%s+" .. search_name)
|
||||
end
|
||||
if type_line then
|
||||
local line_num = 1
|
||||
for line in file_content:gmatch("([^\n]*)\n?") do
|
||||
if line:match('TYPE%s+"' .. search_name .. '"') or line:match("TYPE%s+" .. search_name) then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
add_location(make_location(file_uri, line_num - 1, 0, #search_name))
|
||||
break
|
||||
end
|
||||
line_num = line_num + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local db_result = run_command('find "' .. root_dir .. '" -type f -name "*.db" 2>/dev/null')
|
||||
if db_result then
|
||||
for filepath in db_result:gmatch("[^\n]+") do
|
||||
if filepath and filepath ~= "" then
|
||||
local handle = io.open(filepath, "r")
|
||||
if handle then
|
||||
local file_content = handle:read("*a")
|
||||
handle:close()
|
||||
for _, search_name in ipairs(search_names) do
|
||||
local db_line = file_content:match('DATA_BLOCK%s+"' .. search_name .. '"')
|
||||
if not db_line then
|
||||
db_line = file_content:match("DATA_BLOCK%s+" .. search_name)
|
||||
end
|
||||
if db_line then
|
||||
local line_num = 1
|
||||
for line in file_content:gmatch("([^\n]*)\n?") do
|
||||
if line:match('DATA_BLOCK%s+"' .. search_name .. '"') or line:match("DATA_BLOCK%s+" .. search_name) then
|
||||
local file_uri = path_to_uri(filepath)
|
||||
add_location(make_location(file_uri, line_num - 1, 0, #search_name))
|
||||
break
|
||||
end
|
||||
line_num = line_num + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #locations > 0 then
|
||||
return locations
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function handlers.textDocument_references(params)
|
||||
local uri = params.textDocument.uri
|
||||
local doc = documents[uri]
|
||||
@@ -923,6 +1184,7 @@ local function handle_message(message)
|
||||
if message.method == "$/cancelRequest" then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Convert method name from "textDocument/didOpen" to "textDocument_didOpen"
|
||||
local method_name = message.method:gsub("/", "_")
|
||||
local handler = handlers[method_name]
|
||||
|
||||
+7
-7
@@ -17,22 +17,22 @@ function M.extract_variables(content)
|
||||
local function process_line(line, line_num)
|
||||
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
|
||||
-- Detect block start
|
||||
local fb_name = trimmed:match('FUNCTION_BLOCK%s+"([^"]+)"')
|
||||
-- Detect block start (with or without quotes)
|
||||
local fb_name = trimmed:match('FUNCTION_BLOCK%s+"([^"]+)"') or trimmed:match("^FUNCTION_BLOCK%s+([%w_]+)")
|
||||
if fb_name then
|
||||
current_block = { name = fb_name, kind = "function_block" }
|
||||
in_var_section = false
|
||||
return
|
||||
end
|
||||
|
||||
local ob_name = trimmed:match('ORGANIZATION_BLOCK%s+"([^"]+)"')
|
||||
local ob_name = trimmed:match('ORGANIZATION_BLOCK%s+"([^"]+)"') or trimmed:match("^ORGANIZATION_BLOCK%s+([%w_]+)")
|
||||
if ob_name then
|
||||
current_block = { name = ob_name, kind = "organization_block" }
|
||||
in_var_section = false
|
||||
return
|
||||
end
|
||||
|
||||
local fn_name = trimmed:match('FUNCTION%s+"([^"]+)"')
|
||||
local fn_name = trimmed:match('FUNCTION%s+"([^"]+)"') or trimmed:match("^FUNCTION%s+([%w_]+)")
|
||||
if fn_name then
|
||||
current_block = { name = fn_name, kind = "function" }
|
||||
in_var_section = false
|
||||
@@ -239,7 +239,7 @@ function M.extract_functions(content)
|
||||
end
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
local func_name = line:match('FUNCTION%s+"([^"]+)"')
|
||||
local func_name = line:match('FUNCTION%s+"([^"]+)"') or line:match("^FUNCTION%s+([%w_]+)")
|
||||
if func_name then
|
||||
local end_line = find_block_end(i, "END_FUNCTION", { "END_FUNCTION" })
|
||||
table.insert(functions, {
|
||||
@@ -250,7 +250,7 @@ function M.extract_functions(content)
|
||||
})
|
||||
end
|
||||
|
||||
local fb_name = line:match('FUNCTION_BLOCK%s+"([^"]+)"')
|
||||
local fb_name = line:match('FUNCTION_BLOCK%s+"([^"]+)"') or line:match("^FUNCTION_BLOCK%s+([%w_]+)")
|
||||
if fb_name then
|
||||
local end_line = find_block_end(i, "END_FUNCTION_BLOCK", { "END_FUNCTION_BLOCK" })
|
||||
table.insert(functions, {
|
||||
@@ -261,7 +261,7 @@ function M.extract_functions(content)
|
||||
})
|
||||
end
|
||||
|
||||
local ob_name = line:match('ORGANIZATION_BLOCK%s+"([^"]+)"')
|
||||
local ob_name = line:match('ORGANIZATION_BLOCK%s+"([^"]+)"') or line:match("^ORGANIZATION_BLOCK%s+([%w_]+)")
|
||||
if ob_name then
|
||||
local end_line = find_block_end(i, "END_ORGANIZATION_BLOCK", { "END_ORGANIZATION_BLOCK" })
|
||||
table.insert(functions, {
|
||||
|
||||
@@ -198,6 +198,21 @@ function M.load_types_from_workspace(root_dir)
|
||||
end
|
||||
end
|
||||
|
||||
-- Also scan for .udt files
|
||||
local udt_files = scan_files_recursive(root_dir, "*.udt")
|
||||
for _, filepath in ipairs(udt_files) do
|
||||
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
|
||||
if content then
|
||||
local udt_types = parse_scl_type_file(content)
|
||||
for name, typ in pairs(udt_types) do
|
||||
typ.source = "udt_file"
|
||||
if not types[name] then
|
||||
types[name] = typ
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return types
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user