diff --git a/AGENTS.md b/AGENTS.md index 90141f4..601dac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,59 +1,63 @@ -# AGENTS.md - SCL Language Server (Unified) +# AGENTS.md - SCL Language Server A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion. -## Reference Project for Testing - -For manual testing and SCL/UDT/DB file examples, use: -``` -~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project -``` - ## Build/Lint/Test Commands -### LSP Server ```bash +# LSP Server make start # Start LSP server make test # Run LSP in test mode -lua src/main.lua --test # Direct test mode execution -``` +lua src/main.lua --test # Direct test execution -### Tree-sitter Parser -```bash +# Tree-sitter Parser tree-sitter generate # Generate parser from grammar.js -tree-sitter test # Run tree-sitter tests -tree-sitter parse # Parse a single SCL file +tree-sitter test # Run parser tests +tree-sitter parse # Parse single SCL file + +# Lua Linting +luacheck src/ # Lint LSP server code +luacheck lua/scl/ # Lint plugin modules ``` ## Project Structure ``` scl_lsp/ -├── src/ # Standalone LSP server (uses dofile) -│ ├── main.lua # Entry point, JSON-RPC protocol -│ ├── parser.lua # Regex-based SCL parser -│ ├── diagnostics.lua # Linter diagnostics -│ └── formatter.lua # Document formatter -├── lua/scl/ # Neovim plugin modules (uses require) -│ ├── blink_cmp_source.lua # blink.cmp completion source -│ ├── udt_parser.lua # UDT (.udt) parser -│ ├── db_parser.lua # Global DB (.db) parser -│ ├── fb_parser.lua # Function Block parser -│ ├── variables.lua # Local variable extraction -│ ├── workspace_types.lua # Workspace scanning -│ └── multiline_params.lua # FB parameter filling -└── queries/ # Tree-sitter queries +├── src/ # LSP server (uses dofile) +│ ├── main.lua # Entry point, JSON-RPC protocol +│ ├── parser.lua # SCL parser +│ ├── diagnostics.lua # Linter +│ ├── formatter.lua # Document formatter +│ ├── treesitter.lua # Tree-sitter integration +│ ├── plc_json.lua # External UDT loading +│ └── json.lua # JSON encoder/decoder +├── lua/scl/ # Neovim plugin (uses require) +│ ├── init.lua # Main plugin setup +│ ├── blink_cmp_source.lua +│ ├── udt_parser.lua # UDT parser +│ ├── db_parser.lua # Global DB parser +│ ├── fb_parser.lua # Function Block parser +│ ├── variables.lua # Variable extraction +│ ├── workspace_types.lua # Workspace scanning +│ ├── multiline_params.lua +│ ├── auto_prefix.lua +│ └── attr_toggle.lua # Interactive attribute block toggle +├── queries/ # Tree-sitter queries +│ ├── highlights.scm +│ ├── indents.scm +│ └── folds.scm +└── grammar.js # Tree-sitter grammar ``` ## Code Style Guidelines ### Module Pattern -All modules follow the standard Lua module pattern: ```lua --- Module description comment at top +-- File header comment local M = {} --- Private module-level cache +-- Private cache local cache = {} function M.public_function() @@ -66,37 +70,37 @@ return M ``` ### Naming Conventions -- Functions/variables: `snake_case` (e.g., `get_udt_members`, `var_types`) +- Functions/variables: `snake_case` (e.g., `get_udt_members`) - Constants: `UPPER_SNAKE_CASE` (e.g., `PROJECT_MARKERS`) - Private functions: declare as `local function` before public functions -- Boolean variables: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`) +- Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`) ### Imports - `lua/scl/` modules: use `require("scl.module_name")` - `src/` modules: use `dofile(script_path .. "/module.lua")` - External dependencies: wrap in `pcall()` for safety -### Comments -- Every file must have a header comment explaining its purpose -- All functions should have a brief comment describing what they do -- Complex logic requires inline comments explaining the "why" -- Non-obvious regex patterns must have explanatory comments +### Code Formatting +- `lua/scl/`: 2 spaces indentation +- `src/`: tab-based indentation +- Max line length: 120 characters +- No trailing whitespace -### Parser Module API Convention -All parser modules (udt_parser, db_parser, fb_parser) must implement: -- `parse_*_file(filepath)` - Parse a file and cache result -- `parse_*_content(content, filename)` - Parse string content -- `get_*(name)` - Get single cached item by name -- `get_all_*_names()` - Return list of all cached names -- `get_*_members(name)` - Get members/fields for a type -- `is_*_type(name)` - Check if name is a known type -- `clear_cache()` - Clear all cached data -- `get_cache_count()` - Return number of cached items +### Parser Module API +All parser modules must implement: +- `parse_*_file(filepath)` - Parse and cache +- `parse_*_content(content, filename)` - Parse string +- `get_*(name)` - Get cached item +- `get_all_*_names()` - List all cached names +- `get_*_members(name)` - Get members/fields +- `is_*_type(name)` - Check if known type +- `clear_cache()` - Clear cached data +- `get_cache_count()` - Return cache size ### Cache Management -- Use module-level local tables for caching: `local cache = {}` -- Clear caches by iterating and setting to nil, not by reassigning: ```lua +local cache = {} + function M.clear_cache() for k in pairs(cache) do cache[k] = nil @@ -104,10 +108,22 @@ function M.clear_cache() end ``` +## Lua Reserved Keywords + +Cannot use reserved keywords as table keys directly: +```lua +-- WRONG: syntax error +local range = { start = pos1, end = pos2 } + +-- CORRECT: bracket notation +local range = { start = pos1, ["end"] = pos2 } +``` + +Critical for LSP range objects with `end` field. + ## Error Handling ### Return Pattern -Functions that can fail return `nil, "error message"`: ```lua function M.parse_file(filepath) local file = io.open(filepath, "r") @@ -120,7 +136,6 @@ end ``` ### External Dependencies -Always wrap external requires in `pcall()`: ```lua local ok, module = pcall(require, "some_module") if ok and module then @@ -128,41 +143,187 @@ if ok and module then end ``` -### Validation -Check required parameters early and return sensible defaults: -```lua -function M.get_members(type_name) - if not type_name then - return {} - end - -- ... continue -end -``` - -## Completion System - -### Trigger Characters +## Completion Triggers - `#` - Local variables (after BEGIN) -- `.` - Member access (UDT/DB members) +- `.` - Member access (UDT/DB) - `"` - Global DB names - `(` - FB/Function parameters - ` ` (space) - General completion -### Active Portion Detection -When matching patterns in completion, use the "active portion" of the line (after the last operator) to correctly handle expressions like: -```scl -"DB1".member := "DB2".member +## Formatter Options + +The formatter supports collapsing verbose attribute blocks: + +```lua +-- Before formatting: +statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int; + +-- After formatting: +statCntrPartsIn{...} : Int; ``` -Find the last `:=`, `=>`, `=`, `<>`, `>=`, `<=`, `>`, `<`, `AND`, `OR`, `NOT` and only match patterns after it. + +### Formatter Configuration +```lua +local options = { + insertSpaces = false, + tabSize = 1, + collapseAttributes = true, -- Enable attribute collapsing (default: true) + collapsePatterns = { -- Override default patterns + "^%s*{%s*EXTERNAL", + "^%s*{ S7_", + }, + extendCollapsePatterns = { -- Add custom patterns + "^%s*{%s*CUSTOM", + }, +} +``` + +### Default Collapse Patterns +- `^%s*{%s*EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; ...}` +- `^%s*{ S7_` - Block attributes: `{ S7_Optimized_Access := 'TRUE' }` +- `^%s*{%s*%w+%s*:=` - Generic attributes with assignments + +### Per-Variable Collapse Rules +Control collapsing per variable using rules (evaluated before global patterns): + +```lua +local options = { + collapseVariableRules = { + -- Collapse attributes for variables starting with "stat" + { + variablePattern = "^stat", + collapse = true, + }, + -- Never collapse for "temp" prefix variables + { + variablePattern = "^temp", + collapse = false, + }, + -- Collapse only if both variable AND attribute match + { + variablePattern = "^config", + attributePattern = "EXTERNAL", + collapse = true, + }, + }, +} +``` + +**Rule properties:** +- `variablePattern` - Lua pattern to match variable name (optional) +- `attributePattern` - Lua pattern to match attribute content (optional) +- `collapse` - `true` to collapse to `{...}`, `false` to keep expanded + +Rules are checked in order; first match wins. ## Commands | Command | Description | |---------|-------------| -| `:SCLShowVariables` | Show local variables in current file | -| `:SCLShowWorkspaceTypes` | Show workspace UDTs, FBs, and Global DBs count | -| `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs | -| `:SCLPrefixWord` | Manually prefix current word with `#` | -| `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | -| `:LspSCLFormat` | Format current SCL file | -| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ | +| `:SCLShowVariables` | Show local variables | +| `:SCLShowWorkspaceTypes` | Show workspace types count | +| `:SCLRescanWorkspaceTypes` | Rescan workspace | +| `:SCLPrefixWord` | Prefix word with `#` | +| `:SCLMultilineParams` | Fill FB parameters | +| `:LspSCLFormat` | Format SCL file | +| `:SCLToggleAttrBlock` | Toggle attribute block under cursor | +| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer | +| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks | + +## Keybindings (SCL/UDT files) + +### Attribute Block Toggle +Interactive expand/collapse of individual `{...}` blocks (uses `x` prefix): + +| Key | Mode | Description | +|-----|------|-------------| +| `xa` | Normal/Insert | Toggle block under cursor | +| `xae` | Normal | Expand all collapsed blocks | +| `xac` | Normal | Collapse all attribute blocks | + +**How it works:** +1. Place cursor inside any `{...}` block +2. Press `xa` to toggle between collapsed `{...}` and expanded content +3. Original content is stored per-buffer and persists until buffer is closed +4. Works on both formatter-collapsed blocks and manually collapsed ones + +### Other Keybindings + +| Key | Mode | Description | +|-----|------|-------------| +| `xa` | Normal/Insert | Toggle attribute block under cursor | +| `xae` | Normal | Expand all attribute blocks | +| `xac` | Normal | Collapse all attribute blocks | +| `mp` | Insert | Fill multiline FB parameters | +| `` | Insert | Jump to next parameter or regular Tab | + +Note: `` is typically `\` (backslash) or `` depending on your configuration. + +### Troubleshooting + +**If commands or keybindings don't work:** + +1. Check if setup() was called: + ```lua + :lua print(vim.inspect(require("scl_lsp"))) + ``` + +2. Verify commands exist: + ```vim + :command SCLToggleAttrBlock + ``` + Should show the command definition. + +3. Check for errors during setup: + ```lua + :lua require("scl_lsp").setup({}) + ``` + +4. Verify leader key: + ```vim + :echo mapleader + ``` + If empty, your leader is `\` (backslash). + +5. Manual test keybinding: + ```vim + :nmap xa + ``` + Should show the mapping. + +## Testing + +Manual testing using: `~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project` + +Workflow: +1. Open `.scl` file in Neovim +2. Test LSP features (hover, completion, go-to-definition) +3. Verify diagnostics +4. Test formatting +5. Check workspace scanning + +## LSP Implementation Notes + +### Method Name Conversion +```lua +local method_name = message.method:gsub("/", "_") +local handler = handlers[method_name] +``` + +### Request vs Notification +```lua +if message.id then + -- Request: send response + return { id = message.id, result = result } +end +-- Notification: no response needed +``` + +### Script Path Pattern +```lua +local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "" +if script_path == "" then + script_path = "." +end +package.path = package.path .. ";" .. script_path .. "/?.lua" +``` diff --git a/README.md b/README.md index bc25a38..77f7210 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP) | **Workspace UDT Scanner** | Scans project for user-defined types (.udt files) | | **Workspace Global DB Scanner** | Scans project for global data blocks (.db files) | | **blink.cmp Integration** | Smart completion source for blink.cmp | +| **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `xa` | ## Installation @@ -116,6 +117,9 @@ require("scl_lsp").setup({ | `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | | `:LspSCLFormat` | Format current SCL file | | `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ | +| `:SCLToggleAttrBlock` | Toggle attribute block under cursor | +| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer | +| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks | ## Keybindings @@ -127,9 +131,32 @@ require("scl_lsp").setup({ | `w` | Workspace Symbols | | `mp` | Fill multiline FB/Function parameters (insert mode) | | `` | Jump to next parameter (insert mode) | +| `xa` | Toggle attribute block under cursor | +| `xae` | Expand all attribute blocks | +| `xac` | Collapse all attribute blocks | | `fw` | Workspace Symbols (Telescope) | | `ca` | Code Actions | +## Attribute Block Toggle + +Interactive expand/collapse of SCL variable attribute blocks: + +```scl +-- Before: Verbose attributes +statCounter{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int; + +-- After: Collapsed +statCounter{...} : Int; +``` + +**Usage:** +- Place cursor inside any `{...}` block +- Press `xa` to toggle expand/collapse +- Use `xae` to expand all blocks in buffer +- Use `xac` to collapse all blocks + +The formatter also supports automatic collapsing via `collapseAttributes` option. + ## External UDT Support ### Option 1: Create `.plc.json` @@ -253,10 +280,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..b8d9665 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 @@ -83,8 +98,8 @@ function M.validate_diagnostics(content, workspace_types, root_dir) end end - if in_var_section and trimmed:match("^[%w_]+%s*:") then - local var_name = trimmed:match("^([%w_]+)%s*:") + if in_var_section and (trimmed:match("^[%w_]+%s*%b{}%s*:") or trimmed:match("^[%w_]+%s*:")) then + local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:") local var_start = line:find(var_name, 1, true) if var_name and not var_name:match("^%d") then @@ -99,21 +114,37 @@ function M.validate_diagnostics(content, workspace_types, root_dir) }) end - local type_part = line:match(":%s*([^;]+)") + local type_part = nil + local brace_depth = 0 + local last_colon = nil + for i = 1, #line do + local c = line:sub(i, i) + if c == "{" then + brace_depth = brace_depth + 1 + elseif c == "}" then + brace_depth = brace_depth - 1 + elseif c == ":" and brace_depth == 0 then + last_colon = i + end + end + if last_colon then + type_part = line:sub(last_colon + 1) + end if type_part then type_part = type_part:gsub("%s*:=.*$", "") type_part = type_part:gsub("%s*$", "") type_part = type_part:gsub("^%s+", "") local base_type = type_part:match("^(%w+)") + local upper_type = base_type and base_type:upper() or nil if base_type and not all_types[base_type] and - base_type ~= "BOOL" and base_type ~= "INT" and base_type ~= "DINT" and - base_type ~= "REAL" and base_type ~= "LREAL" and base_type ~= "BYTE" and - base_type ~= "WORD" and base_type ~= "DWORD" and base_type ~= "LWORD" and - base_type ~= "CHAR" and base_type ~= "STRING" and base_type ~= "WCHAR" and - base_type ~= "WSTRING" and base_type ~= "TIME" and base_type ~= "DATE" and - base_type ~= "TIME_OF_DAY" and base_type ~= "DATE_AND_TIME" and - base_type ~= "S5TIME" and base_type ~= "LTIME" then + upper_type ~= "BOOL" and upper_type ~= "INT" and upper_type ~= "DINT" and + upper_type ~= "REAL" and upper_type ~= "LREAL" and upper_type ~= "BYTE" and + upper_type ~= "WORD" and upper_type ~= "DWORD" and upper_type ~= "LWORD" and + upper_type ~= "CHAR" and upper_type ~= "STRING" and upper_type ~= "WCHAR" and + upper_type ~= "WSTRING" and upper_type ~= "TIME" and upper_type ~= "DATE" and + upper_type ~= "TIME_OF_DAY" and upper_type ~= "DATE_AND_TIME" and + upper_type ~= "S5TIME" and upper_type ~= "LTIME" then table.insert(diagnostics, { range = range_to_lsp(line_num, 0, line_num, #line), severity = DiagnosticSeverity.Warning, diff --git a/src/formatter.lua b/src/formatter.lua index 81f405f..3e6aef7 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,6 +1,18 @@ -- 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 +-- Users can extend these via options.collapse_patterns +local DEFAULT_COLLAPSE_PATTERNS = { + -- Match S7 variable attributes: {EXTERNALACCESSIBLE := 'false'; ...} + "^%s*{%s*EXTERNAL", + -- Match S7 block attributes: { S7_Optimized_Access := 'TRUE' } or {S7_...} + "^%s*{%s*S7_", + -- Match generic attribute blocks with assignments + "^%s*{%s*%w+%s*:=", +} + local function position(line, character) return { line = line, character = character } end @@ -8,86 +20,496 @@ 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 +-- Block types with their corresponding enders +local BLOCKS = { + -- Control structures + ["IF"] = "END_IF", + ["FOR"] = "END_FOR", + ["WHILE"] = "END_WHILE", + ["REPEAT"] = "END_REPEAT", + ["CASE"] = "END_CASE", + ["REGION"] = "END_REGION", + -- Declarations + ["FUNCTION"] = "END_FUNCTION", + ["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK", + ["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK", + ["TYPE"] = "END_TYPE", + ["STRUCT"] = "END_STRUCT", + -- VAR sections (special handling) + ["VAR"] = "END_VAR", + ["VAR_INPUT"] = "END_VAR", + ["VAR_OUTPUT"] = "END_VAR", + ["VAR_IN_OUT"] = "END_VAR", + ["VAR_TEMP"] = "END_VAR", + ["VAR_CONSTANT"] = "END_VAR", + ["VAR_RETAIN"] = "END_VAR", + ["VAR_NON_RETAIN"] = "END_VAR", +} + +-- Keywords that don't change indent level themselves (handled specially) +local SAME_LEVEL = { + ["THEN"] = true, + ["ELSE"] = true, + ["ELSIF"] = true, + ["DO"] = true, + ["OF"] = true, + ["BEGIN"] = true, +} + +-- Control structure enders that need semicolons +local NEEDS_SEMICOLON = { + ["END_IF"] = true, + ["END_FOR"] = true, + ["END_WHILE"] = true, + ["END_REPEAT"] = true, + ["END_CASE"] = true, + ["END_REGION"] = true, +} + +-- VAR keywords for special handling +local VAR_KEYWORDS = { + ["VAR"] = true, + ["VAR_INPUT"] = true, + ["VAR_OUTPUT"] = true, + ["VAR_IN_OUT"] = true, + ["VAR_TEMP"] = true, + ["VAR_CONSTANT"] = true, + ["VAR_RETAIN"] = true, + ["VAR_NON_RETAIN"] = true, +} + +-- Declaration keywords (reset indent) +local DECLARATIONS = { + ["FUNCTION"] = true, + ["FUNCTION_BLOCK"] = true, + ["ORGANIZATION_BLOCK"] = true, + ["TYPE"] = true, + ["STRUCT"] = true, +} + function M.format_document(content, options) options = options or {} - local indent_size = options.indentSize or 4 - local indent_char = options.insertSpaces and " " or "\t" + local use_spaces = options.insertSpaces or false + local indent_size = options.tabSize or 1 + local indent_char = use_spaces and string.rep(" ", indent_size) or "\t" + + -- Attribute block collapsing options + local collapse_attributes = options.collapseAttributes + if collapse_attributes == nil then + collapse_attributes = true -- Default: enabled + end + + -- User can provide custom patterns or extend defaults + local collapse_patterns = options.collapsePatterns or DEFAULT_COLLAPSE_PATTERNS + if options.extendCollapsePatterns then + -- Extend defaults with user patterns + for _, pattern in ipairs(options.extendCollapsePatterns) do + table.insert(collapse_patterns, pattern) + end + end + -- Parse content into lines local lines = {} for line in content:gmatch("([^\n]*)\n") do table.insert(lines, line) end - if #lines == 0 or content:sub(-1) ~= "\n" then - table.insert(lines, "") + local last_line = content:match("([^\n]+)$") + if last_line and last_line ~= "" then + table.insert(lines, last_line) + end + if #lines == 0 then + return { { range = range(0, 0, 0, 0), newText = "" } } end + -- Stack of active blocks + -- Each entry: { type = "IF", indent = 1, is_case = false } + local stack = {} local formatted_lines = {} - local indent_level = 0 + -- FB call state + local in_fb_call = false + local fb_name_length = 0 + local fb_call_first_line = false + local fb_paren_offset = 0 -- Offset to align parameters with opening paren + + -- Helper: get current indent string local function get_indent() - return string.rep(indent_char, indent_level * indent_size) - end - - local function is_block_keyword(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local block_keywords = { - IF = true, ELSIF = true, ELSE = true, - FOR = true, WHILE = true, REPEAT = true, - CASE = true, - FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, - TYPE = true, STRUCT = true, - } - return block_keywords[kw] - end - - local function is_end_block(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local end_keywords = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_FUNCTION = true, END_FUNCTION_BLOCK = true, - END_ORGANIZATION_BLOCK = true, END_TYPE = true, END_STRUCT = true, - } - return end_keywords[kw] - end - - local function process_line(line) - local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - - if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") then - table.insert(formatted_lines, line) - return - end - - if is_end_block(trimmed) then - indent_level = math.max(0, indent_level - 1) - end - - local formatted_line = get_indent() .. trimmed - table.insert(formatted_lines, formatted_line) - - if is_block_keyword(trimmed) and not is_end_block(trimmed) then - if not trimmed:match("CASE%s+.+OF%s*$") and - not trimmed:match("IF%s+.+THEN%s*$") and - not trimmed:match("ELSIF%s+.+THEN%s*$") and - not trimmed:match("ELSE%s*$") and - not trimmed:match("FOR%s+.+DO%s*$") and - not trimmed:match("WHILE%s+.+DO%s*$") and - not trimmed:match("REPEAT%s*$") then - indent_level = indent_level + 1 + local level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + level = level + 1 end end + return string.rep(indent_char, level) end - for _, line in ipairs(lines) do - process_line(line) + -- Helper: get continuation indent for FB calls + local function get_continuation_indent() + local base = get_indent() + return base .. string.rep(" ", fb_paren_offset) end +-- Helper: check if line is a case label + local function is_case_label(trimmed) + return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil + end + + -- Helper: extract variable name from a line (before any attribute block) + local function extract_variable_name(line) + -- Match pattern: variableName{...} or variableName : type + -- Variable names can contain letters, numbers, underscores + local var_name = line:match("^%s*([%w_]+)%s*[%({:]") + return var_name + end + + -- Helper: collapse attribute blocks like {EXTERNALACCESSIBLE := 'false'; ...} to {...} + local function collapse_attribute_block(line) + if not collapse_attributes then + return line + end + + -- Check if line contains an attribute block + local attr_start, attr_end = line:find("{.-}") + if not attr_start then + return line + end + + -- Extract the content before the attribute block + local before_attr = line:sub(1, attr_start - 1) + local attr_content = line:sub(attr_start, attr_end) + local after_attr = line:sub(attr_end + 1) + + -- Extract variable name for per-variable rules + local var_name = extract_variable_name(line) + + -- Check per-variable collapse rules first (highest priority) + if options.collapseVariableRules and var_name then + for _, rule in ipairs(options.collapseVariableRules) do + local var_pattern = rule.variablePattern + local attr_pattern = rule.attributePattern + local should_collapse = rule.collapse + + -- Check if variable name matches + local var_matches = not var_pattern or var_name:match(var_pattern) + + -- Check if attribute content matches (if specified) + local attr_matches = not attr_pattern or attr_content:match(attr_pattern) + + if var_matches and attr_matches then + if should_collapse then + return before_attr .. "{...}" .. after_attr + else + -- Explicitly don't collapse this match + return line + end + end + end + end + + -- Check if this attribute block matches any global collapse pattern + for _, pattern in ipairs(collapse_patterns) do + if attr_content:match(pattern) then + -- Collapse to {...} + return before_attr .. "{...}" .. after_attr + end + end + + return line + end + + -- Helper: check if line starts FB call + local function is_fb_call_start(line) + local trimmed = line:gsub("^%s+", "") + if not (trimmed:match("^#%w+") or trimmed:match('^"')) then + return false + end + local paren_pos = trimmed:find("%(") + if not paren_pos then return false end + local assign_pos = trimmed:find(":=") + if assign_pos and assign_pos < paren_pos then return false end + local open_count, close_count = 0, 0 + for i = 1, #trimmed do + local c = trimmed:sub(i, i) + if c == "(" then open_count = open_count + 1 + elseif c == ")" then close_count = close_count + 1 end + end + return open_count > 0 and close_count < open_count + end + + -- Helper: check if FB call ends on this line + local function fb_call_has_closing(line) + local open_count, close_count = 0, 0 + for i = 1, #line do + local c = line:sub(i, i) + if c == "(" then open_count = open_count + 1 + elseif c == ")" then close_count = close_count + 1 end + end + -- FB call ends when we have closes > opens, not when they're equal + -- (equal means no parens on this line, which shouldn't end the call) + return close_count > 0 and close_count >= open_count + end + + -- Helper: check if line ends with comma (FB call continuation) + local function is_continuation(line) + return line:gsub("^%s+", ""):gsub("%s+$", ""):match(".*,$") ~= nil + end + + -- Helper: extract keyword from line + local function get_keyword(trimmed) + return trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") + end + + -- Helper: find case block in stack + local function find_case_block() + for i = #stack, 1, -1 do + if stack[i].type == "CASE" then + return stack[i], i + end + end + return nil, nil + end + + -- Helper: pop blocks until matching ender found + local function pop_to_ender(ender_type) + while #stack > 0 do + local top = stack[#stack] + table.remove(stack) + local expected_ender = BLOCKS[top.type] + if expected_ender == ender_type then + return true + end + end + return false + end + + -- Process each line + for line_num, line in ipairs(lines) do + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + local kw = get_keyword(trimmed) + + -- Handle empty lines (preserve as-is) + if trimmed == "" then + table.insert(formatted_lines, "") + goto continue + end + + -- Handle comments (re-indent with current level) + if trimmed:match("^//") or trimmed:match("^%(") then + -- Apply attribute block collapsing to comments too (for consistency) + local collapsed = collapse_attribute_block(trimmed) + table.insert(formatted_lines, get_indent() .. collapsed) + goto continue + end + + -- Apply attribute block collapsing + trimmed = collapse_attribute_block(trimmed) + + -- Check for FB call start + if not in_fb_call and is_fb_call_start(trimmed) then + local fb_name = trimmed:match("^#?([%w_]+)") or trimmed:match('^"?([%w_]+)"?') + if fb_name then + fb_name_length = #fb_name + in_fb_call = true + fb_call_first_line = true + -- Calculate paren offset: find position of opening paren + 1 + -- This aligns continuation lines with the content inside the parens + local paren_pos = trimmed:find("%(") + if paren_pos then + fb_paren_offset = paren_pos + 1 -- +1 to align with content after paren + else + fb_paren_offset = fb_name_length + 2 + end + end + end + + -- Handle FB call lines + if in_fb_call then + local is_first = fb_call_first_line + local has_closing = fb_call_has_closing(trimmed) + local is_cont = is_continuation(trimmed) + + local formatted + if has_closing then + formatted = (is_first and get_indent() or get_continuation_indent()) .. trimmed + in_fb_call = false + fb_call_first_line = false + elseif is_cont then + formatted = is_first and (get_indent() .. trimmed) or (get_continuation_indent() .. trimmed) + fb_call_first_line = false + else + formatted = get_continuation_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + goto continue + end + + -- Handle block declarations (FUNCTION_BLOCK, etc.) + if DECLARATIONS[kw] then + -- Clear stack for new block + stack = {} + table.insert(formatted_lines, trimmed) + table.insert(stack, { type = kw, indent = 0, is_declaration = true }) + goto continue + end + + -- Handle declaration enders + if kw and not BLOCKS[kw] then + for starter, ender in pairs(BLOCKS) do + if DECLARATIONS[starter] and kw == ender then + stack = {} + table.insert(formatted_lines, trimmed) + goto continue + end + end + end + + -- Handle BEGIN (transition to code section) + if kw == "BEGIN" then + -- Pop any VAR blocks + while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + -- BEGIN itself doesn't add indent, but code inside does + goto continue + end + + -- Handle VAR section start + if VAR_KEYWORDS[kw] then + -- Pop to declaration level + while #stack > 0 and not stack[#stack].is_declaration do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(stack, { type = kw, indent = #stack, is_var = true }) + goto continue + end + + -- Handle END_VAR + if kw == "END_VAR" then + -- Pop VAR blocks + while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + goto continue + end + + -- Handle block enders (END_IF, END_FOR, etc.) + if kw and BLOCKS[kw] == nil then + -- Check if it's an ender + for starter, ender in pairs(BLOCKS) do + if kw == ender and not DECLARATIONS[starter] then + -- Pop matching block + pop_to_ender(kw) + -- Add semicolon if needed + local formatted = get_indent() .. trimmed + if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then + formatted = formatted .. ";" + end + table.insert(formatted_lines, formatted) + goto continue + end + end + end + + -- Handle same-level keywords (THEN, ELSE, ELSIF) + if SAME_LEVEL[kw] then + local formatted + if kw == "THEN" then + -- Pop IF block temporarily to get correct indent + 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 + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- Push content block after THEN + table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + elseif kw == "ELSE" or kw == "ELSIF" then + -- Pop THEN_CONTENT if present + if #stack > 0 and stack[#stack].type == "THEN_CONTENT" then + table.remove(stack) + end + -- Find IF block for correct indent + 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 + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- Push new content block + table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + else + formatted = get_indent() .. trimmed + table.insert(formatted_lines, formatted) + end + goto continue + end + + -- Handle case labels + if is_case_label(trimmed) then + local case_block, case_idx = find_case_block() + if case_block then + -- Pop to just after CASE + while #stack > case_idx do + table.remove(stack) + end + -- Case label at CASE level + 1 + local label_indent = string.rep(indent_char, case_block.indent + 1) + table.insert(formatted_lines, label_indent .. trimmed) + -- Push case content level + table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true }) + else + table.insert(formatted_lines, get_indent() .. trimmed) + end + goto continue + end + + -- Handle block starters (IF, FOR, CASE, REGION, etc.) + if kw and BLOCKS[kw] and not VAR_KEYWORDS[kw] and not DECLARATIONS[kw] then + local current_level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + current_level = current_level + 1 + end + end + local formatted = string.rep(indent_char, current_level) .. trimmed + table.insert(formatted_lines, formatted) + table.insert(stack, { type = kw, indent = current_level }) + goto continue + end + + -- Regular line + local formatted = get_indent() .. trimmed + -- Add semicolon if it looks like it needs one + if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then + formatted = formatted .. ";" + end + table.insert(formatted_lines, formatted) + + ::continue:: + end + + -- Join lines local result = table.concat(formatted_lines, "\n") if content:sub(-1) == "\n" then result = result .. "\n" @@ -103,11 +525,25 @@ end function M.get_formatting_options() return { - insertSpaces = true, - tabSize = 4, + insertSpaces = false, + tabSize = 1, trimTrailingWhitespace = true, insertFinalNewline = true, trimFinalNewlines = true, + collapseAttributes = true, -- Collapse variable attribute blocks to {...} + -- collapsePatterns = { ... }, -- Override default collapse patterns + -- extendCollapsePatterns = { ... }, -- Extend default patterns with custom ones + -- collapseVariableRules = { -- Per-variable collapse rules (highest priority) + -- { + -- variablePattern = "^stat", -- Match variable names starting with "stat" + -- attributePattern = "EXTERNAL", -- Optional: also match attribute content + -- collapse = true, -- true to collapse, false to expand + -- }, + -- { + -- variablePattern = "^temp", -- Match variables starting with "temp" + -- collapse = false, -- Never collapse these + -- }, + -- }, } 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 diff --git a/src/parser.lua b/src/parser.lua index 6f03b63..ea436a4 100644 --- a/src/parser.lua +++ b/src/parser.lua @@ -78,15 +78,26 @@ function M.extract_variables(content) return end - local var_name = trimmed:match("^([%w_]+)%s*:") + local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:") if not var_name then - var_name = trimmed:match("^([%w_]+)%s*,") + var_name = trimmed:match("^([%w_]+)%s*%b{}%s*,") or trimmed:match("^([%w_]+)%s*,") end if var_name and not variables[var_name] then local col = line:find(var_name, 1, true) if col then - local type_start = line:find(":", 1, true) + local type_start = nil + local brace_depth = 0 + for i = 1, #line do + local c = line:sub(i, i) + if c == "{" then + brace_depth = brace_depth + 1 + elseif c == "}" then + brace_depth = brace_depth - 1 + elseif c == ":" and brace_depth == 0 then + type_start = i + end + end local var_data_type = "unknown" if type_start then local type_part = line:sub(type_start + 1)