Inintal commit

This commit is contained in:
2026-02-14 15:05:12 +01:00
commit 625297106e
21 changed files with 75105 additions and 0 deletions
+897
View File
@@ -0,0 +1,897 @@
#!/usr/bin/env lua
-- SCL Language Server
-- Language Server Protocol implementation for Siemens SCL
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
if script_path == "" then
script_path = "."
end
local cwd = io.popen("pwd"):read("*l")
if script_path:sub(1, 1) == "/" then
script_path = script_path:gsub("/+$", "")
elseif cwd then
script_path = cwd .. "/" .. script_path
script_path = script_path:gsub("/+", "/"):gsub("/$", "")
else
script_path = "."
end
package.path = package.path .. ";" .. script_path .. "/?.lua"
local json = dofile(script_path .. "/json.lua")
local parser = dofile(script_path .. "/parser.lua")
local treesitter = dofile(script_path .. "/treesitter.lua")
local plc_json = dofile(script_path .. "/plc_json.lua")
local diagnostics = dofile(script_path .. "/diagnostics.lua")
local formatter = dofile(script_path .. "/formatter.lua")
local documents = {}
local semanticTokenModifiers = {}
local semanticTokenTypes = {
"namespace",
"type",
"class",
"enum",
"interface",
"struct",
"typeParameter",
"parameter",
"variable",
"property",
"enumMember",
"event",
"func",
"method",
"macro",
"keyword",
"modifier",
"comment",
"string",
"number",
"operator",
}
local capabilities = {
hoverProvider = true,
completionProvider = {
triggerCharacters = { ".", "#", "(" },
resolveProvider = false,
},
definitionProvider = true,
documentSymbolProvider = true,
diagnosticProvider = { relatedDocuments = {} },
textDocumentSync = 1,
semanticTokensProvider = {
full = { delta = true },
legend = { tokenTypes = semanticTokenTypes, tokenModifiers = semanticTokenModifiers },
range = true,
},
inlayHintProvider = { resolveProvider = false },
workspaceSymbolProvider = true,
documentFormattingProvider = true,
documentRangeFormattingProvider = false,
}
local function uri_to_path(uri)
return uri:gsub("^file://", ""):gsub("%%20", " ")
end
local function path_to_uri(path)
return "file://" .. path:gsub(" ", "%%20")
end
local function run_command(cmd)
local handle = io.popen(cmd)
if not handle then
return nil
end
local result = handle:read("*a")
handle:close()
return result
end
local function scan_scl_files(root_dir)
local files = {}
if not root_dir or root_dir == "" then
return files
end
local result = run_command("find " .. root_dir .. ' -type f -name "*.scl" 2>/dev/null')
if result then
for f in result:gmatch("[^\n]+") do
if f and f ~= "" then
table.insert(files, f)
end
end
end
return files
end
local function parse_scl_file(filepath)
local handle = io.open(filepath, "r")
if not handle then
return nil, nil, nil
end
local content = handle:read("*a")
handle:close()
local variables = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
return variables, functions, types
end
local workspace_symbol_cache = {}
local function clear_symbol_cache()
workspace_symbol_cache = {}
end
local function fuzzy_match(text, pattern)
if not text or not pattern then
return false
end
text = text:lower()
pattern = pattern:lower()
local text_len = #text
local pattern_len = #pattern
local pattern_idx = 1
for i = 1, text_len do
if pattern_idx <= pattern_len and text:sub(i, i) == pattern:sub(pattern_idx, pattern_idx) then
pattern_idx = pattern_idx + 1
end
end
return pattern_idx > pattern_len
end
local handlers = {}
function handlers.initialize(params)
return {
capabilities = capabilities,
serverInfo = { name = "scl-language-server", version = "1.0.0" },
}
end
function handlers.initialized(params) end
function handlers.shutdown(params)
return nil
end
function handlers.exit(params)
os.exit(0)
end
function handlers.textDocument_didOpen(params)
local uri = params.textDocument.uri
local content = params.textDocument.text
local path = uri_to_path(uri)
local root_dir = path:match("(.*/)") or "."
local variables, var_positions = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
local external_types = plc_json.get_types(root_dir)
for name, ext_type in pairs(external_types) do
if not types[name] then
types[name] = ext_type
end
end
local tree = nil
local ts_ok = treesitter.init()
if ts_ok then
tree, _ = treesitter.parse_scl(content)
end
documents[uri] = {
content = content,
version = params.textDocument.version,
path = path,
root_dir = root_dir,
variables = variables,
variable_positions = var_positions,
functions = functions,
types = types,
tree = tree,
}
end
function handlers.textDocument_didChange(params)
local uri = params.textDocument.uri
local content = params.contentChanges[1].text
local variables, var_positions = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
local doc = documents[uri]
local root_dir = doc and doc.root_dir or ""
local external_types = plc_json.get_types(root_dir)
for name, ext_type in pairs(external_types) do
if not types[name] then
types[name] = ext_type
end
end
local tree = nil
local ts_ok = treesitter.init()
if ts_ok then
tree, _ = treesitter.parse_scl(content)
end
documents[uri] = {
content = content,
version = params.textDocument.version,
path = doc and doc.path or "",
root_dir = root_dir,
variables = variables,
variable_positions = var_positions,
functions = functions,
types = types,
tree = tree,
}
clear_symbol_cache()
end
function handlers.textDocument_didClose(params)
documents[params.textDocument.uri] = nil
clear_symbol_cache()
end
function handlers.textDocument_hover(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
return nil
end
local line_idx = params.position.line + 1
if line_idx > #lines then
return nil
end
local line = lines[line_idx]
local col = params.position.character + 1
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1)
local var_name = word:gsub("^#", "")
if var_name ~= "" and doc.variables and doc.variables[var_name] then
local var_info = doc.variables[var_name]
local detail = string.format(
"**Local Variable** `#%s`\nScope: `%s`\nType: `%s`\nDeclared at line %d",
var_name,
var_info.type,
var_info.data_type or "unknown",
var_info.line + 1
)
return {
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
if doc.types then
for type_name, type_info in pairs(doc.types) do
if type_name == word then
local detail = string.format("**Type**: `%s`\nKind: %s", type_name, type_info.kind)
if type_info.kind == "struct" and type_info.fields then
detail = detail .. "\n\n**Fields:**\n"
for _, field in ipairs(type_info.fields) do
detail = detail .. string.format("- `%s`: %s\n", field.name, field.type)
end
end
return {
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
end
end
return nil
end
function handlers.textDocument_completion(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { items = {} }
end
local items = {}
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local line_idx = params.position.line + 1
if line_idx <= #lines then
local line = lines[line_idx]
local col = params.position.character
local trigger = col > 0 and line:sub(col, col) or ""
if trigger == "#" then
if doc.variables then
for name, info in pairs(doc.variables) do
table.insert(items, {
label = "#" .. name,
kind = 13,
detail = "Local variable (" .. (info.type or "unknown") .. ")",
insertText = "#" .. name,
insertTextFormat = 1,
})
end
end
elseif trigger == "." then
local prefix_start = col - 1
while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do
prefix_start = prefix_start - 1
end
local prefix = line:sub(prefix_start + 1, col - 1)
if prefix ~= "" then
local var_name = prefix:gsub("^#", "")
local var_info = doc.variables and doc.variables[var_name]
local data_type = var_info and var_info.data_type
if data_type and doc.types then
local struct_name = data_type
if doc.types[struct_name] and doc.types[struct_name].fields then
for _, field in ipairs(doc.types[struct_name].fields) do
table.insert(items, {
label = field.name,
kind = 13,
detail = struct_name .. "." .. field.name .. ": " .. field.type,
insertText = field.name,
insertTextFormat = 1,
})
end
end
end
end
if #items == 0 then
local keywords = {
{ label = "AND", detail = "Logical AND", kind = 14 },
{ label = "OR", detail = "Logical OR", kind = 14 },
{ label = "XOR", detail = "Logical XOR", kind = 14 },
{ label = "NOT", detail = "Logical NOT", kind = 14 },
{ label = "MOD", detail = "Modulo", kind = 14 },
}
for _, kw in ipairs(keywords) do
table.insert(items, kw)
end
end
elseif trigger == "(" then
if doc.functions then
for _, func in ipairs(doc.functions) do
table.insert(items, {
label = func.name,
kind = 3,
detail = func.kind:upper(),
insertText = func.name .. "()",
insertTextFormat = 2,
})
end
end
end
end
return { isIncomplete = false, items = items }
end
function handlers.textDocument_definition(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if params.position.line + 1 > #lines then
return nil
end
local line = lines[params.position.line + 1]
local col = params.position.character + 1
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1):gsub("^#", "")
if doc.variable_positions and doc.variable_positions[word] then
local pos = doc.variable_positions[word]
return {
uri = uri,
range = {
start = { line = pos.line, character = pos.character },
endPos = { line = pos.line, character = pos.character + #word },
},
}
end
return nil
end
function handlers.textDocument_documentSymbol(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return {}
end
local symbols = {}
local kind_map = {
["function"] = 14,
["function_block"] = 11,
["organization_block"] = 25,
["struct"] = 23,
["alias"] = 7,
["array"] = 24,
}
if doc.functions then
for _, func in ipairs(doc.functions) do
table.insert(symbols, {
name = func.kind:upper() .. " " .. func.name,
kind = kind_map[func.kind] or 14,
range = { start = { line = func.start, character = 0 }, endPos = { line = func.final, character = 0 } },
selectionRange = {
start = { line = func.start, character = 0 },
endPos = { line = func.start, character = #func.name },
},
containerName = nil,
})
end
end
if doc.variables then
for var_name, var_info in pairs(doc.variables) do
local var_kind = 13
if var_info.type and (var_info.type:find("CONSTANT") or var_info.type:find("RETAIN")) then
var_kind = 26
end
table.insert(symbols, {
name = "#" .. var_name,
kind = var_kind,
detail = var_info.type or "VAR",
range = {
start = { line = var_info.line, character = 0 },
endPos = { 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 },
},
containerName = nil,
})
end
end
if doc.types then
for type_name, type_info in pairs(doc.types) do
local kind = kind_map[type_info.kind] or 7
local detail = type_info.kind:upper()
if type_info.kind == "alias" and type_info.base_type then
detail = detail .. " (" .. type_info.base_type .. ")"
elseif type_info.kind == "struct" and type_info.fields and #type_info.fields > 0 then
detail = detail .. " (" .. #type_info.fields .. " fields)"
end
table.insert(symbols, {
name = "TYPE " .. type_name,
kind = kind,
detail = detail,
range = {
start = { line = type_info.start_line or 0, character = 0 },
endPos = { 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 },
},
containerName = nil,
})
end
end
return symbols
end
function handlers.textDocument_diagnostic(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { items = {} }
end
local external_types = {}
if doc.root_dir then
external_types = plc_json.get_types(doc.root_dir)
end
local diag_results = diagnostics.validate_diagnostics(doc.content, external_types, doc.root_dir)
return { items = diag_results }
end
function handlers.textDocument_semanticTokensFull(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { data = {} }
end
local tokens = {}
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local function add_token(line, startChar, length, tokenType)
local deltaLine = line
local deltaStart = startChar
if #tokens >= 4 then
local prevLine = tokens[#tokens - 3]
local prevStart = tokens[#tokens - 2]
if line == prevLine then
deltaLine = 0
deltaStart = startChar - prevStart
else
deltaLine = line - prevLine
end
end
table.insert(tokens, deltaLine)
table.insert(tokens, deltaStart)
table.insert(tokens, length)
table.insert(tokens, 0)
table.insert(tokens, 0)
end
for i, line in ipairs(lines) do
local lineNum = i - 1
local trimmed = line:gsub("^%s+", "")
if doc.variables then
for var_name, info in pairs(doc.variables) do
if info.line == lineNum then
local startChar = line:find(var_name, 1, true)
if startChar then
add_token(lineNum, startChar - 1, #var_name, "variable")
end
end
end
end
local keywords = {
"IF",
"THEN",
"ELSIF",
"ELSE",
"END_IF",
"FOR",
"TO",
"BY",
"DO",
"END_FOR",
"WHILE",
"END_WHILE",
"REPEAT",
"UNTIL",
"END_REPEAT",
"CASE",
"OF",
"END_CASE",
"VAR_INPUT",
"VAR_OUTPUT",
"VAR_IN_OUT",
"VAR_TEMP",
"VAR",
"END_VAR",
"BEGIN",
"EXIT",
"CONTINUE",
"RETURN",
"TRUE",
"FALSE",
}
for _, kw in ipairs(keywords) do
local startPos = line:find("%f[%a]" .. kw .. "%f[%A]")
if startPos then
add_token(lineNum, startPos - 1, #kw, "keyword")
end
end
end
return { data = tokens }
end
function handlers.textDocument_semanticTokensRange(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { data = {} }
end
return handlers.textDocument_semanticTokensFull(params)
end
function handlers.textDocument_inlayHint(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return {}
end
local hints = {}
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
for i, line in ipairs(lines) do
local lineNum = i - 1
if doc.variables then
for var_name, info in pairs(doc.variables) do
if info.line == lineNum and info.data_type and info.data_type ~= "unknown" then
local colonPos = line:find(":", 1, true)
if colonPos then
table.insert(hints, {
position = { line = lineNum, character = #line },
label = ": " .. info.data_type,
kind = 1,
paddingLeft = true,
})
end
end
end
end
end
return hints
end
function handlers.workspace_symbol(params)
local query = params.query or ""
local max_results = params.maxResults or 50
if query == "" then
return {}
end
local results = {}
local function add_result(name, kind, uri, line, col, length)
table.insert(results, {
name = name,
kind = kind,
location = {
uri = uri,
range = {
start = { line = line, character = col },
endPos = { line = line, character = col + length },
},
},
})
end
for uri, doc in pairs(documents) do
if doc.functions then
for _, func in ipairs(doc.functions) do
if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then
add_result(
func.kind:upper() .. " " .. func.name,
func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25),
uri,
func.start,
0,
#func.name
)
end
end
end
if doc.variables then
for var_name, var_info in pairs(doc.variables) do
if fuzzy_match(var_name, query) then
add_result("#" .. var_name, 13, uri, var_info.line, var_info.character, #var_name)
end
end
end
if doc.types then
for type_name, type_info in pairs(doc.types) do
if fuzzy_match(type_name, query) then
local kind = 7
if type_info.kind == "struct" then
kind = 23
elseif type_info.kind == "array" then
kind = 24
end
add_result("TYPE " .. type_name, kind, uri, type_info.start_line or 0, 0, #type_name)
end
end
end
end
local scanned_dirs = {}
for _, doc in pairs(documents) do
if doc.root_dir and doc.root_dir ~= "" then
scanned_dirs[doc.root_dir] = true
end
end
local function search_file(filepath)
local cached_file = filepath
if not workspace_symbol_cache[":file:" .. cached_file] then
local variables, functions, types = parse_scl_file(filepath)
workspace_symbol_cache[":file:" .. cached_file] = {
variables = variables,
functions = functions,
types = types,
}
end
local cached_data = workspace_symbol_cache[":file:" .. cached_file]
local file_uri = path_to_uri(filepath)
if cached_data.functions then
for _, func in ipairs(cached_data.functions) do
if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then
add_result(
func.kind:upper() .. " " .. func.name,
func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25),
file_uri,
func.start,
0,
#func.name
)
end
end
end
if cached_data.variables then
for var_name, var_info in pairs(cached_data.variables) do
if fuzzy_match(var_name, query) then
add_result("#" .. var_name, 13, file_uri, var_info.line, var_info.character, #var_name)
end
end
end
if cached_data.types then
for type_name, type_info in pairs(cached_data.types) do
if fuzzy_match(type_name, query) then
local kind = 7
if type_info.kind == "struct" then
kind = 23
elseif type_info.kind == "array" then
kind = 24
end
add_result("TYPE " .. type_name, kind, file_uri, type_info.start_line or 0, 0, #type_name)
end
end
end
end
for root_dir, _ in pairs(scanned_dirs) do
local scl_files = scan_scl_files(root_dir)
for _, filepath in ipairs(scl_files) do
search_file(filepath)
end
end
local final_results = {}
for i = 1, math.min(#results, max_results) do
table.insert(final_results, results[i])
end
return final_results
end
function handlers.textDocument_formatting(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local options = params.options or {}
local format_options = {
insertSpaces = options.insertSpaces,
tabSize = options.tabSize,
indentSize = options.tabSize or 4,
}
return formatter.format_document(doc.content, format_options)
end
local function handle_message(message)
if message.method == "$/cancelRequest" then
return nil
end
local handler = handlers[message.method]
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) } }
end
else
return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } }
end
end
local function main()
local stdin = io.input()
local stdout = io.output()
local content_length = nil
while true do
local line = stdin:read("*l")
if not line then
break
end
if line:match("^Content%-Length:%s*(%d+)") then
content_length = tonumber(line:match("^Content%-Length:%s*(%d+)"))
elseif line == "" then
if content_length then
local body = stdin:read(content_length)
local message = json.decode(body)
if message.method == "exit" then
break
end
local response = handle_message(message)
if response then
local response_body = json.encode(response)
stdout:write("Content-Length: " .. #response_body .. "\r\n")
stdout:write("Content-Type: application/json\r\n\r\n")
stdout:write(response_body)
stdout:flush()
end
content_length = nil
end
end
end
end
main()