diff --git a/AGENTS.md b/AGENTS.md index 90141f4..4c08939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,19 @@ function M.clear_cache() end ``` +## Lua Reserved Keywords + +Lua reserved keywords (like `end`, `for`, `in`, etc.) cannot be used as table keys directly. Use bracket notation: +```lua +-- WRONG: causes syntax error +local range = { start = pos1, end = pos2 } + +-- CORRECT: use bracket notation +local range = { start = pos1, ["end"] = pos2 } +``` + +This is especially important for LSP range objects which require an `end` field per the LSP specification. + ## Error Handling ### Return Pattern @@ -166,3 +179,34 @@ Find the last `:=`, `=>`, `=`, `<>`, `>=`, `<=`, `>`, `<`, `AND`, `OR`, `NOT` an | `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | | `:LspSCLFormat` | Format current SCL file | | `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ | + +## LSP Server Implementation Notes + +### Method Name Conversion +LSP methods like `textDocument/didOpen` are converted to handler names like `textDocument_didOpen`: +```lua +local method_name = message.method:gsub("/", "_") +local handler = handlers[method_name] +``` + +### Request vs Notification +- Requests have `id` field and require a response +- Notifications have no `id` and should not receive a response +```lua +if message.id then + -- Only send response for requests + return { id = message.id, result = result } +end +``` + +### Line Ending Handling +The LSP server strips CRLF (`\r\n`) line endings for Windows compatibility: +```lua +line = line:gsub("\r$", "") +``` + +### JSON Parser +The standalone JSON parser in `src/json.lua` handles: +- Objects, arrays, strings, numbers, booleans, null +- Escape sequences in strings +- Whitespace skipping diff --git a/README.md b/README.md index bc25a38..98b9e42 100644 --- a/README.md +++ b/README.md @@ -253,10 +253,24 @@ myVar.temperature := 25.5; END_FUNCTION_BLOCK ``` +## Architecture + +The plugin uses a standalone LSP server (`src/main.lua`) that communicates via JSON-RPC over stdio. The server handles: +- Text document synchronization +- Diagnostics (linting) +- Formatting +- Completion, hover, go-to-definition +- Semantic tokens + +The Neovim plugin (`lua/scl_lsp/init.lua`) manages: +- LSP client lifecycle via `vim.lsp.start()` +- FileType autocmd for lazy loading +- blink.cmp integration +- Workspace scanning for UDTs/DBs + ## Requirements -- **Neovim 0.9+** -- **nvim-lspconfig** - LSP client +- **Neovim 0.11+** (uses `vim.lsp.start()`) - **nvim-treesitter** - Syntax highlighting - **blink.cmp** - Optional, for completion - **Lua 5.1+** - LSP server runtime diff --git a/src/diagnostics.lua b/src/diagnostics.lua index c976c77..9e9d834 100644 --- a/src/diagnostics.lua +++ b/src/diagnostics.lua @@ -1,6 +1,21 @@ -- SCL Diagnostics - Linter functionality for the LSP server local M = {} -local parser = require("scl_lsp.parser") + +-- Get script path for loading parser (same pattern as main.lua) +local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "" +if script_path == "" then + script_path = "." +end +if script_path:sub(1, 1) ~= "/" then + local cwd = io.popen("pwd") + if cwd then + script_path = cwd:read("*l") .. "/" .. script_path + cwd:close() + end +end +package.path = package.path .. ";" .. script_path .. "/?.lua" + +local parser = dofile(script_path .. "/parser.lua") local DiagnosticSeverity = { Error = 1, @@ -19,7 +34,7 @@ end local function range_to_lsp(start_line, start_char, end_line, end_char) return { start = position_to_lsp(start_line, start_char), - end = position_to_lsp(end_line, end_char), + ["end"] = position_to_lsp(end_line, end_char), } end diff --git a/src/formatter.lua b/src/formatter.lua index 81f405f..3c4e54f 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -8,7 +8,7 @@ end local function range(start_line, start_char, end_line, end_char) return { start = position(start_line, start_char), - end = position(end_line, end_char), + ["end"] = position(end_line, end_char), } end diff --git a/src/json.lua b/src/json.lua index f69ebdc..818d637 100644 --- a/src/json.lua +++ b/src/json.lua @@ -13,7 +13,7 @@ end local function escape(s) s = s:gsub("\\", "\\\\") - s = s:gsub("\"", "\\\"") + s = s:gsub('"', '\\"') s = s:gsub("\n", "\\n") s = s:gsub("\r", "\\r") s = s:gsub("\t", "\\t") @@ -29,7 +29,7 @@ function json.encode(data) elseif t == "number" then return tostring(data) elseif t == "string" then - return "\"" .. escape(data) .. "\"" + return '"' .. escape(data) .. '"' elseif t == "table" then local parts = {} if is_array(data) then @@ -39,7 +39,7 @@ function json.encode(data) return "[" .. table.concat(parts, ",") .. "]" else for k, v in pairs(data) do - table.insert(parts, "\"" .. escape(k) .. "\":" .. json.encode(v)) + table.insert(parts, '"' .. escape(k) .. '":' .. json.encode(v)) end return "{" .. table.concat(parts, ",") .. "}" end @@ -49,7 +49,7 @@ function json.encode(data) end local function skip_whitespace(s, i) - while i <= #s and s:find("^%s", i) do + while i <= #s and s:sub(i, i):match("%s") do i = i + 1 end return i @@ -60,7 +60,7 @@ local function parse_string(s, i) i = i + 1 while i <= #s do local c = s:sub(i, i) - if c == "\"" then + if c == '"' then return table.concat(result), i + 1 elseif c == "\\" and i < #s then local next_c = s:sub(i + 1, i + 1) @@ -70,9 +70,9 @@ local function parse_string(s, i) table.insert(result, "\r") elseif next_c == "t" then table.insert(result, "\t") - elseif next_c == "\\\"" then - table.insert(result, "\"") - elseif next_c == "\\\\" then + elseif next_c == '"' then + table.insert(result, '"') + elseif next_c == "\\" then table.insert(result, "\\") else table.insert(result, next_c) @@ -91,28 +91,85 @@ local function parse_number(s, i) if s:sub(i, i) == "-" then i = i + 1 end - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end if s:sub(i, i) == "." then i = i + 1 - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end end - if s:sub(i, i):find("^[eE]") then + if s:sub(i, i):match("[eE]") then i = i + 1 - if s:sub(i, i):find("^[+-]") then + if s:sub(i, i):match("[+-]") then i = i + 1 end - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end end return tonumber(s:sub(start, i - 1)), i end -local function parse_value(s, i) +local parse_value + +local function parse_array(s, i) + local arr = {} + i = i + 1 + while i <= #s do + i = skip_whitespace(s, i) + if s:sub(i, i) == "]" then + return arr, i + 1 + end + local val + val, i = parse_value(s, i) + table.insert(arr, val) + i = skip_whitespace(s, i) + if s:sub(i, i) == "]" then + return arr, i + 1 + end + if s:sub(i, i) == "," then + i = i + 1 + end + end + return nil, i +end + +local function parse_object(s, i) + local obj = {} + i = i + 1 + while i <= #s do + i = skip_whitespace(s, i) + if s:sub(i, i) == "}" then + return obj, i + 1 + end + if s:sub(i, i) == '"' then + local key + key, i = parse_string(s, i) + i = skip_whitespace(s, i) + if s:sub(i, i) ~= ":" then + return nil, i + end + i = i + 1 + local val + val, i = parse_value(s, i) + obj[key] = val + i = skip_whitespace(s, i) + if s:sub(i, i) == "}" then + return obj, i + 1 + end + if s:sub(i, i) == "," then + i = i + 1 + end + else + return nil, i + end + end + return nil, i +end + +parse_value = function(s, i) i = skip_whitespace(s, i) if i > #s then return nil, i @@ -121,66 +178,30 @@ local function parse_value(s, i) local c = s:sub(i, i) if c == "{" then - local obj = {} - i = i + 1 - while i <= #s do - i = skip_whitespace(s, i) - if s:sub(i, i) == "}" then - return obj, i + 1 - end - if s:sub(i, i) == "\"" then - local key - key, i = parse_string(s, i) - i = skip_whitespace(s, i) - if s:sub(i, i) ~= ":" then - return nil, i - end - i = i + 1 - local val - val, i = parse_value(s, i) - obj[key] = val - i = skip_whitespace(s, i) - if s:sub(i, i) == "}" then - return obj, i + 1 - end - if s:sub(i, i) == "," then - i = i + 1 - end - else - return nil, i - end - end - return nil, i + return parse_object(s, i) elseif c == "[" then - local arr = {} - i = i + 1 - while i <= #s do - i = skip_whitespace(s, i) - if s:sub(i, i) == "]" then - return arr, i + 1 - end - local val - val, i = parse_value(s, i) - table.insert(arr, val) - i = skip_whitespace(s, i) - if s:sub(i, i) == "]" then - return arr, i + 1 - end - if s:sub(i, i) == "," then - i = i + 1 - end + return parse_array(s, i) + elseif c == '"' then + return parse_string(s, i) + elseif c == "t" then + if s:sub(i, i + 3) == "true" then + return true, i + 4 end return nil, i - elseif c == "\"" then - return parse_string(s, i) - elseif s:sub(i, i + 3) == "null" then - return nil, i + 4 - elseif s:sub(i, i + 3) == "true" then - return true, i + 4 - elseif s:sub(i, i + 4) == "false" then - return false, i + 5 - else + elseif c == "f" then + if s:sub(i, i + 4) == "false" then + return false, i + 5 + end + return nil, i + elseif c == "n" then + if s:sub(i, i + 3) == "null" then + return nil, i + 4 + end + return nil, i + elseif c == "-" or c:match("%d") then return parse_number(s, i) + else + return nil, i end end diff --git a/src/main.lua b/src/main.lua index 1bbe0a8..2467e78 100644 --- a/src/main.lua +++ b/src/main.lua @@ -296,7 +296,7 @@ function handlers.textDocument_hover(params) contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, - endPos = { line = params.position.line, character = word_end - 1 }, + ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end @@ -315,7 +315,7 @@ function handlers.textDocument_hover(params) contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, - endPos = { line = params.position.line, character = word_end - 1 }, + ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end @@ -449,7 +449,7 @@ function handlers.textDocument_definition(params) uri = uri, range = { start = { line = pos.line, character = pos.character }, - endPos = { line = pos.line, character = pos.character + #word }, + ["end"] = { line = pos.line, character = pos.character + #word }, }, } end @@ -478,10 +478,10 @@ function handlers.textDocument_documentSymbol(params) table.insert(symbols, { name = func.kind:upper() .. " " .. func.name, kind = kind_map[func.kind] or 14, - range = { start = { line = func.start, character = 0 }, endPos = { line = func.final, character = 0 } }, + range = { start = { line = func.start, character = 0 }, ["end"] = { line = func.final, character = 0 } }, selectionRange = { start = { line = func.start, character = 0 }, - endPos = { line = func.start, character = #func.name }, + ["end"] = { line = func.start, character = #func.name }, }, containerName = nil, }) @@ -500,11 +500,11 @@ function handlers.textDocument_documentSymbol(params) detail = var_info.type or "VAR", range = { start = { line = var_info.line, character = 0 }, - endPos = { line = var_info.line, character = #var_name }, + ["end"] = { line = var_info.line, character = #var_name }, }, selectionRange = { start = { line = var_info.line, character = var_info.character }, - endPos = { line = var_info.line, character = var_info.character + #var_name }, + ["end"] = { line = var_info.line, character = var_info.character + #var_name }, }, containerName = nil, }) @@ -526,11 +526,11 @@ function handlers.textDocument_documentSymbol(params) detail = detail, range = { start = { line = type_info.start_line or 0, character = 0 }, - endPos = { line = type_info.start_line or 0, character = #type_name }, + ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, selectionRange = { start = { line = type_info.start_line or 0, character = 0 }, - endPos = { line = type_info.start_line or 0, character = #type_name }, + ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, containerName = nil, }) @@ -656,6 +656,10 @@ function handlers.textDocument_semanticTokensRange(params) 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] @@ -708,7 +712,7 @@ function handlers.workspace_symbol(params) uri = uri, range = { start = { line = line, character = col }, - endPos = { line = line, character = col + length }, + ["end"] = { line = line, character = col + length }, }, }, }) @@ -848,17 +852,26 @@ local function handle_message(message) if message.method == "$/cancelRequest" then return nil end - local handler = handlers[message.method] + -- 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) - if ok then - return { id = message.id, result = result } - else - return { id = message.id, error = { code = -32603, message = tostring(result) } } + -- 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 - return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } } + -- 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() @@ -871,6 +884,9 @@ local function main() 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