diff --git a/AGENTS.md b/AGENTS.md index d0c9551..1fbea2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,17 @@ Rules are checked in order; first match wins. | `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer | | `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks | +## LSP Features + +### Goto Declaration (`gD`) +The LSP supports goto declaration functionality for: +- **Functions** - Jump to function definition in `.scl` files +- **Function Blocks (FB)** - Jump to FB definition in `.scl` files +- **Data Blocks (DB)** - Jump to DB definition in `.scl` and `.db` files +- **User-Defined Types (UDT)** - Jump to type definition in `.scl` and `.udt` files + +Usage: Place cursor on a variable or type name and press `gD` (or use LSP client command). + ## Keybindings (SCL/UDT files) ### Attribute Block Toggle diff --git a/src/main.lua b/src/main.lua index 1f5bde7..9853157 100644 --- a/src/main.lua +++ b/src/main.lua @@ -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] diff --git a/src/parser.lua b/src/parser.lua index 936f4d5..06bb44c 100644 --- a/src/parser.lua +++ b/src/parser.lua @@ -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, { diff --git a/src/plc_json.lua b/src/plc_json.lua index 433f41a..e4833db 100644 --- a/src/plc_json.lua +++ b/src/plc_json.lua @@ -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