Rename the project from scl_lsp to tia_lsp to future-proof for additional
TIA Portal languages (STL/AWL, LAD, FBD). The 'scl' language name is kept
as-is for the SCL filetype and tree-sitter parser; future languages get
their own names (stl, lad, fbd).
Project-wide changes:
- lua/scl_lsp/ → lua/tia_lsp/ (plugin entry point)
- lua/scl/ → lua/tia/ (language modules, shared across TIA langs)
- require('scl.*') → require('tia.*')
- require('scl_lsp') → require('tia_lsp')
- LSP client name: 'scl_lsp' → 'tia_lsp'
- Diagnostic source: 'scl_lsp' → 'tia_lsp' (src/diagnostics.lua)
- package.json name: 'scl-language-server' → 'tia-lsp'
lua/tia_lsp/init.lua:
- Add vim.fn.exepath('tia-lsp') detection so the plugin prefers the
mason-installed executable and falls back to { 'lua', server_path }
for manual installs.
- Add opts.ts_parser_url to configure the tree-sitter parser source.
- Remove hardcoded ~/dev/scl_lsp paths in favor of ~/dev/tia-lsp and
the new ts_parser_url option.
Cleanup:
- Delete lua/tia/init.lua (dead code, unreferenced duplicate of tia_lsp).
- Delete scl_lsp.sh (replaced by the mason-installed bin/tia-lsp wrapper).
- Update README.md and AGENTS.md to reflect the two-repo split
(tia-lsp server + tia-lsp.nvim plugin) and document Mason installation.
Unchanged (intentional):
- grammar.js, src/grammar.json, src/parser.c: tree-sitter language name
remains 'scl' (it's the language, not the project).
- Filetype patterns ('scl', 'udt', 'db') in autocmds.
- :SCL* command names (per-filetype prefix; future :STL* etc.).
Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
13 KiB
tia-lsp - Language Server for Siemens TIA Portal
A Neovim/LazyVim plugin and standalone LSP server providing Language Server Protocol (LSP), Linting, Formatting, and Syntax Highlighting for Siemens TIA Portal languages. Currently supports SCL (Structured Control Language), with planned support for STL/AWL, LAD, and FBD.
The project is split into two repos:
tia-lsp(this repo) — the standalone LSP server (src/) plus the tree-sitter grammar (grammar.js,src/parser.c). Speaks JSON-RPC over stdio. Editor-agnostic: usable from Neovim, VS Code, or any LSP client.tia-lsp.nvim— the Neovim plugin (lua/) that launches the server viavim.lsp.start()and adds editor-specific features (auto-prefix, blink.cmp source, workspace UDT scanning, attribute toggle, multiline params).
Features
LSP (Language Server Protocol)
| Feature | Description |
|---|---|
| Hover | Press K to see variable type and struct fields |
| Completion | # for variables, . for struct fields, ( for functions |
| Go to Definition | gD jump to function, FB, DB, or UDT definition |
| Document Symbols | Shows functions, variables, types |
| Workspace Symbols | Search across all .scl files with fuzzy matching |
| Semantic Tokens | Keywords, variables highlighted |
| Inlay Hints | Shows variable types after declaration |
| External UDT | Load types from .plc.json or data_types/ folder |
Linter (Diagnostics)
| Feature | Description |
|---|---|
| Undefined Variables | Detects variables used without declaration |
| Invalid # Prefix | Detects # prefix in VAR sections |
| Unknown Types | Warns about unknown data types |
| Type Validation | Basic type checking |
Formatter
| Feature | Description |
|---|---|
| Document Formatting | Format entire SCL files with :SCLFormat |
| Multi-line Assignments | Preserves and formats multi-line expressions with proper alignment |
| FB Call Formatting | Formats function block calls in multiline style |
| Indentation | Proper block indentation |
| Keyword Casing | Maintains SCL keyword casing |
Syntax Highlighting
- Full SCL syntax support via tree-sitter
- Block definitions: ORGANIZATION_BLOCK, FUNCTION_BLOCK, FUNCTION
- Variable sections: VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, VAR_TEMP, VAR_CONSTANT
- Control structures: IF/THEN/ELSIF/ELSE/END_IF, CASE/OF/END_CASE, FOR/TO/BY/DO/END_FOR, WHILE/END_WHILE, REPEAT/UNTIL/END_REPEAT
- Built-in data types: BOOL, INT, DINT, REAL, STRING, TIME, etc.
- Function block instances highlighted in calls
- FB parameter names (IN, PT, CU, etc.) highlighted as properties
#prefix for local variables highlighted- Operators: AND, OR, NOT, XOR, MOD
- Comments: line (
//), block ((* *)), C-style (/* */)
Additional Features
| Feature | Description |
|---|---|
| Auto-Prefix | Automatically adds # prefix to local variables (like TIA Portal) |
| Multiline Parameters | Fill FB/Function call parameters across multiple lines with <Space>mp or :SCLMultilineParams |
| Tab Navigation | Jump between parameters with <Tab> in insert mode |
| 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 <Leader>xa |
Installation
The plugin auto-detects the mason-installed tia-lsp executable via vim.fn.exepath("tia-lsp") and falls back to lua <server_path> for manual installs. See Mason installation below for the recommended path.
LazyVim
-- ~/.config/nvim/lua/plugins/tia-lsp.lua
return {
"lazar/tia-lsp.nvim",
ft = "scl",
lazy = true,
dependencies = {
"neovim/nvim-lspconfig",
"nvim-treesitter/nvim-treesitter",
"saghen/blink.cmp",
},
config = function()
require("tia_lsp").setup({
-- LSP options
lsp = {
on_attach = function(client, bufnr)
-- custom keymaps
end,
},
cmp = true, -- Enable blink.cmp integration
workspace_types = true, -- Enable workspace UDT scanning
auto_prefix = true, -- Enable auto-# prefixing
debug = false,
})
end,
}
Manual Setup
-- In your init.lua or plugin file
require("tia_lsp").setup({
server_path = "/path/to/tia-lsp/src/main.lua", -- only used if tia-lsp is not on PATH
ts_parser_url = "https://your-gitea/tia-lsp.git", -- tree-sitter parser source
cmp = true,
workspace_types = true,
auto_prefix = true,
})
Mason installation
tia-lsp can be installed via mason.nvim using a custom registry. The setup below uses a file: registry sourced from a Gitea-hosted tia-mason-registry repo (cloned by lazy.nvim), and falls back to the official mason registry for any other packages.
- Clone the registry repo as a lazy.nvim plugin:
-- ~/.config/nvim/lua/plugins/tia-mason-registry.lua return { url = "https://your-gitea/tia-mason-registry.git", name = "tia-mason-registry", lazy = false, -- must be available before mason setup } - Register the registry with mason:
-- ~/.config/nvim/lua/plugins/mason.lua return { "mason-org/mason.nvim", opts = { registries = { "file:" .. vim.fn.expand("~/.local/share/nvim/lazy/tia-mason-registry"), "github:mason-org/mason-registry", }, }, } - Install
yq(one-time prerequisite for mason'sfile:registry)::MasonInstall yq - Install the server:
:MasonInstall tia-lsp - Verify: open a
.sclfile and run:LspInfo—tia_lspshould be attached, andvim.fn.exepath("tia-lsp")should resolve to~/.local/share/nvim/mason/bin/tia-lsp.
To upgrade: tag a new vX.Y.Z release on the tia-lsp Gitea repo, bump source.id in tia-mason-registry/packages/tia-lsp/package.yaml, push the registry repo, then :MasonInstall tia-lsp again.
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
server_path |
string | ~/dev/tia-lsp/src/main.lua |
Path to LSP server (only used if tia-lsp is not on PATH) |
ts_parser_url |
string | file://~/dev/tia-lsp |
URL passed to nvim-treesitter for the SCL parser source |
lsp.on_attach |
function | nil |
Custom LSP attach callback |
lsp.formatting |
boolean | false |
Enable format command |
cmp |
boolean | true |
Enable blink.cmp integration |
workspace_types |
boolean | true |
Enable workspace UDT scanning |
auto_prefix |
boolean | true |
Enable auto-# prefixing |
project_root |
string | nil |
Custom project root |
library_paths |
table | {} |
Additional library paths |
debug |
boolean | false |
Enable debug logging |
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 |
:SCLFormat |
Format current SCL file |
:SCLToggleAttrBlock |
Toggle attribute block under cursor |
:SCLExpandAllAttrBlocks |
Expand all {...} blocks in buffer |
:SCLCollapseAllAttrBlocks |
Collapse all matching attribute blocks |
Keybindings
| Key | Action |
|---|---|
K |
Hover (show variable info) |
gd |
Go to Definition |
<leader>s |
Document Symbols |
<leader>w |
Workspace Symbols |
<Space>mp |
Fill multiline FB/Function parameters (insert mode) |
<Tab> |
Jump to next parameter (insert mode) |
<Leader>xa |
Toggle attribute block under cursor |
<Leader>xae |
Expand all attribute blocks |
<Leader>xac |
Collapse all attribute blocks |
<leader>fw |
Workspace Symbols (Telescope) |
<leader>ca |
Code Actions |
Attribute Block Toggle
Interactive expand/collapse of SCL variable attribute blocks:
-- Before: Verbose attributes
statCounter{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int;
-- After: Collapsed
statCounter{...} : Int;
Usage:
- Place cursor inside any
{...}block - Press
<Leader>xato toggle expand/collapse - Use
<Leader>xaeto expand all blocks in buffer - Use
<Leader>xacto collapse all blocks
Note: Collapsed blocks are automatically expanded before saving to preserve full content on disk. Use :SCLFormat to format, then :SCLCollapseAllAttrBlocks to collapse while preserving the ability to expand later.
External UDT Support
Option 1: Create .plc.json
{
"dataTypes": [
{
"name": "equipmentData",
"struct": [
{"name": "id", "dataTypeName": "INT"},
{"name": "status", "dataTypeName": "BOOL"},
{"name": "temperature", "dataTypeName": "REAL"}
]
}
]
}
Option 2: Auto-scan data_types/ folder
The LSP automatically scans for SCL files in data_types/ folder and extracts TYPE definitions.
Option 3: Generate .plc.json
lua ~/dev/tia-lsp/src/plc_json.lua --generate
Global Data Block Support
The plugin automatically scans for .db files in the workspace and provides auto-completion for global data blocks and their members.
How it works
- Workspace Scanning: When opening a
.sclfile, the plugin scans the project for.dbfiles - DB Parsing: Parses
DATA_BLOCKdefinitions and extracts members fromVARsections - Auto-completion: Provides smart completion for DB names and members
Usage
- Type a space or
#to trigger completion - global DB names appear alongside local variables - Type
"followed by a DB name to get member completions - Access nested members:
"MyDB".member.submember
Example
// Access global DB members
"HmiData".units
"Parameter".machine.unit100.paramTimeStampApplied
// Inside FB call parameter
#instParameterManager(hmiInterface:="HmiData".units, ...)
Project Structure
tia-lsp/
├── README.md # This file
├── package.json # NPM scripts (tree-sitter)
├── Makefile # Build commands
├── grammar.js # Tree-sitter grammar (SCL language)
├── tree-sitter.json # Parser config
├── queries/ # Tree-sitter queries
│ └── scl/ # Per-language query dir (SCL)
│ ├── highlights.scm # Syntax highlighting
│ ├── indents.scm # Indentation
│ ├── folds.scm # Code folding
│ ├── locals.scm # Variable scoping
│ └── tags.scm # Navigation tags
├── src/ # LSP server
│ ├── main.lua # Main entry point (JSON-RPC over stdio)
│ ├── parser.lua # SCL parser (will become parser_scl.lua + dispatch)
│ ├── treesitter.lua # Tree-sitter integration
│ ├── json.lua # JSON encoder/decoder
│ ├── plc_json.lua # External UDT loading (language-agnostic)
│ ├── diagnostics.lua # Linter diagnostics
│ └── formatter.lua # Document formatter
└── lua/
├── tia_lsp/ # Neovim plugin
│ ├── init.lua # Main plugin setup
│ └── plugins/
│ └── scl.lua # LazyVim plugin spec (SCL filetype)
└── tia/ # Language modules (shared across TIA languages)
├── auto_prefix.lua
├── attr_toggle.lua
├── blink_cmp_source.lua
├── builtin_instructions.lua
├── db_parser.lua
├── fb_parser.lua
├── multiline_params.lua
├── udt_parser.lua
├── variables.lua
└── workspace_types.lua
Building Tree-sitter Parser
# Generate parser from grammar.js
npm run build
# or: tree-sitter generate
# Run tests
npm test
# or: tree-sitter test
SCL Code Example
FUNCTION_BLOCK "Em101Sequence"
VAR
x : INT;
myVar : equipmentData; // User-defined type
END_VAR
IF x > 5 THEN
myVar.id := 10;
END_IF
myVar.status := TRUE;
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/tia_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.11+ (uses
vim.lsp.start()) - nvim-treesitter - Syntax highlighting
- blink.cmp - Optional, for completion
- Lua 5.1+ - LSP server runtime