#!/usr/bin/env lua -- SCL Language Server -- Language Server Protocol implementation for Siemens SCL local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "" if script_path == "" then script_path = "." end local cwd = io.popen("pwd"):read("*l") if script_path:sub(1, 1) == "/" then script_path = script_path:gsub("/+$", "") elseif cwd then script_path = cwd .. "/" .. script_path script_path = script_path:gsub("/+", "/"):gsub("/$", "") else script_path = "." end package.path = package.path .. ";" .. script_path .. "/?.lua" local json = dofile(script_path .. "/json.lua") local parser = dofile(script_path .. "/parser.lua") local treesitter = dofile(script_path .. "/treesitter.lua") local plc_json = dofile(script_path .. "/plc_json.lua") local diagnostics = dofile(script_path .. "/diagnostics.lua") local formatter = dofile(script_path .. "/formatter.lua") local builtin = dofile(script_path .. "/builtin_instructions.lua") local documents = {} local semanticTokenModifiers = {} local semanticTokenTypes = { "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "func", "method", "macro", "keyword", "modifier", "comment", "string", "number", "operator", } local capabilities = { hoverProvider = true, completionProvider = { triggerCharacters = { ".", "#", "(", " ", ":", ";", "\n" }, resolveProvider = false, }, definitionProvider = true, referencesProvider = true, declarationProvider = true, documentSymbolProvider = true, diagnosticProvider = { relatedDocuments = {} }, textDocumentSync = 1, semanticTokensProvider = { full = false, -- Disabled: was causing "Invalid line: out of range" errors legend = { tokenTypes = semanticTokenTypes, tokenModifiers = semanticTokenModifiers }, range = false, }, inlayHintProvider = { resolveProvider = false }, workspaceSymbolProvider = true, documentFormattingProvider = true, documentRangeFormattingProvider = false, } local function uri_to_path(uri) return uri:gsub("^file://", ""):gsub("%%20", " ") end local function path_to_uri(path) return "file://" .. path:gsub(" ", "%%20") end local function run_command(cmd) local handle = io.popen(cmd) if not handle then return nil end local result = handle:read("*a") handle:close() return result end local function scan_scl_files(root_dir) local files = {} 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') if result then for f in result:gmatch("[^\n]+") do if f and f ~= "" then table.insert(files, f) end end end return files end local function parse_scl_file(filepath) local handle = io.open(filepath, "r") if not handle then return nil, nil, nil end local content = handle:read("*a") handle:close() local variables = parser.extract_variables(content) local functions = parser.extract_functions(content) local types = parser.extract_types(content) return variables, functions, types end local workspace_symbol_cache = {} local function clear_symbol_cache() workspace_symbol_cache = {} end local function fuzzy_match(text, pattern) if not text or not pattern then return false end text = text:lower() pattern = pattern:lower() local text_len = #text local pattern_len = #pattern local pattern_idx = 1 for i = 1, text_len do if pattern_idx <= pattern_len and text:sub(i, i) == pattern:sub(pattern_idx, pattern_idx) then pattern_idx = pattern_idx + 1 end end return pattern_idx > pattern_len end local handlers = {} function handlers.initialize(params) return { capabilities = capabilities, serverInfo = { name = "scl-language-server", version = "1.0.0" }, } end function handlers.initialized(params) end 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 function handlers.textDocument_didOpen(params) local uri = params.textDocument.uri local content = params.textDocument.text local path = uri_to_path(uri) 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) local types = parser.extract_types(content) local external_types = plc_json.get_types(root_dir) for name, ext_type in pairs(external_types) do if not types[name] then types[name] = ext_type end end local tree = nil local ts_ok = treesitter.init() if ts_ok then tree, _ = treesitter.parse_scl(content) end documents[uri] = { content = content, version = params.textDocument.version, path = path, root_dir = root_dir, variables = variables, variable_positions = var_positions, functions = functions, types = types, tree = tree, } end function handlers.textDocument_didChange(params) local uri = params.textDocument.uri local content = params.contentChanges[1].text local variables, var_positions = parser.extract_variables(content) local functions = parser.extract_functions(content) local types = parser.extract_types(content) local doc = documents[uri] local root_dir = doc and doc.root_dir or "" local external_types = plc_json.get_types(root_dir) for name, ext_type in pairs(external_types) do if not types[name] then types[name] = ext_type end end local tree = nil local ts_ok = treesitter.init() if ts_ok then tree, _ = treesitter.parse_scl(content) end documents[uri] = { content = content, version = params.textDocument.version, path = doc and doc.path or "", root_dir = root_dir, variables = variables, variable_positions = var_positions, functions = functions, types = types, tree = tree, } clear_symbol_cache() end function handlers.textDocument_didClose(params) documents[params.textDocument.uri] = nil clear_symbol_cache() end function handlers.textDocument_hover(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 #lines == 0 then return nil end local line_idx = params.position.line + 1 if line_idx > #lines then return nil end 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 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) local var_name = word:gsub("^#", "") if var_name ~= "" and doc.variables and doc.variables[var_name] then local var_info = doc.variables[var_name] local detail = string.format( "**Local Variable** `#%s`\nScope: `%s`\nType: `%s`\nDeclared at line %d", var_name, var_info.type, var_info.data_type or "unknown", var_info.line + 1 ) if var_info.comment and var_info.comment ~= "" then detail = detail .. "\n\n" .. var_info.comment end if var_info.data_type then local builtin_params = builtin.get_parameters(var_info.data_type) if builtin_params then detail = detail .. "\n\n**Members:**\n" if builtin_params.inputs and #builtin_params.inputs > 0 then detail = detail .. "*Inputs:*\n" for _, inp in ipairs(builtin_params.inputs) do detail = detail .. string.format(" - `%s`: %s\n", inp.name, inp.type or "ANY") end end if builtin_params.outputs and #builtin_params.outputs > 0 then detail = detail .. "*Outputs:*\n" for _, out in ipairs(builtin_params.outputs) do detail = detail .. string.format(" - `%s`: %s\n", out.name, out.type or "ANY") end end end end return { contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end if doc.types then for type_name, type_info in pairs(doc.types) do if type_name == word then local detail = string.format("**Type**: `%s`\nKind: %s", type_name, type_info.kind) if type_info.comment and type_info.comment ~= "" then detail = detail .. "\n\n" .. type_info.comment end if type_info.kind == "struct" and type_info.fields then detail = detail .. "\n\n**Fields:**\n" for _, field in ipairs(type_info.fields) do if field.comment and field.comment ~= "" then detail = detail .. string.format("- `%s`: %s // %s\n", field.name, field.type, field.comment) else detail = detail .. string.format("- `%s`: %s\n", field.name, field.type) end end end return { contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end end end if builtin.is_known_function_block(word) or builtin.is_known_function(word) then local params_info = builtin.get_parameters(word) local kind = builtin.is_known_function_block(word) and "Function Block" or "Function" local detail = string.format("**%s**: `%s`\n", kind, word) if params_info then detail = detail .. "\n**Parameters:**\n" if params_info.inputs and #params_info.inputs > 0 then detail = detail .. "*Inputs:*\n" for _, inp in ipairs(params_info.inputs) do detail = detail .. string.format(" - `%s`: %s\n", inp.name, inp.type or "ANY") end end if params_info.outputs and #params_info.outputs > 0 then detail = detail .. "*Outputs:*\n" for _, out in ipairs(params_info.outputs) do detail = detail .. string.format(" - `%s`: %s\n", out.name, out.type or "ANY") end end else detail = detail .. "\n_(No parameter info available)_" end return { contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end local fb_param_info = nil local function find_fb_call_start(lines, current_line_idx) for i = current_line_idx, 1, -1 do local l = lines[i] local fb_name = l:match("#([%w_]+)%s*%(") or l:match("([%w_]+)%s*%(") if fb_name and doc.variables and doc.variables[fb_name] then return fb_name, i end if l:match("%)%s*;%s*$") then break end end return nil, nil end local fb_name_on_line, _ = find_fb_call_start(lines, line_idx) if fb_name_on_line then local fb_var = doc.variables[fb_name_on_line] if fb_var then local fb_type = fb_var.data_type if fb_type then local clean_type = fb_type:gsub('"', ""):gsub("#", "") local params_info = builtin.get_parameters(clean_type) if not params_info then local workspace_types = plc_json.get_types(doc.root_dir) if workspace_types and workspace_types[clean_type] then local ws_type = workspace_types[clean_type] if ws_type.kind == "function_block" or ws_type.kind == "function" then local fields = {} for _, f in ipairs(ws_type.inputs or {}) do table.insert(fields, { name = f.name, type = f.type, comment = f.comment }) end params_info = { inputs = fields, outputs = ws_type.outputs or {} } elseif ws_type.fields then local fields = {} for _, f in ipairs(ws_type.fields) do table.insert(fields, { name = f.name, type = f.type, comment = f.comment }) end params_info = { inputs = fields, outputs = {} } end end end if not params_info and doc.types and doc.types[clean_type] and doc.types[clean_type].fields then local fields = {} for _, f in ipairs(doc.types[clean_type].fields) do table.insert(fields, { name = f.name, type = f.type, comment = f.comment }) end params_info = { inputs = fields, outputs = {} } end if params_info then for _, inp in ipairs(params_info.inputs or {}) do if inp.name:upper() == word:upper() then fb_param_info = { name = inp.name, type = inp.type, direction = "input", comment = inp.comment } break end end if not fb_param_info then for _, out in ipairs(params_info.outputs or {}) do if out.name:upper() == word:upper() then fb_param_info = { name = out.name, type = out.type, direction = "output", comment = out.comment } break end end end end end end end if fb_param_info then local detail = string.format("**Parameter**: `%s`\nType: `%s`\nDirection: %s", fb_param_info.name, fb_param_info.type or "ANY", fb_param_info.direction) if fb_param_info.comment and fb_param_info.comment ~= "" then detail = detail .. "\n\n" .. fb_param_info.comment end return { contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end return nil end function handlers.textDocument_completion(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return { items = {} } end local items = {} local lines = {} for line in doc.content:gmatch("([^\n]*)\n") do table.insert(lines, line) end local line_idx = params.position.line + 1 if line_idx <= #lines then local line = lines[line_idx] local col = params.position.character local trigger = col > 0 and line:sub(col, col) or "" local line_before = col > 1 and line:sub(1, col - 1) or "" -- Get word prefix being typed (for filtering) local prefix_start = col - 1 while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do prefix_start = prefix_start - 1 end local word_prefix = line:sub(prefix_start + 1, col - 1) -- Check if we're in VAR section local in_var_section = false for i = line_idx - 1, 1, -1 do local prev_line = lines[i]:gsub("^%s+", ""):gsub("%s+$", "") if prev_line:match("^END_VAR") or prev_line:match("^VAR_TEMP") or prev_line:match("^VAR_IN_OUT") or prev_line:match("^VAR_OUTPUT") or prev_line:match("^VAR_INPUT") then break end if prev_line:match("^VAR%s*$") or prev_line:match("^VAR$") then in_var_section = true break end end if trigger == "#" then if doc.variables then for name, info in pairs(doc.variables) do table.insert(items, { label = "#" .. name, kind = 13, detail = "Local variable (" .. (info.type or "unknown") .. ")", insertText = "#" .. name, insertTextFormat = 1, }) end end elseif trigger == "." then local prefix_start = col - 1 while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do prefix_start = prefix_start - 1 end local prefix = line:sub(prefix_start + 1, col - 1) if prefix ~= "" then local var_name = prefix:gsub("^#", "") local var_info = doc.variables and doc.variables[var_name] local data_type = var_info and var_info.data_type if data_type then local struct_name = data_type if doc.types and doc.types[struct_name] and doc.types[struct_name].fields then for _, field in ipairs(doc.types[struct_name].fields) do table.insert(items, { label = field.name, kind = 13, detail = struct_name .. "." .. field.name .. ": " .. field.type, insertText = field.name, insertTextFormat = 1, }) end else local builtin_params = builtin.get_parameters(struct_name) if builtin_params then for _, inp in ipairs(builtin_params.inputs or {}) do table.insert(items, { label = inp.name, kind = 13, detail = struct_name .. "." .. inp.name .. ": " .. (inp.type or "ANY"), insertText = inp.name, insertTextFormat = 1, }) end for _, out in ipairs(builtin_params.outputs or {}) do table.insert(items, { label = out.name, kind = 13, detail = struct_name .. "." .. out.name .. ": " .. (out.type or "ANY"), insertText = out.name, insertTextFormat = 1, }) end end end end end if #items == 0 then local keywords = { { label = "AND", detail = "Logical AND", kind = 14 }, { label = "OR", detail = "Logical OR", kind = 14 }, { label = "XOR", detail = "Logical XOR", kind = 14 }, { label = "NOT", detail = "Logical NOT", kind = 14 }, { label = "MOD", detail = "Modulo", kind = 14 }, } for _, kw in ipairs(keywords) do table.insert(items, kw) end end elseif trigger == "(" then if doc.functions then for _, func in ipairs(doc.functions) do table.insert(items, { label = func.name, kind = 3, detail = func.kind:upper(), insertText = func.name .. "()", insertTextFormat = 2, }) end end else -- Check context before cursor local is_var_decl = line_before:match(":%s*$") -- Helper to add item with prefix filtering local function add_item(name, kind_val, detail_text) if word_prefix == "" or name:upper():find("^" .. word_prefix:upper()) then table.insert(items, { label = name, kind = kind_val, detail = detail_text, insertText = name, insertTextFormat = 1, }) end end if in_var_section or is_var_decl then -- In VAR section: show data types and function blocks for type_name, _ in pairs(builtin.DATA_TYPES) do add_item(type_name, 7, "Data type") end for fb_name, _ in pairs(builtin.FUNCTION_BLOCKS) do add_item(fb_name, 6, "Function block") end -- Also add workspace types if available if doc.types then for type_name, type_info in pairs(doc.types) do add_item(type_name, 7, type_info.kind or "Type") end end else -- General context: show all for type_name, _ in pairs(builtin.DATA_TYPES) do add_item(type_name, 7, "Data type") end for fb_name, _ in pairs(builtin.FUNCTION_BLOCKS) do add_item(fb_name, 6, "Function block") end for func_name, _ in pairs(builtin.FUNCTIONS) do add_item(func_name, 3, "Function") end end end end return { isIncomplete = false, items = items } end function handlers.textDocument_definition(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 doc.variable_positions and doc.variable_positions[word] then local pos = doc.variable_positions[word] return { uri = uri, range = { start = { line = pos.line, character = pos.character }, ["end"] = { line = pos.line, character = pos.character + #word }, }, } end 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('"([^"]+)"') or 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] 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 references = {} for line_num, line_content in ipairs(lines) do local search_col = 1 while search_col <= #line_content do local match_start, match_end = line_content:find("[%w_]+", search_col) if not match_start then break end local matched_word = line_content:sub(match_start, match_end) if matched_word == word then table.insert(references, { uri = uri, range = { start = { line = line_num - 1, character = match_start - 1 }, ["end"] = { line = line_num - 1, character = match_end }, }, }) end search_col = match_end + 1 end end if #references > 0 then return references end return nil end function handlers.textDocument_documentSymbol(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return {} end local symbols = {} local kind_map = { ["function"] = 14, ["function_block"] = 11, ["organization_block"] = 25, ["struct"] = 23, ["alias"] = 7, ["array"] = 24, } if doc.functions then for _, func in ipairs(doc.functions) do table.insert(symbols, { name = func.kind:upper() .. " " .. func.name, kind = kind_map[func.kind] or 14, range = { start = { line = func.start, character = 0 }, ["end"] = { line = func.final, character = 0 } }, selectionRange = { start = { line = func.start, character = 0 }, ["end"] = { line = func.start, character = #func.name }, }, containerName = nil, }) end end if doc.variables then for var_name, var_info in pairs(doc.variables) do local var_kind = 13 if var_info.type and (var_info.type:find("CONSTANT") or var_info.type:find("RETAIN")) then var_kind = 26 end table.insert(symbols, { name = "#" .. var_name, kind = var_kind, detail = var_info.type or "VAR", range = { start = { line = var_info.line, character = 0 }, ["end"] = { line = var_info.line, character = #var_name }, }, selectionRange = { start = { line = var_info.line, character = var_info.character }, ["end"] = { line = var_info.line, character = var_info.character + #var_name }, }, containerName = nil, }) end end if doc.types then for type_name, type_info in pairs(doc.types) do local kind = kind_map[type_info.kind] or 7 local detail = type_info.kind:upper() if type_info.kind == "alias" and type_info.base_type then detail = detail .. " (" .. type_info.base_type .. ")" elseif type_info.kind == "struct" and type_info.fields and #type_info.fields > 0 then detail = detail .. " (" .. #type_info.fields .. " fields)" end table.insert(symbols, { name = "TYPE " .. type_name, kind = kind, detail = detail, range = { start = { line = type_info.start_line or 0, character = 0 }, ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, selectionRange = { start = { line = type_info.start_line or 0, character = 0 }, ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, containerName = nil, }) end end return symbols end function handlers.textDocument_diagnostic(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return { items = {} } end local external_types = {} if doc.root_dir then external_types = plc_json.get_types(doc.root_dir) end local diag_results = diagnostics.validate_diagnostics(doc.content, external_types, doc.root_dir) return { items = diag_results } end function handlers.textDocument_semanticTokensFull(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return { data = {} } end -- Protected token collection local tokens = {} local ok, err = pcall(function() local lines = {} for line in doc.content:gmatch("([^\n]*)\n") do table.insert(lines, line) end local total_lines = #lines local function add_token(line, startChar, length) -- Validate all parameters if not line or not startChar or not length then return end if line < 0 or line >= total_lines then return end if startChar < 0 or length <= 0 then return end if startChar + length > 10000 then return end local deltaLine = line local deltaStart = startChar if #tokens >= 4 then local prevLine = tokens[#tokens - 3] local prevStart = tokens[#tokens - 2] if line == prevLine then deltaLine = 0 deltaStart = startChar - prevStart else deltaLine = line - prevLine end -- Validate deltaStart is non-negative if deltaStart < 0 then return end end table.insert(tokens, deltaLine) table.insert(tokens, deltaStart) table.insert(tokens, length) table.insert(tokens, 0) table.insert(tokens, 0) end for i, line in ipairs(lines) do local lineNum = i - 1 local trimmed = line:gsub("^%s+", "") if doc.variables then for var_name, info in pairs(doc.variables) do if info and info.line and info.line >= 0 and info.line < total_lines and info.line == lineNum then local startChar = line:find(var_name, 1, true) if startChar then add_token(lineNum, startChar - 1, #var_name) end end end end local keywords = { "IF", "THEN", "ELSIF", "ELSE", "END_IF", "FOR", "TO", "BY", "DO", "END_FOR", "WHILE", "END_WHILE", "REPEAT", "UNTIL", "END_REPEAT", "CASE", "OF", "END_CASE", "VAR_INPUT", "VAR_OUTPUT", "VAR_IN_OUT", "VAR_TEMP", "VAR", "END_VAR", "BEGIN", "EXIT", "CONTINUE", "RETURN", "TRUE", "FALSE", } for _, kw in ipairs(keywords) do local startPos = line:find("%f[%a]" .. kw .. "%f[%A]") if startPos then add_token(lineNum, startPos - 1, #kw) end end end end) -- If any error occurred, return empty tokens if not ok then return { data = {} } end return { data = tokens } end function handlers.textDocument_semanticTokensRange(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return { data = {} } end return handlers.textDocument_semanticTokensFull(params) end -- Aliases for method names with slashes converted to underscores handlers["textDocument_semanticTokens_full"] = handlers.textDocument_semanticTokensFull handlers["textDocument_semanticTokens_range"] = handlers.textDocument_semanticTokensRange function handlers.textDocument_inlayHint(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return {} end local hints = {} local lines = {} for line in doc.content:gmatch("([^\n]*)\n") do table.insert(lines, line) end local total_lines = #lines for i, line in ipairs(lines) do local lineNum = i - 1 if doc.variables then for var_name, info in pairs(doc.variables) do -- Validate line number is within bounds if info.line and info.line >= 0 and info.line < total_lines and info.line == lineNum and info.data_type and info.data_type ~= "unknown" then local colonPos = line:find(":", 1, true) if colonPos then table.insert(hints, { position = { line = lineNum, character = #line }, label = ": " .. info.data_type, kind = 1, paddingLeft = true, }) end end end end end return hints end function handlers.workspace_symbol(params) local query = params.query or "" local max_results = params.maxResults or 50 if query == "" then return {} end local results = {} local function add_result(name, kind, uri, line, col, length) table.insert(results, { name = name, kind = kind, location = { uri = uri, range = { start = { line = line, character = col }, ["end"] = { line = line, character = col + length }, }, }, }) end for uri, doc in pairs(documents) do if doc.functions then for _, func in ipairs(doc.functions) do if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then add_result( func.kind:upper() .. " " .. func.name, func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25), uri, func.start, 0, #func.name ) end end end if doc.variables then for var_name, var_info in pairs(doc.variables) do if fuzzy_match(var_name, query) then add_result("#" .. var_name, 13, uri, var_info.line, var_info.character, #var_name) end end end if doc.types then for type_name, type_info in pairs(doc.types) do if fuzzy_match(type_name, query) then local kind = 7 if type_info.kind == "struct" then kind = 23 elseif type_info.kind == "array" then kind = 24 end add_result("TYPE " .. type_name, kind, uri, type_info.start_line or 0, 0, #type_name) end end end end local scanned_dirs = {} for _, doc in pairs(documents) do if doc.root_dir and doc.root_dir ~= "" then scanned_dirs[doc.root_dir] = true end end local function search_file(filepath) local cached_file = filepath if not workspace_symbol_cache[":file:" .. cached_file] then local variables, functions, types = parse_scl_file(filepath) workspace_symbol_cache[":file:" .. cached_file] = { variables = variables, functions = functions, types = types, } end local cached_data = workspace_symbol_cache[":file:" .. cached_file] local file_uri = path_to_uri(filepath) if cached_data.functions then for _, func in ipairs(cached_data.functions) do if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then add_result( func.kind:upper() .. " " .. func.name, func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25), file_uri, func.start, 0, #func.name ) end end end if cached_data.variables then for var_name, var_info in pairs(cached_data.variables) do if fuzzy_match(var_name, query) then add_result("#" .. var_name, 13, file_uri, var_info.line, var_info.character, #var_name) end end end if cached_data.types then for type_name, type_info in pairs(cached_data.types) do if fuzzy_match(type_name, query) then local kind = 7 if type_info.kind == "struct" then kind = 23 elseif type_info.kind == "array" then kind = 24 end add_result("TYPE " .. type_name, kind, file_uri, type_info.start_line or 0, 0, #type_name) end end end end for root_dir, _ in pairs(scanned_dirs) do local scl_files = scan_scl_files(root_dir) for _, filepath in ipairs(scl_files) do search_file(filepath) end end local final_results = {} for i = 1, math.min(#results, max_results) do table.insert(final_results, results[i]) end return final_results end function handlers.textDocument_formatting(params) local uri = params.textDocument.uri local doc = documents[uri] if not doc then return nil end local options = params.options or {} local format_options = { insertSpaces = options.insertSpaces, tabSize = options.tabSize, indentSize = options.tabSize or 4, } return formatter.format_document(doc.content, format_options) end 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] if handler then local ok, result = pcall(handler, message.params) -- Only return response for requests (have id), not notifications if message.id then if ok then return { id = message.id, result = result } else return { id = message.id, error = { code = -32603, message = tostring(result) } } end end else -- Only return error for requests (have id) if message.id then return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } } end end return nil end local function main() local stdin = io.input() local stdout = io.output() local content_length = nil while true do local line = stdin:read("*l") if not line then break end -- Strip trailing \r (Windows line endings) line = line:gsub("\r$", "") if line:match("^Content%-Length:%s*(%d+)") then content_length = tonumber(line:match("^Content%-Length:%s*(%d+)")) elseif line == "" then if content_length then local body = stdin:read(content_length) local message = json.decode(body) if message.method == "exit" then break end local response = handle_message(message) if response then local response_body = json.encode(response) stdout:write("Content-Length: " .. #response_body .. "\r\n") stdout:write("Content-Type: application/json\r\n\r\n") stdout:write(response_body) stdout:flush() end content_length = nil end end end end main()