diff --git a/AGENTS.md b/AGENTS.md index 9080c69..6317527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ return M - No trailing whitespace ### Comments -**DO NOT ADD COMMENTS** in code unless explicitly requested by the user. +Add comments to explain non-obvious code sections, complex logic, and public API functions. Keep comments concise and relevant. ### Parser Module API All parser modules must implement: diff --git a/src/builtin_instructions.lua b/src/builtin_instructions.lua index 0d3c730..73f0417 100644 --- a/src/builtin_instructions.lua +++ b/src/builtin_instructions.lua @@ -1,5 +1,9 @@ +-- TIA Portal built-in instructions for SCL LSP +-- Defines functions, function blocks, data types, and their parameters + local M = {} +-- Built-in functions (return values, no state) M.FUNCTIONS = { ADD = true, SUB = true, @@ -169,6 +173,7 @@ M.FUNCTIONS = { DCP_CLIENT = true, } +-- Built-in function blocks (have state, need instance) M.FUNCTION_BLOCKS = { TON = true, TOF = true, @@ -226,6 +231,8 @@ M.DATA_TYPES = { DTL = true, } +-- Parameter definitions for built-in instructions +-- Format: { inputs = { { name, type }, ... }, outputs = { { name, type }, ... } } M.PARAMETERS = { TON = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } }, TOF = { inputs = { { name = "IN", type = "BOOL" }, { name = "PT", type = "TIME" } }, outputs = { { name = "Q", type = "BOOL" }, { name = "ET", type = "TIME" } } }, @@ -283,10 +290,48 @@ M.PARAMETERS = { I_BCD = { inputs = { { name = "IN" } }, outputs = { { name = "OUT" } } }, } +-- Get parameter info for a built-in instruction +-- @param name string The instruction name +-- @return table|nil Parameter definition with inputs and outputs arrays function M.get_parameters(name) return M.PARAMETERS[name:upper()] end +-- Check if name is a known function block +-- @param name string The name to check +-- @return boolean True if known function block +function M.is_known_function_block(name) + return M.FUNCTION_BLOCKS[name:upper()] or false +end + +-- Check if name is a known function +-- @param name string The name to check +-- @return boolean True if known function +function M.is_known_function(name) + return M.FUNCTIONS[name:upper()] or false +end + +-- Check if name is a known data type +-- @param name string The name to check +-- @return boolean True if known data type +function M.is_known_data_type(name) + return M.DATA_TYPES[name:upper()] or false +end + +-- Check if name is a known instruction (function or function block) +-- @param name string The name to check +-- @return boolean True if known instruction +function M.is_known_instruction(name) + return M.is_known_function(name) or M.is_known_function_block(name) or false +end + +-- Check if name is a known type or instruction +-- @param name string The name to check +-- @return boolean True if known type or instruction +function M.is_known_type_or_instruction(name) + return M.is_known_data_type(name) or M.is_known_instruction(name) or false +end + function M.is_known_function(name) return M.FUNCTIONS[name:upper()] or false end diff --git a/src/diagnostics.lua b/src/diagnostics.lua index 02e5517..506e913 100644 --- a/src/diagnostics.lua +++ b/src/diagnostics.lua @@ -1,4 +1,6 @@ -- SCL Diagnostics - Linter functionality for the LSP server +-- Validates SCL code for undefined variables, unknown types, and syntax errors + local M = {} -- Get script path for loading parser (same pattern as main.lua) @@ -18,6 +20,7 @@ package.path = package.path .. ";" .. script_path .. "/?.lua" local parser = dofile(script_path .. "/parser.lua") local builtin = dofile(script_path .. "/builtin_instructions.lua") +-- LSP diagnostic severity levels local DiagnosticSeverity = { Error = 1, Warning = 2, @@ -25,6 +28,10 @@ local DiagnosticSeverity = { Hint = 4, } +--- Convert line/character to LSP position format +--- @param line number Zero-based line number +--- @param character number Zero-based character offset +--- @return table LSP Position object local function position_to_lsp(line, character) return { line = line, @@ -32,6 +39,12 @@ local function position_to_lsp(line, character) } end +--- Create LSP Range object from start and end positions +--- @param start_line number Start line (zero-based) +--- @param start_char number Start character (zero-based) +--- @param end_line number End line (zero-based) +--- @param end_char number End character (zero-based) +--- @return table LSP Range object local function range_to_lsp(start_line, start_char, end_line, end_char) return { start = position_to_lsp(start_line, start_char), @@ -39,6 +52,12 @@ local function range_to_lsp(start_line, start_char, end_line, end_char) } end +--- Validate SCL content and generate diagnostics +--- Checks for: undefined variables, invalid # prefix, unknown data types +--- @param content string The SCL source code to validate +--- @param workspace_types table Known types from workspace scanning +--- @param root_dir string Project root directory for additional type lookup +--- @return table Array of LSP Diagnostic objects function M.validate_diagnostics(content, workspace_types, root_dir) local diagnostics = {} local lines = {} diff --git a/src/formatter.lua b/src/formatter.lua index 4a2b96b..b0bbc30 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,5 +1,6 @@ -- SCL Formatter - Document formatting for the LSP server -- Stack-based implementation for proper nested structure handling + local M = {} -- Default patterns for attribute blocks to collapse @@ -10,10 +11,20 @@ local DEFAULT_COLLAPSE_PATTERNS = { "^%s*{%s*%w+%s*:=", } +--- Create LSP Position object +--- @param line number Line number +--- @param character number Character offset +--- @return table Position object local function position(line, character) return { line = line, character = character } end +--- Create LSP Range object +--- @param start_line number Start line +--- @param start_char number Start character +--- @param end_line number End line +--- @param end_char number End character +--- @return table Range object local function range(start_line, start_char, end_line, end_char) return { start = position(start_line, start_char), @@ -89,6 +100,11 @@ local CONTINUATION_OPERATORS = { ["MOD"] = true, } +--- Format SCL document content +--- Handles indentation, attribute collapsing, and multiline FB calls +--- @param content string The SCL source code +--- @param options table Formatting options (insertSpaces, tabSize, collapseAttributes) +--- @return table Array of TextEdit objects with range and newText function M.format_document(content, options) options = options or {} local use_spaces = options.insertSpaces or false @@ -147,6 +163,15 @@ function M.format_document(content, options) end local function is_fb_call_start(trimmed) + local first_word = trimmed:match("^(#?[%w_]+)") + if not first_word then return false end + + -- Don't treat control structure keywords as FB calls + local control_keywords = { IF = true, ELSIF = true, FOR = true, WHILE = true, CASE = true, WITH = true } + if control_keywords[first_word:upper()] then + return false + end + if not (trimmed:match("^#?%w+") or trimmed:match('^"')) then return false end @@ -341,10 +366,14 @@ function M.format_document(content, options) -- Check if continuing condition (IF/ELSIF condition before THEN) if in_condition and not ends_assignment then - local starts_with_operator = is_assignment_continuation(trimmed) local first_word = trimmed:match("^([%w_]+)") local is_then = first_word and first_word:upper() == "THEN" - if starts_with_operator or is_then then + local is_else = first_word and first_word:upper() == "ELSE" + local is_elsif = first_word and first_word:upper() == "ELSIF" + local is_ender = trimmed:match("^END_") ~= nil + local starts_new_statement = is_then or is_else or is_elsif or is_ender + + if not starts_new_statement then table.insert(formatted_lines, condition_indent .. trimmed) if is_then then in_condition = false @@ -474,19 +503,20 @@ function M.format_document(content, options) if SAME_LEVEL[kw] then local formatted if kw == "THEN" then - local if_block = nil - for i = #stack, 1, -1 do - if stack[i].type == "IF" then - if_block = stack[i] - break - end - end - if if_block then - formatted = string.rep(indent_char, if_block.indent) .. trimmed + local then_indent + if in_condition and condition_indent ~= "" then + then_indent = condition_indent else - formatted = get_indent() .. trimmed + local if_block = nil + for i = #stack, 1, -1 do + if stack[i].type == "IF" then + if_block = stack[i] + break + end + end + then_indent = if_block and string.rep(indent_char, if_block.indent) or get_indent() end - table.insert(formatted_lines, formatted) + table.insert(formatted_lines, then_indent .. trimmed) table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) in_condition = false elseif kw == "ELSE" or kw == "ELSIF" then @@ -507,6 +537,11 @@ function M.format_document(content, options) end table.insert(formatted_lines, formatted) table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + -- Check if this is a multi-line condition (doesn't end with THEN on same line) + if not trimmed:match("THEN%s*$") then + in_condition = true + condition_indent = string.rep(indent_char, (if_block and if_block.indent or 0) + 1) + end else formatted = get_indent() .. trimmed table.insert(formatted_lines, formatted) @@ -540,8 +575,12 @@ function M.format_document(content, options) table.insert(formatted_lines, formatted) table.insert(stack, { type = kw, indent = current_level }) if kw == "IF" or kw == "ELSIF" then - in_condition = true - condition_indent = string.rep(indent_char, current_level) + -- Check if this is a multi-line condition (doesn't end with THEN on same line) + if not trimmed:match("THEN%s*$") then + in_condition = true + -- Continuation lines should be indented one level deeper than IF/ELSIF + condition_indent = string.rep(indent_char, current_level + 1) + end end goto continue end @@ -587,6 +626,8 @@ function M.format_document(content, options) } end +--- Get default formatting options +--- @return table Default options table function M.get_formatting_options() return { insertSpaces = false, diff --git a/src/json.lua b/src/json.lua index 818d637..072b8be 100644 --- a/src/json.lua +++ b/src/json.lua @@ -1,5 +1,11 @@ +-- JSON Encoder/Decoder for LSP server +-- Minimal implementation supporting basic JSON parsing and encoding + local json = {} +--- Check if a table is an array (sequential numeric keys starting from 1) +--- @param t table Table to check +--- @return boolean True if table is an array local function is_array(t) local i = 0 for _ in pairs(t) do @@ -11,6 +17,9 @@ local function is_array(t) return true end +--- Escape special characters in string for JSON +--- @param s string String to escape +--- @return string Escaped string local function escape(s) s = s:gsub("\\", "\\\\") s = s:gsub('"', '\\"') @@ -20,6 +29,9 @@ local function escape(s) return s end +--- Encode Lua value to JSON string +--- @param data any Value to encode +--- @return string JSON string function json.encode(data) local t = type(data) if t == "nil" then @@ -48,6 +60,10 @@ function json.encode(data) end end +--- Skip whitespace in string +--- @param s string JSON string +--- @param i number Current position +--- @return number New position after whitespace local function skip_whitespace(s, i) while i <= #s and s:sub(i, i):match("%s") do i = i + 1 @@ -55,6 +71,11 @@ local function skip_whitespace(s, i) return i end +--- Parse JSON string literal +--- @param s string JSON string +--- @param i number Current position (at opening quote) +--- @return string|nil Parsed string +--- @return number New position local function parse_string(s, i) local result = {} i = i + 1 @@ -86,6 +107,11 @@ local function parse_string(s, i) return nil, i end +--- Parse JSON number +--- @param s string JSON string +--- @param i number Current position +--- @return number Parsed number +--- @return number New position local function parse_number(s, i) local start = i if s:sub(i, i) == "-" then @@ -114,6 +140,11 @@ end local parse_value +--- Parse JSON array +--- @param s string JSON string +--- @param i number Current position (at opening bracket) +--- @return table|nil Parsed array +--- @return number New position local function parse_array(s, i) local arr = {} i = i + 1 @@ -136,6 +167,11 @@ local function parse_array(s, i) return nil, i end +--- Parse JSON object +--- @param s string JSON string +--- @param i number Current position (at opening brace) +--- @return table|nil Parsed object +--- @return number New position local function parse_object(s, i) local obj = {} i = i + 1 @@ -205,6 +241,10 @@ parse_value = function(s, i) end end +--- Decode JSON string to Lua value +--- @param s string JSON string +--- @return any|nil Parsed value +--- @return number|nil Position after parsing or error function json.decode(s) if type(s) ~= "string" then return nil, "invalid input" diff --git a/src/main.lua b/src/main.lua index 7232070..a025492 100644 --- a/src/main.lua +++ b/src/main.lua @@ -154,6 +154,9 @@ end local handlers = {} +--- Handle LSP initialize request +--- @param params table Initialization parameters from client +--- @return table Server capabilities and info function handlers.initialize(params) return { capabilities = capabilities, @@ -161,8 +164,13 @@ function handlers.initialize(params) } end +--- Handle initialized notification (no response needed) +--- @param params table Parameters (usually empty) function handlers.initialized(params) end +--- Handle shutdown request +--- @param params table Parameters (usually empty) +--- @return nil function handlers.shutdown(params) return nil end @@ -206,10 +214,15 @@ local function find_project_root(start_path) return nil end +--- Handle exit notification - terminates the server +--- @param params table Parameters (usually empty) function handlers.exit(params) os.exit(0) end +--- Handle textDocument/didOpen notification +--- Parses document content, extracts symbols, and stores in documents cache +--- @param params table Contains textDocument with uri, text, version function handlers.textDocument_didOpen(params) local uri = params.textDocument.uri local content = params.textDocument.text @@ -247,6 +260,9 @@ function handlers.textDocument_didOpen(params) } end +--- Handle textDocument/didChange notification +--- Re-parses document content after edits +--- @param params table Contains textDocument with uri and contentChanges array function handlers.textDocument_didChange(params) local uri = params.textDocument.uri local content = params.contentChanges[1].text @@ -285,11 +301,18 @@ function handlers.textDocument_didChange(params) clear_symbol_cache() end +--- Handle textDocument/didClose notification +--- Removes document from cache +--- @param params table Contains textDocument with uri function handlers.textDocument_didClose(params) documents[params.textDocument.uri] = nil clear_symbol_cache() end +--- Handle textDocument/hover request +--- Provides hover information for variables, types, and built-in instructions +--- @param params table Contains textDocument with uri and position +--- @return table|nil Hover result with contents and range, or nil function handlers.textDocument_hover(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -699,6 +722,10 @@ function handlers.textDocument_hover(params) return nil end +--- Handle textDocument/completion request +--- Provides completion items based on context (VAR section, member access, etc.) +--- @param params table Contains textDocument with uri and position +--- @return table Completion list with items array function handlers.textDocument_completion(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -879,6 +906,10 @@ function handlers.textDocument_completion(params) return { isIncomplete = false, items = items } end +--- Handle textDocument/definition request +--- Finds definition location for symbol at position +--- @param params table Contains textDocument with uri and position +--- @return table|nil Location with uri and range, or nil function handlers.textDocument_definition(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -921,6 +952,10 @@ function handlers.textDocument_definition(params) return nil end +--- Handle textDocument/declaration request +--- Finds declaration locations for symbol at position across workspace +--- @param params table Contains textDocument with uri and position +--- @return table|nil Array of locations, or nil function handlers.textDocument_declaration(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1141,6 +1176,10 @@ function handlers.textDocument_declaration(params) return nil end +--- Handle textDocument/references request +--- Finds all references to symbol at position in current document +--- @param params table Contains textDocument with uri and position +--- @return table|nil Array of reference locations, or nil function handlers.textDocument_references(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1202,6 +1241,10 @@ function handlers.textDocument_references(params) return nil end +--- Handle textDocument/documentSymbol request +--- Returns all symbols in document for outline/navigation +--- @param params table Contains textDocument with uri +--- @return table Array of DocumentSymbol objects function handlers.textDocument_documentSymbol(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1286,6 +1329,10 @@ function handlers.textDocument_documentSymbol(params) return symbols end +--- Handle textDocument/diagnostic request +--- Validates document and returns diagnostics +--- @param params table Contains textDocument with uri +--- @return table Diagnostic report with items array function handlers.textDocument_diagnostic(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1302,6 +1349,10 @@ function handlers.textDocument_diagnostic(params) return { items = diag_results } end +--- Handle textDocument/semanticTokens/full request +--- Returns semantic tokens for syntax highlighting (currently disabled) +--- @param params table Contains textDocument with uri +--- @return table Semantic tokens with data array function handlers.textDocument_semanticTokensFull(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1399,6 +1450,10 @@ function handlers.textDocument_semanticTokensFull(params) return { data = tokens } end +--- Handle textDocument/semanticTokens/range request +--- Returns semantic tokens for a range (delegates to full) +--- @param params table Contains textDocument with uri and range +--- @return table Semantic tokens with data array function handlers.textDocument_semanticTokensRange(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1412,6 +1467,10 @@ end handlers["textDocument_semanticTokens_full"] = handlers.textDocument_semanticTokensFull handlers["textDocument_semanticTokens_range"] = handlers.textDocument_semanticTokensRange +--- Handle textDocument/inlayHint request +--- Provides inlay hints for variable types in VAR sections +--- @param params table Contains textDocument with uri and range +--- @return table Array of InlayHint objects function handlers.textDocument_inlayHint(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -1449,6 +1508,10 @@ function handlers.textDocument_inlayHint(params) return hints end +--- Handle workspace/symbol request +--- Searches workspace for symbols matching query +--- @param params table Contains query string and optional maxResults +--- @return table Array of SymbolInformation objects function handlers.workspace_symbol(params) local query = params.query or "" local max_results = params.maxResults or 50 @@ -1586,6 +1649,10 @@ function handlers.workspace_symbol(params) return final_results end +--- Handle textDocument/formatting request +--- Formats entire document using SCL formatter +--- @param params table Contains textDocument with uri and options +--- @return table|nil Array of TextEdit objects, or nil function handlers.textDocument_formatting(params) local uri = params.textDocument.uri local doc = documents[uri] diff --git a/src/parser.lua b/src/parser.lua index 836eaa6..7fd83ee 100644 --- a/src/parser.lua +++ b/src/parser.lua @@ -1,6 +1,12 @@ -- SCL Parser utilities for the LSP server +-- Extracts variables, types, and function definitions from SCL source code + local M = {} +--- Extract variables from SCL content +--- @param content string The SCL source code +--- @return table variables Table of variable definitions keyed by name +--- @return table variable_positions Table of variable positions keyed by name function M.extract_variables(content) local variables = {} local variable_positions = {} @@ -164,6 +170,9 @@ function M.extract_variables(content) return variables, variable_positions end +--- Extract type definitions from SCL content +--- @param content string The SCL source code +--- @return table types Table of type definitions keyed by name function M.extract_types(content) local types = {} @@ -285,6 +294,9 @@ function M.extract_types(content) return types end +--- Extract function/block definitions from SCL content +--- @param content string The SCL source code +--- @return table functions Array of function definitions with name, kind, start, final function M.extract_functions(content) local functions = {} diff --git a/src/plc_json.lua b/src/plc_json.lua index f03c7bb..0e0ece9 100644 --- a/src/plc_json.lua +++ b/src/plc_json.lua @@ -1,10 +1,14 @@ -- Load UDT types from external .plc.json files --- Auto-generate from data_types folder +-- Auto-generate from data_types folder and .scl/.udt/.db files local M = {} +-- Cache for loaded types: { [root_dir] = { [type_name] = type_info } } local cached_types = {} +--- Execute shell command and capture output +--- @param cmd string Command to execute +--- @return string|nil Command output, or nil on error local function run_command(cmd) local handle = io.popen(cmd) if not handle then return nil end @@ -26,6 +30,9 @@ else script_path = "." end +--- Load and parse JSON content +--- @param content string JSON string +--- @return table|nil Parsed data, or nil on error local function load_json(content) local json = dofile(script_path .. "/json.lua") local ok, data = pcall(function() return json.decode(content) end) @@ -33,6 +40,10 @@ local function load_json(content) return nil end +--- Scan directory recursively for files matching pattern +--- @param dir string Directory to scan +--- @param pattern string Glob pattern for file names +--- @return table Array of matching file paths local function scan_files_recursive(dir, pattern) local files = {} local result = run_command("find " .. dir .. " -type f -name \"" .. pattern .. "\" 2>/dev/null") @@ -44,6 +55,9 @@ local function scan_files_recursive(dir, pattern) return files end +--- Parse a PLC type definition from JSON structure +--- @param dt table Type definition from JSON +--- @return table|nil Parsed type info, or nil if invalid local function parse_plc_type(dt) local type_name = dt.name or dt.Name or dt.baseType or dt.dataTypeName if not type_name then return nil end @@ -78,6 +92,9 @@ local function parse_plc_type(dt) return typ end +--- Parse SCL type file content (TYPE...END_TYPE, FUNCTION_BLOCK, etc.) +--- @param content string SCL source code +--- @return table types Parsed types keyed by name local function parse_scl_type_file(content) local types = {} local in_type = false @@ -244,6 +261,9 @@ local function parse_scl_type_file(content) return types end +--- Parse DB file content (DATA_BLOCK...END_DATA_BLOCK) +--- @param content string DB source code +--- @return table types Parsed DB types keyed by name local function parse_db_file(content) local types = {} local in_var = false @@ -288,6 +308,10 @@ local function parse_db_file(content) return types end +--- Load all types from workspace directory +--- Scans .plc.json, .scl, .udt, and .db files +--- @param root_dir string Project root directory +--- @return table Types keyed by name function M.load_types_from_workspace(root_dir) local types = {} @@ -375,6 +399,9 @@ function M.load_types_from_workspace(root_dir) return types end +--- Get types for a workspace (cached) +--- @param root_dir string Project root directory +--- @return table Types keyed by name function M.get_types(root_dir) if not root_dir or root_dir == "" then root_dir = "workspace" @@ -389,6 +416,10 @@ function M.get_types(root_dir) return types end +--- Generate .plc.json file from workspace types +--- @param root_dir string Project root directory +--- @param output_file string Output file path +--- @return boolean True if file was written successfully function M.generate_plc_json(root_dir, output_file) local json = dofile(script_path .. "/json.lua") @@ -435,6 +466,7 @@ function M.generate_plc_json(root_dir, output_file) return false end +--- Clear all cached types function M.clear_cache() cached_types = {} end diff --git a/src/treesitter.lua b/src/treesitter.lua index e2d2f04..1e4c5b7 100644 --- a/src/treesitter.lua +++ b/src/treesitter.lua @@ -1,10 +1,13 @@ -- Tree-sitter integration for SCL LSP --- Uses tree-sitter to parse SCL files more accurately +-- Uses tree-sitter to parse SCL files more accurately than regex parsing local M = {} local ts = nil +--- Initialize tree-sitter parser +--- Attempts to load vim.treesitter module +--- @return boolean True if tree-sitter is available function M.init() local ok, treesitter = pcall(require, "vim.treesitter") if ok then @@ -14,6 +17,10 @@ function M.init() return false end +--- Parse SCL content using tree-sitter +--- @param content string The SCL source code +--- @return table|nil Parse tree, or nil on error +--- @return string|nil Error message if parsing failed function M.parse_scl(content) if not ts then return nil, "Tree-sitter not available" @@ -32,6 +39,11 @@ function M.parse_scl(content) return tree, nil end +--- Extract variables from tree-sitter parse tree +--- @param tree table Parse tree from parse_scl +--- @param bufnr number Buffer number for text retrieval +--- @return table variables Table of variable definitions +--- @return table variable_positions Table of variable positions function M.extract_variables_ts(tree, bufnr) local variables = {} local variable_positions = {} @@ -79,6 +91,10 @@ function M.extract_variables_ts(tree, bufnr) return variables, variable_positions end +--- Extract function/block definitions from tree-sitter parse tree +--- @param tree table Parse tree from parse_scl +--- @param bufnr number Buffer number for text retrieval +--- @return table functions Array of function definitions function M.extract_functions_ts(tree, bufnr) local functions = {} @@ -109,6 +125,11 @@ function M.extract_functions_ts(tree, bufnr) return functions end +--- Get syntax node at position +--- @param tree table Parse tree +--- @param row number Row number (zero-based) +--- @param col number Column number (zero-based) +--- @return table|nil Node at position, or nil if not found function M.get_node_at_position(tree, row, col) if not tree then return nil end @@ -124,6 +145,12 @@ function M.get_node_at_position(tree, row, col) return nil end +--- Get variable type at cursor position +--- @param tree table Parse tree +--- @param bufnr number Buffer number +--- @param row number Row number (zero-based) +--- @param col number Column number (zero-based) +--- @return string|nil Variable type, or nil if not found function M.get_variable_type_at_cursor(tree, bufnr, row, col) if not tree or not ts then return nil end