Compare commits

..
26 Commits
Author SHA1 Message Date
Christoph Brandau 5cba6763f1 Merge branch 'new_features'
/ build_and_publish (release) Successful in 36s
2025-08-12 13:21:27 +02:00
Christoph Brandau e20bb85ac6 fix: wrong highligthing 2025-08-12 13:18:08 +02:00
Christoph 98a0b2197d Update version to 2025.8.202 2025-08-12 09:38:34 +00:00
Christoph Brandau 480978d6bb Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features
/ build_and_publish (release) Failing after 31s
2025-08-12 11:27:20 +02:00
Christoph Brandau 04798328bd fix: formatter 2025-08-12 11:27:14 +02:00
Christoph Brandau ae835089b6 fix: formatter when \ was deleting 2025-08-12 11:26:52 +02:00
Christoph 0ed1e887fc Update version to 2025.9.100 2025-08-12 07:21:23 +00:00
Christoph Brandau 12b7b62aa5 add variable indexing keep track of postion
/ build_and_publish (release) Successful in 39s
2025-08-12 09:07:04 +02:00
Christoph Brandau 7263b6f530 optimze Server 2025-08-12 07:48:05 +02:00
Christoph Brandau c0635e48ea Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features 2025-08-12 07:15:21 +02:00
Christoph Brandau 8fb98d2f40 refacxtor the code 2025-08-12 07:15:18 +02:00
Christoph f24b280edf mod: Keywords 2025-08-11 22:06:49 +02:00
Christoph b61cff3b3b Update version to 2025.9.4 2025-08-11 19:46:20 +00:00
Christoph feba88212f fix: some highlight error
/ build_and_publish (release) Successful in 29s
2025-08-11 21:36:04 +02:00
Christoph b141818251 Update version to 2025.9.3 2025-08-11 16:41:00 +00:00
Christoph Brandau 72f894b05d add goto
/ build_and_publish (release) Successful in 31s
2025-08-11 18:40:06 +02:00
Christoph Brandau 0a26cb5c0f add outline to lsp 2025-08-11 18:28:23 +02:00
Christoph b3cac5c292 Update version to 2025.9.2 2025-08-11 15:20:46 +00:00
Christoph Brandau ef558c3aa3 update pipline
/ build_and_publish (release) Successful in 28s
2025-08-11 17:19:47 +02:00
Christoph Brandau 9d5ef23162 update pipline
/ build_and_publish (release) Failing after 26s
2025-08-11 17:16:27 +02:00
Christoph Brandau c25bf79427 update pipline
/ build_and_publish (release) Failing after 32s
2025-08-11 17:00:14 +02:00
Christoph a90e15514b Update .gitea/workflows/build_and_puplish.yml
/ build_and_publish (release) Failing after 31s
2025-08-11 14:23:08 +00:00
Christoph Brandau 2c2579319d update hover feature
/ build_and_publish (release) Failing after 27s
2025-08-11 16:20:14 +02:00
Christoph Brandau b38fab1428 Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features 2025-08-11 16:05:58 +02:00
Christoph Brandau bc745622b9 add first proc doc 2025-08-11 15:59:09 +02:00
Christoph Brandau 0bb4ef731e add first proc doc 2025-08-11 10:15:58 +02:00
14 changed files with 1201 additions and 106 deletions
+23 -3
View File
@@ -5,8 +5,11 @@ jobs:
build_and_publish: build_and_publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout branch from release
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ github.event.release.target_commitish }}
fetch-depth: 0
- name: GitHub Tag Name example - name: GitHub Tag Name example
run: | run: |
echo "Tag name from GITHUB_REF_NAME: $GITHUB_REF_NAME" echo "Tag name from GITHUB_REF_NAME: $GITHUB_REF_NAME"
@@ -32,8 +35,25 @@ jobs:
git config user.name "${{ vars.USERNAME_GIT }}" git config user.name "${{ vars.USERNAME_GIT }}"
git config user.email "${{ vars.EMAIL_GIT }}" git config user.email "${{ vars.EMAIL_GIT }}"
git add package.json git add package.json
git commit -m "Update version to ${{ github.ref_name }}" git commit -m "Update version to ${{ github.ref_name }}" || echo "No changes to commit."
git push origin HEAD:main
# Get current branch; if detached, resolve to a remote branch that contains this commit
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$BRANCH" == "HEAD" || -z "$BRANCH" ]]; then
# If target_commitish is a branch, use it; if its a SHA, find a branch that contains it
CANDIDATE='${{ github.event.release.target_commitish }}'
if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then
BRANCH="$(git branch -r --contains "$GITHUB_SHA" | sed -n 's|.*origin/||p' | head -n1)"
else
BRANCH="$CANDIDATE"
fi
fi
if [[ -z "$BRANCH" ]]; then
echo "Could not determine branch for push."; git branch -a; exit 1
fi
git push origin HEAD:refs/heads/$BRANCH
- name: Publish to Visual Studio Marketplace (Pre-Release) - name: Publish to Visual Studio Marketplace (Pre-Release)
if: ${{ github.event.release.prerelease }} if: ${{ github.event.release.prerelease }}
run: vsce publish --pre-release run: vsce publish --pre-release
+1 -5
View File
@@ -179,11 +179,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(formatDefProvider) context.subscriptions.push(formatDefProvider)
const tclOutlineProvider = vscode.languages.registerDocumentSymbolProvider( // Outline now provided by the language server (Document Symbols). Removing TS provider.
{ scheme: "file", language: "tcl" },
{ provideDocumentSymbols: tclDocumentSymbolProvider }
)
context.subscriptions.push(tclOutlineProvider)
// Diagnostics collection // Diagnostics collection
const diagnosticCollectionCdl = vscode.languages.createDiagnosticCollection("cdl") const diagnosticCollectionCdl = vscode.languages.createDiagnosticCollection("cdl")
+6 -3
View File
@@ -2,7 +2,7 @@
"name": "nx-post-support", "name": "nx-post-support",
"displayName": "NX Postprocessor Support", "displayName": "NX Postprocessor Support",
"description": "", "description": "",
"version": "2025.8.201", "version": "2025.8.202",
"publisher": "Christoph", "publisher": "Christoph",
"icon": "images/nx-1.png", "icon": "images/nx-1.png",
"serverInfo": { "serverInfo": {
@@ -17,7 +17,10 @@
"keywords": [ "keywords": [
"cdl", "cdl",
"NX CDL", "NX CDL",
"UDE" "UDE",
"Postprocessor",
"NX CAM",
"Siemens NX"
], ],
"engines": { "engines": {
"vscode": "^1.96.0" "vscode": "^1.96.0"
@@ -129,4 +132,4 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }
+211 -73
View File
@@ -9,7 +9,9 @@ import os
import pathlib import pathlib
import re import re
import sys import sys
from typing import Any, List, Optional, Tuple import threading
from typing import Any, Optional
import operator import operator
from functools import reduce from functools import reduce
@@ -49,7 +51,6 @@ from lsp_tclserver import TclLanguageServer
WORKSPACE_SETTINGS = {} WORKSPACE_SETTINGS = {}
GLOBAL_SETTINGS = {} GLOBAL_SETTINGS = {}
RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py"
MAX_WORKERS = 5 MAX_WORKERS = 5
@@ -58,9 +59,7 @@ LSP_SERVER = TclLanguageServer(name="NX Postprocessor Support", version="0.0.1",
# ********************************************************** # **********************************************************
# Tool specific code goes below this. # Tool specific code goes below this.
# ********************************************************** # **********************************************************
TOOL_MODULE = "nx-post-support"
TOOL_DISPLAY = "NX Postprocessor Support"
TOOL_ARGS = [] # default arguments always passed to your tool.
# Delete "Linting features" section if your tool is NOT a linter. # Delete "Linting features" section if your tool is NOT a linter.
# ********************************************************** # **********************************************************
@@ -76,6 +75,8 @@ def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
"""LSP handler for textDocument/didOpen request.""" """LSP handler for textDocument/didOpen request."""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.compute_diagnostics(document) LSP_SERVER.compute_diagnostics(document)
# Also update custom completion and proc docs for this file
LSP_SERVER.update_poco_completion_for_file(document)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
@@ -85,7 +86,7 @@ def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
def did_close(params: lsp.DidCloseTextDocumentParams) -> None: def did_close(_: lsp.DidCloseTextDocumentParams) -> None:
"""LSP handler for textDocument/didClose request.""" """LSP handler for textDocument/didClose request."""
@@ -123,14 +124,47 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION)
def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
_ = LSP_SERVER.workspace.get_text_document(params.text_document.uri) from tools.variable_index import build_variable_index
poco = [] from tools.completion_items import BUILTIN_VAR_LABELS
for key, value in LSP_SERVER.poco_completion.items(): doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
poco.extend(value)
items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco # Base items
return lsp.CompletionList(is_incomplete=False, items=items) poco = [item for items in LSP_SERVER.poco_completion.values() for item in items]
base_items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco
# Build variable index from current document
globals_set, procs_locals, proc_ranges = build_variable_index(doc.source)
# Always include globals (excluding built-ins)
dynamic_items = []
for name in sorted(globals_set):
if name not in BUILTIN_VAR_LABELS:
dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable))
# Include proc-local variables when cursor is inside that proc
pos = params.position
if pos is not None:
for pr in proc_ranges:
if pr.start_line <= pos.line <= (pr.end_line or pr.start_line):
for name in sorted(procs_locals.get(pr.name, set())):
# Exclude built-ins and globals to avoid duplication
if name not in BUILTIN_VAR_LABELS and name not in globals_set:
dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable))
break
# Merge with de-duplication for variables only
merged: list[lsp.CompletionItem] = []
seen_var_labels: set[str] = set()
for it in base_items + dynamic_items:
if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable:
if it.label in seen_var_labels:
continue
seen_var_labels.add(it.label)
merged.append(it)
return lsp.CompletionList(is_incomplete=False, items=merged)
# @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) # @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
@@ -142,21 +176,31 @@ def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]:
# return symbols # return symbols
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
def document_symbols(params: lsp.DocumentSymbolParams):
from tools.document_symbols import build_document_symbols
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
return build_document_symbols(doc.source)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT)
def inlay_hints(params: lsp.InlayHintParams): def inlay_hints(params: lsp.InlayHintParams):
log_to_output(str(GLOBAL_SETTINGS.get("inlayHint", False)))
if not GLOBAL_SETTINGS.get("inlayHint", False): if not GLOBAL_SETTINGS.get("inlayHint", False):
return [] return []
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
tree = LSP_SERVER.parser.parse(document.source)
# collect Inlay Hints # Reuse cached AST
hints = [] tree = LSP_SERVER.get_tree(document)
for key, value in LSP_SERVER.proc_signatures.items():
generator = InlayHintGenerator(LSP_SERVER.proc_signatures[key]) # Merge proc signatures across files and traverse once
tree.accept(generator, recurse=True) merged_signatures = {}
hints += generator.hints for sigs in LSP_SERVER.proc_signatures.values():
return hints merged_signatures.update(sigs)
generator = InlayHintGenerator(merged_signatures)
tree.accept(generator, recurse=True)
return generator.hints
@LSP_SERVER.feature( @LSP_SERVER.feature(
@@ -173,7 +217,8 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
plugins = [] plugins = []
hl = _Highlighter(plugins, LSP_SERVER.poco_completion) hl = _Highlighter(plugins, LSP_SERVER.poco_completion)
tree = LSP_SERVER.parser.parse(document.source) # Reuse cached AST
tree = LSP_SERVER.get_tree(document)
tree.accept(hl, recurse=True) tree.accept(hl, recurse=True)
tokens = hl.tokens() tokens = hl.tokens()
@@ -182,7 +227,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
[ [
token.line, token.line,
token.offset, token.offset,
token.lenght, token.length,
TOKEN_TYPES.index(token.tok_type), TOKEN_TYPES.index(token.tok_type),
reduce(operator.or_, token.tok_modifiers, 0), reduce(operator.or_, token.tok_modifiers, 0),
] ]
@@ -202,6 +247,13 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
except IndexError: except IndexError:
return None return None
# Do not show hover for proc name in its declaration
from tools.proc_docs import is_proc_declaration_position
if is_proc_declaration_position(document.source, pos.line, pos.character):
return None
# Identify the token under the cursor
for m in re.finditer(r"\b\w+\b", line): for m in re.finditer(r"\b\w+\b", line):
if m.start() <= col <= m.end(): if m.start() <= col <= m.end():
token = m.group(0) token = m.group(0)
@@ -209,49 +261,121 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
else: else:
return None return None
# 1) If token is a known MOM proc/variable, return built-in hover
command = token command = token
data = standard_items.json_data data = standard_items.json_data
all_items = data.get("MOM_procs", []) + data.get("mom_variables", []) all_items = data.get("MOM_procs", []) + data.get("mom_variables", [])
match = next((item for item in all_items if item["label"] == command), None) match = next((item for item in all_items if item["label"] == command), None)
if not match: if match and match.get("kind") == "function":
return None label = match.get("label", "")
parameters = match.get("parameters", [])
if not match.get("kind") == "function": param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
return None example_data = match.get("example", [])
example_md = "\n".join(f"{line}" for line in example_data)
label = match.get("label", "") returns_data = match.get("returns", ["None"])
returns_md = "\n".join(f"- {line}" for line in returns_data)
parameters = match.get("parameters", []) doc_md = f"""\
param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
example_data = match.get("example", [])
example_md = "\n".join(f"{line}" for line in example_data)
returns_data = match.get("returns", ["None"])
returns_md = "\n".join(f"- {line}" for line in returns_data)
doc_md = f"""\
### 📘 {label} ### 📘 {label}
**Purpose** **Purpose**
{match.get("description", "No description available.")} {match.get("description", "No description available.")}
**Format** **Format**
`{match.get("format", label)}` `{match.get("format", label)}`
**Parameters** **Parameters**
{param_lines} {param_lines}
**Return value** **Return value**
{returns_md} {returns_md}
**Example** **Example**
```tcl ```tcl
{example_md}""" {example_md}"""
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md))
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md)) # 2) Otherwise, check if the token is a custom proc and show its preceding doc block
# Build a merged map of proc -> docs gathered during initialization and updates
proc_docs: dict[str, str] = {}
for file_docs in LSP_SERVER.proc_docs.values():
proc_docs.update(file_docs)
if token in proc_docs:
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_docs[token]))
return None
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
def goto_definition(params: lsp.DefinitionParams):
"""Provide go-to-definition locations for Tcl procs.
Strategy:
- Find the token under the cursor.
- If it matches a custom proc collected in proc_signatures, locate its declaration
by searching the current document first, then other indexed files.
- Return a Location pointing to the proc name in its declaration line.
"""
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
pos = params.position
try:
line = doc.lines[pos.line]
except IndexError:
return None
# Identify token under cursor
token = None
for m in re.finditer(r"\b\w+\b", line):
if m.start() <= pos.character <= m.end():
token = m.group(0)
break
if not token:
return None
# Helper to search a single source text for a proc declaration
def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]:
lines = source_text.split("\n")
pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b")
for i, ln in enumerate(lines):
m = pattern.match(ln)
if m:
start_char = ln.find(token)
if start_char < 0:
start_char = max(m.end() - len(token), 0)
start = lsp.Position(i, start_char)
end = lsp.Position(i, start_char + len(token))
return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end))
return None
# 1) Search in current document
loc = find_decl_in_source(doc.source, doc.uri)
if loc:
return loc
# 2) Search in indexed files from proc_signatures
# Build list of candidate files that declare this token as a proc
candidate_files: list[str] = []
for file_path, procs in LSP_SERVER.proc_signatures.items():
if token in procs:
candidate_files.append(file_path)
for fp in candidate_files:
uri = pathlib.Path(fp).as_uri()
# Try to get from workspace if available; else read from disk
try:
other_doc = LSP_SERVER.workspace.get_text_document(uri)
source = other_doc.source
except Exception:
try:
source = pathlib.Path(fp).read_text(encoding="utf-8")
except Exception:
continue
loc = find_decl_in_source(source, uri)
if loc:
return loc
return None
# ********************************************************** # **********************************************************
@@ -292,7 +416,7 @@ def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | Non
# Required Language Server Initialization and Exit handlers. # Required Language Server Initialization and Exit handlers.
# ********************************************************** # **********************************************************
@LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION) @LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION)
def did_change_configuration(params: lsp.DidChangeConfigurationParams): def did_change_configuration(_: lsp.DidChangeConfigurationParams):
"""LSP Handler for Config Changes""" """LSP Handler for Config Changes"""
@@ -312,40 +436,54 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
log_to_output(f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n") log_to_output(f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n")
semantic_tokens_legend = lsp.SemanticTokensLegend( semantic_tokens_legend = lsp.SemanticTokensLegend(
token_types=TOKEN_TYPES, token_types=TOKEN_TYPES,
token_modifiers=TokenModifier, token_modifiers=[m.name for m in TokenModifier],
) )
return lsp.InitializeResult( return lsp.InitializeResult(
capabilities=lsp.ServerCapabilities( capabilities=lsp.ServerCapabilities(
document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True),
semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False), semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False),
definition_provider=True,
) )
) )
@LSP_SERVER.feature(lsp.INITIALIZED) @LSP_SERVER.feature(lsp.INITIALIZED)
def initialized(params: lsp.InitializedParams): def initialized(_params: lsp.InitializedParams):
root = LSP_SERVER.workspace.root_path """Kick off background indexing to avoid blocking initialization."""
psc_files = get_all_psc_files(pathlib.Path(root))
for psc_file in psc_files: def index_workspace():
poco_files = read_psc_file(psc_file) try:
for sourced_layer in poco_files: root = LSP_SERVER.workspace.root_path
completion.reset() log_to_output("Background indexing started...")
try: psc_files = get_all_psc_files(pathlib.Path(root))
file_root = pathlib.Path(root).joinpath(sourced_layer.subfolder if sourced_layer.subfolder else "") for psc_file in psc_files:
for tcl_file in sourced_layer.files: poco_files = read_psc_file(psc_file)
filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl") for sourced_layer in poco_files:
if not filepath.exists():
continue
completion.reset() completion.reset()
document = LSP_SERVER.workspace.get_text_document(filepath.as_uri()) # filepath.read_text(encoding="utf-8") try:
tree = LSP_SERVER.parser.parse(document.source) file_root = pathlib.Path(root).joinpath(sourced_layer.subfolder if sourced_layer.subfolder else "")
tree.accept(completion, recurse=True) for tcl_file in sourced_layer.files:
remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion) filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl")
LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions if not filepath.exists():
remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) continue
LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures completion.reset()
except Exception as e: document = LSP_SERVER.workspace.get_text_document(filepath.as_uri())
log_to_output(f"Fehler beim Parsen von {filepath}: {e}") tree = LSP_SERVER.parser.parse(document.source)
tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion)
LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions
remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures)
LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures
from tools.proc_docs import build_proc_docs
LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(tree, document.source)
except Exception as e:
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
log_to_output("Background indexing completed.")
except Exception as e:
log_to_output(f"Background indexing failed: {e}")
threading.Thread(target=index_workspace, name="nxps-indexer", daemon=True).start()
@LSP_SERVER.feature(lsp.EXIT) @LSP_SERVER.feature(lsp.EXIT)
+38 -5
View File
@@ -11,6 +11,7 @@ from plugins.poco_plugin import commands
from tools import checks, parser from tools import checks, parser
from pygls import server, uris from pygls import server, uris
from tools.completion_items import completion, remove_existing_items, remove_shared_keys from tools.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.proc_docs import build_proc_docs
DIAGNOSTIC_SOURCE = "nx-post-support" DIAGNOSTIC_SOURCE = "nx-post-support"
@@ -25,6 +26,38 @@ class TclLanguageServer(server.LanguageServer):
self.diagnostics = {} self.diagnostics = {}
self.poco_completion: dict = {} self.poco_completion: dict = {}
self.proc_signatures: dict = {} self.proc_signatures: dict = {}
self.proc_docs: dict = {}
# Cache: (uri, version) -> (tree, violations)
self._ast_cache = {}
def get_tree(self, document: TextDocument):
key = (document.uri, document.version)
cached = self._ast_cache.get(key)
if cached:
return cached[0]
# Parse and cache
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations)
return tree
def get_tree_and_violations(self, document: TextDocument):
key = (document.uri, document.version)
cached = self._ast_cache.get(key)
if cached:
return cached
# Parse and cache
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations)
return tree, violations
def clear_cache_for_uri(self, uri: str):
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri]
for k in to_delete:
del self._ast_cache[k]
def update_poco_completion_for_file(self, document: TextDocument): def update_poco_completion_for_file(self, document: TextDocument):
"""Update poco_completion for a specific file when it changes""" """Update poco_completion for a specific file when it changes"""
@@ -35,16 +68,19 @@ class TclLanguageServer(server.LanguageServer):
del self.poco_completion[filepath] del self.poco_completion[filepath]
if filepath in self.proc_signatures: if filepath in self.proc_signatures:
del self.proc_signatures[filepath] del self.proc_signatures[filepath]
if filepath in self.proc_docs:
del self.proc_docs[filepath]
# Parse and extract new completion items # Parse and extract new completion items
completion.reset() completion.reset()
try: try:
tree = self.parser.parse(document.source) tree = self.get_tree(document)
tree.accept(completion, recurse=True) tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, self.poco_completion) remove_existing_items(completion.custom_functions, self.poco_completion)
self.poco_completion[filepath] = completion.custom_functions self.poco_completion[filepath] = completion.custom_functions
remove_shared_keys(self.proc_signatures, completion.proc_signatures) remove_shared_keys(self.proc_signatures, completion.proc_signatures)
self.proc_signatures[filepath] = completion.proc_signatures self.proc_signatures[filepath] = completion.proc_signatures
self.proc_docs[filepath] = build_proc_docs(tree, document.source)
except Exception as e: except Exception as e:
logging.debug(f"Error parsing {filepath}: {e}") logging.debug(f"Error parsing {filepath}: {e}")
@@ -77,10 +113,7 @@ class TclLanguageServer(server.LanguageServer):
self, self,
document: TextDocument, document: TextDocument,
) -> List[Violation]: ) -> List[Violation]:
violations = [] tree, violations = self.get_tree_and_violations(document)
self.parser.violations = []
tree = self.parser.parse(document.source)
violations += self.parser.violations
for checker in checks.get_checkers(): for checker in checks.get_checkers():
violations += checker.check(document.source, tree) violations += checker.check(document.source, tree)
return violations return violations
+29 -7
View File
@@ -2,6 +2,8 @@ from tclint.syntax_tree import Visitor, Command, BareWord, List
import lsprotocol.types as lsp import lsprotocol.types as lsp
from common.load_data import standard_items from common.load_data import standard_items
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
class CompletionItems: class CompletionItems:
def __init__(self): def __init__(self):
@@ -34,23 +36,25 @@ class _Completion(Visitor):
self._custom_functions = [] self._custom_functions = []
self._proc_signatures = {} self._proc_signatures = {}
def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan
if not any(ci.label == item.label for ci in self._custom_functions):
self._custom_functions.append(item)
def visit_command(self, command: Command): def visit_command(self, command: Command):
routine = command.routine routine = command.routine
# Collect custom proc names and their signatures
if routine.contents == "proc" and command.args: if routine.contents == "proc" and command.args:
first_arg = command.args[0] first_arg = command.args[0]
if not first_arg.value: if not getattr(first_arg, "value", None):
return return
if any(item.label == first_arg.value for item in standard_items.nx_procs): if any(item.label == first_arg.value for item in standard_items.nx_procs):
return return
try: # Record proc name as a completion item
self._custom_functions.remove(first_arg.value) self._append_unique(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function))
except ValueError:
pass
self._custom_functions.append(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function))
if len(command.args) < 2: if len(command.args) < 2:
return return
@@ -69,6 +73,24 @@ class _Completion(Visitor):
self._proc_signatures[first_arg.value] = param_names self._proc_signatures[first_arg.value] = param_names
# Collect global variables declared with: global var1 var2 ...
elif routine.contents == "global" and command.args:
for arg in command.args:
if isinstance(arg, BareWord) and getattr(arg, "value", None):
if arg.value not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable))
# Collect variables set with explicit global namespace: set ::var_name ...
elif routine.contents == "set" and command.args:
first = command.args[0]
if isinstance(first, BareWord) and getattr(first, "value", None):
var_name = first.value
if var_name.startswith("::"):
base_name = var_name.split("(", 1)[0]
clean_name = base_name[2:] # remove leading '::' for completion display
if clean_name not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable))
def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None: def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None:
""" """
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
import re
import lsprotocol.types as lsp
# Precompiled regex patterns for performance
NS_RE = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)")
PROC_RE = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{")
SET_RE = re.compile(r"^\s*set\s+([^\s\}]+)")
EVENT_START_RE = re.compile(r"^\s*LIB_GE_command_buffer_edit_(prepend|append|insert|replace)\b")
EVENT_CLOSE_INLINE_RE = re.compile(r"^\s*\}\s*(\S+)\s*.*$")
EVENT_NAME_LINE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\b")
def build_document_symbols(source: str) -> list[lsp.DocumentSymbol]:
"""Parse a Tcl document source and return DocumentSymbols.
This mirrors the previous inline implementation in lsp_server, but is extracted
for readability and reuse.
"""
lines = source.split("\n")
class Scope:
def __init__(self, name: str, symbol: lsp.DocumentSymbol, start_line: int):
self.name = name
self.symbol = symbol
self.start_line = start_line
self.brace_count = 0
root_symbol = lsp.DocumentSymbol(
name="root",
detail="",
kind=lsp.SymbolKind.Namespace,
range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(len(lines), 0)),
selection_range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(0, 0)),
children=[],
)
scope_stack: list[Scope] = [Scope("", root_symbol, 0)]
pending_event: dict | None = None
for i, line in enumerate(lines):
# If inside an event, update its brace count for this line
if pending_event is not None:
pe_open = line.count("{")
pe_close = line.count("}")
pending_event["brace_count"] = pending_event.get("brace_count", 1) + pe_open - pe_close
# If this line closed the outer event block, finalize the event
if pending_event["brace_count"] <= 0:
# Try inline name on the same line
close_inline = EVENT_CLOSE_INLINE_RE.match(line)
if close_inline:
event_name = close_inline.group(1)
name_line_index = i
else:
# Look ahead to next non-empty line for the name
j = i + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
event_name = None
name_line_index = i
if j < len(lines):
name_line = lines[j]
name_match = EVENT_NAME_LINE_RE.match(name_line)
if name_match:
event_name = name_match.group(1)
name_line_index = j
if event_name:
start_line = pending_event["start"]
edit_type = pending_event["edit_type"]
children = pending_event.get("children", [])
detail = f"Event ({edit_type})"
event_symbol = lsp.DocumentSymbol(
name=event_name,
detail=detail,
kind=lsp.SymbolKind.Event,
range=lsp.Range(start=lsp.Position(start_line, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))),
selection_range=lsp.Range(start=lsp.Position(name_line_index, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))),
children=children or [],
)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(event_symbol)
# Clear event tracking and continue
pending_event = None
continue
ns_match = NS_RE.match(line)
proc_match = PROC_RE.match(line)
set_match = SET_RE.match(line)
# Namespace
if ns_match:
ns_name = ns_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(ns_name)
sel_end_char = sel_start_char + len(ns_name) if sel_start_char >= 0 else len(line)
ns_symbol = lsp.DocumentSymbol(
name=ns_name,
detail="Namespace",
kind=lsp.SymbolKind.Namespace,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=[],
)
scope = Scope(ns_name, ns_symbol, i)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(ns_symbol)
scope_stack.append(scope)
# Proc
elif proc_match:
proc_name = proc_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(proc_name)
sel_end_char = sel_start_char + len(proc_name) if sel_start_char >= 0 else len(line)
proc_symbol = lsp.DocumentSymbol(
name=proc_name,
detail="Procedure",
kind=lsp.SymbolKind.Function,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=[],
)
scope = Scope(proc_name, proc_symbol, i)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(proc_symbol)
scope_stack.append(scope)
# set variable
elif set_match:
var_name = set_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(var_name)
sel_end_char = sel_start_char + len(var_name) if sel_start_char >= 0 else len(line)
var_symbol = lsp.DocumentSymbol(
name=var_name,
detail="Variable",
kind=lsp.SymbolKind.Variable,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=None,
)
# Attach to current scope or pending event as child
if pending_event is not None:
pending_event["children"].append(var_symbol)
else:
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(var_symbol)
# Event start (buffer edit)
m = EVENT_START_RE.match(line)
if m:
edit_type = m.group(1)
pending_event = {"start": i, "edit_type": edit_type, "children": []}
continue
# Brace balancing for scopes (namespace/proc)
open_count = line.count("{")
close_count = line.count("}")
scope_stack[-1].brace_count += open_count - close_count
# Close finished scopes
while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0:
finished = scope_stack.pop()
# Set the full range from startLine to current line
finished.symbol.range = lsp.Range(
start=lsp.Position(finished.start_line, 0),
end=lsp.Position(i, len(line)),
)
# Return top-level children
return root_symbol.children
+177
View File
@@ -0,0 +1,177 @@
import re
from typing import Dict, List
from tclint.syntax_tree import Visitor, Command
from tools.parser import CustomParser
def _strip_comment_prefix(line: str) -> str:
"""Strip leading '# ' or '#' from a line."""
if line.lstrip().startswith("#"):
# remove up to one leading '#' and one optional following space
return re.sub(r"^\s*#\s?", "", line)
return line
def extract_doc_block_above(lines: List[str], start_line_index: int) -> str | None:
"""
Extract a contiguous block of line comments immediately above the given line index.
- lines: document split into lines
- start_line_index: 0-based index of the line where the proc command starts
Returns the cleaned documentation text or None if no comment block found.
"""
i = start_line_index - 1
if i < 0:
return None
doc_lines: List[str] = []
# Skip trailing empty lines directly above
while i >= 0 and lines[i].strip() == "":
i -= 1
# Collect contiguous comment lines going upwards
while i >= 0 and lines[i].lstrip().startswith("#"):
doc_lines.append(lines[i])
i -= 1
if not doc_lines:
return None
# Reverse to original order and strip comment prefixes
doc_lines.reverse()
cleaned = [_strip_comment_prefix(line_text) for line_text in doc_lines]
# Simple tag -> markdown conversions for nicer rendering
md_lines: List[str] = []
tag_map = {
"<Documentation>": "### Documentation",
"<Arguments>": "### Arguments",
"<Returnvalue>": "### Return value",
"<Example>": "### Example",
"<Internal Documentation>": "### Internal Documentation",
"<Internal Example>": "### Internal Example",
}
in_example = False
code_block_open = False
def close_code_block_if_open():
nonlocal code_block_open
if code_block_open:
md_lines.append("```")
code_block_open = False
for line in cleaned:
stripped = line.strip()
# Convert tags to headings and manage example sections
if stripped in tag_map:
# If we hit any new tag, close a pending code block
close_code_block_if_open()
heading = tag_map[stripped]
md_lines.append(heading)
in_example = heading in ("### Example", "### Internal Example")
continue
if in_example:
low = stripped.lower()
if low.startswith("code:"):
# Open code block if needed and append the code content
code_text = line.split(":", 1)[1].strip()
if not code_block_open:
md_lines.append("```tcl")
code_block_open = True
md_lines.append(code_text)
continue
# Keep name/desc lines as regular text outside code
if low.startswith("name:") or low.startswith("desc:"):
md_lines.append(line)
continue
md_lines.append(line)
# Close any dangling code fence at the end of the block
close_code_block_if_open()
return "\n".join(md_lines).rstrip()
class ProcDocExtractor(Visitor):
"""Visitor that collects documentation blocks above proc declarations."""
def __init__(self, source_text: str):
super().__init__()
self._lines = source_text.split("\n")
self.docs: Dict[str, str] = {}
def visit_command(self, command: Command):
routine = getattr(command.routine, "contents", None)
if routine != "proc":
return
if not command.args:
return
name_node = command.args[0]
proc_name = getattr(name_node, "contents", None)
if not proc_name:
return
# Prefer line of the 'proc' keyword; fallback to the name node
pos = getattr(command.routine, "pos", None) or getattr(name_node, "pos", None)
if not pos:
return
line_idx = pos[0] - 1 # 0-based
block = extract_doc_block_above(self._lines, line_idx)
if block:
self.docs[proc_name] = block
def build_proc_docs(tree, source_text: str) -> Dict[str, str]:
"""Build a mapping of proc name -> markdown doc from a parsed tree and source text."""
extractor = ProcDocExtractor(source_text)
tree.accept(extractor, recurse=True)
return extractor.docs
def is_proc_declaration_position(source_text: str, line_zero_based: int, char_zero_based: int) -> bool:
"""Return True if the position is on a proc name within its declaration."""
parser = CustomParser()
tree = parser.parse(source_text)
# Walk commands to find 'proc' declarations and check if position intersects the name arg
class _DeclFinder(Visitor):
def __init__(self):
self.is_decl = False
def visit_command(self, command: Command):
if self.is_decl:
return
routine = getattr(command.routine, "contents", None)
if routine != "proc" or not command.args:
return
name_node = command.args[0]
if not hasattr(name_node, "pos"):
return
# Calculate range for the name token
try:
start_line, start_col = name_node.pos
end_line, end_col = getattr(name_node, "end_pos", name_node.pos)
except Exception:
return
if start_line - 1 == line_zero_based:
length = 0
if hasattr(name_node, "value") and name_node.value is not None:
length = len(name_node.value)
elif hasattr(name_node, "contents") and name_node.contents is not None:
length = len(name_node.contents)
if length:
start_c = start_col - 1
end_c = start_c + length
if start_c <= char_zero_based <= end_c:
self.is_decl = True
finder = _DeclFinder()
tree.accept(finder, recurse=True)
return finder.is_decl
+18 -10
View File
@@ -4,7 +4,6 @@ from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord
from tclint.commands import get_commands from tclint.commands import get_commands
import attrs import attrs
from common.load_data import standard_items from common.load_data import standard_items
from tools.completion_items import completion
import lsprotocol.types as lsp import lsprotocol.types as lsp
@@ -26,6 +25,11 @@ class Token:
tok_type: str = "" tok_type: str = ""
tok_modifiers: List[TokenModifier] = attrs.field(factory=list) tok_modifiers: List[TokenModifier] = attrs.field(factory=list)
@property
def length(self) -> int:
"""Compatibility alias for misspelled 'lenght' field."""
return self.lenght
TOKEN_TYPES = [ TOKEN_TYPES = [
"keyword", "keyword",
@@ -81,19 +85,23 @@ class _Highlighter(Visitor):
pass pass
def visit_bare_word(self, word: BareWord): def visit_bare_word(self, word: BareWord):
name = word.value # Intentionally do not classify bare words as functions here.
# Function highlighting is handled in visit_command for the routine only,
in_standard = any(item.label == name for item in standard_items.nx_procs) # using completion items (custom functions) as the source of truth.
return
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items)
if in_standard or in_custom:
line, col = word.pos
self._tokens.append((((line - 1, col - 1), len(name), "function", [])))
def visit_command(self, command: Command): def visit_command(self, command: Command):
routine = command.routine routine = command.routine
# Highlight functions (custom or standard) when used as the routine
name = getattr(routine, "contents", None)
if name:
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items)
in_standard = any(item.label == name for item in standard_items.nx_procs)
if in_custom or in_standard:
line, col = routine.contents_pos
self._tokens.append((((line - 1, col - 1), len(name), "function", [])))
if routine.contents == "puts": if routine.contents == "puts":
line, col = routine.contents_pos line, col = routine.contents_pos
self._tokens.append( self._tokens.append(
+100
View File
@@ -0,0 +1,100 @@
import re
from dataclasses import dataclass
from typing import Dict, Set, List, Tuple
# Reuse patterns similar to document_symbols
NS_RE = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)")
PROC_RE = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{")
SET_RE = re.compile(r"^\s*set\s+([^\s\}]+)")
@dataclass
class ProcRange:
name: str
start_line: int
end_line: int | None = None
def build_variable_index(source: str) -> tuple[Set[str], Dict[str, Set[str]], List[ProcRange]]:
"""
Parse Tcl source text and build:
- globals: set of variable names considered global suggestions
- procs: mapping proc_name -> set of local variable names (set without :: inside that proc)
- proc_ranges: list of ProcRange (name, start_line, end_line)
Rules:
- set ::var -> global var suggestion (strip leading :: and any array index "(")
- set var without :: at top level (not in namespace/proc) -> global suggestion
- set var without :: inside proc -> local to that proc
- set var inside namespace (no ::) is ignored for global suggestions
"""
lines = source.split("\n")
class Scope:
def __init__(self, name: str, kind: str, start_line: int):
self.name = name
self.kind = kind # "namespace" or "proc" or "root"
self.start_line = start_line
self.brace_count = 0
globals_set: Set[str] = set()
procs: Dict[str, Set[str]] = {}
proc_ranges: List[ProcRange] = []
scope_stack: List[Scope] = [Scope("", "root", 0)]
for i, line in enumerate(lines):
ns_match = NS_RE.match(line)
proc_match = PROC_RE.match(line)
set_match = SET_RE.match(line)
# Namespace scope
if ns_match:
scope_stack.append(Scope(ns_match.group(1), "namespace", i))
# Proc scope
elif proc_match:
pname = proc_match.group(1)
scope_stack.append(Scope(pname, "proc", i))
proc_ranges.append(ProcRange(name=pname, start_line=i, end_line=None))
# Track set statements
if set_match:
raw_name = set_match.group(1)
# Normalize array names and leading ::
base = raw_name.split("(", 1)[0]
if base.startswith("::"):
clean = base[2:]
globals_set.add(clean)
else:
top = scope_stack[-1]
if top.kind == "root":
globals_set.add(base)
elif top.kind == "proc":
procs.setdefault(top.name, set()).add(base)
else:
# inside namespace without :: -> ignore for globals
pass
# Brace balancing for current top scope
open_count = line.count("{")
close_count = line.count("}")
scope_stack[-1].brace_count += open_count - close_count
# Close finished scopes
while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0:
finished = scope_stack.pop()
if finished.kind == "proc":
# Update the last matching proc range end_line
for pr in reversed(proc_ranges):
if pr.name == finished.name and pr.end_line is None:
pr.end_line = i
break
# Finalize any unterminated proc ranges
for pr in proc_ranges:
if pr.end_line is None:
pr.end_line = len(lines) - 1
return globals_set, procs, proc_ranges
@@ -0,0 +1,115 @@
import sys
from pathlib import Path
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from lsp_server import LSP_SERVER, document_symbols # type: ignore
def test_document_symbols_namespace_proc_set_hierarchy(tmp_path: Path):
source_lines = [
"set top_var 1",
"namespace eval myns {",
" set ns_var 2",
" proc add {a b} {",
" set sum [expr {$a + $b}]",
" return $sum",
" }",
"}",
"proc top_proc {} {",
" set x 3",
"}",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "sym.tcl").as_uri()
# Put a text document into the workspace
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
# Request document symbols
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
# Expect at least 2 top-level children: root contains 'set top_var' (variable) and 'namespace myns' and 'proc top_proc'
names_kinds = {(s.name, s.kind) for s in symbols}
assert ("root", lsp.SymbolKind.Namespace) not in names_kinds # root should not be included itself
# Find namespace symbol
ns = next(s for s in symbols if s.name == "myns")
assert ns.kind == lsp.SymbolKind.Namespace
assert ns.children is not None
# Inside namespace: has variable and proc
child_names = {c.name for c in ns.children}
assert "ns_var" in child_names
assert "add" in child_names
# top-level variable and proc also present
top_names = {s.name for s in symbols}
assert "top_var" in top_names
assert "top_proc" in top_names
# Check that proc add has no children (we're not extracting params as children here)
add = next(c for c in ns.children if c.name == "add")
assert add.kind == lsp.SymbolKind.Function
assert add.children == []
def test_buffer_edit_events_are_symbols_with_type_and_name(tmp_path: Path):
source_lines = [
"LIB_GE_command_buffer_edit_insert LIB_ROTARY_positioning_first_move_pos ROTARY_POSITIONING_FIRST_MOVE_POS {",
" MOM_enable_address Z M_coolant_off D M_coolant_1 M_coolant_2 H_pressure",
"}",
" Coolant after @DECOMPOSEZUL",
"",
"LIB_GE_command_buffer_edit_append MOM_start_of_path_LIB MOM_start_of_path_LIB_ENTRY_end {",
" MOM_force once M_coolant_1 M_coolant_2 H_pressure",
"}",
" force_coolant",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "events.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
# Find event symbols
events = [s for s in symbols if s.kind == lsp.SymbolKind.Event]
assert events, "Expected at least one event symbol"
# Verify names and details
names = [e.name for e in events]
assert "Coolant" in names or "force_coolant" in names
for e in events:
assert e.detail.startswith("Event (")
def test_event_children_include_set_variable(tmp_path: Path):
source_lines = [
"LIB_GE_command_buffer_edit_append MOM_rapid_move_LIB MOM_rapid_move_LIB_ENTRY_start {",
" if {[info exists ::mom_lift_off_output] && $::kapp_vars(retract_start) == 0} {",
" kapp_retract_subpgm",
" }",
" set ::kapp_vars(retract_start) 0",
"}",
" KappRetractSubPgm",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "event_children.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
events = [s for s in symbols if s.kind == lsp.SymbolKind.Event and s.name == "KappRetractSubPgm"]
assert events, "Expected event symbol for KappRetractSubPgm"
ev = events[0]
assert ev.children is not None
# Ensure the set variable is a child of the event
child_names = {c.name for c in ev.children}
assert "::kapp_vars(retract_start)" in child_names
@@ -0,0 +1,116 @@
import sys
from pathlib import Path
# Ensure server/src is on the path for imports
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from lsp_server import LSP_SERVER, goto_definition # type: ignore
def _loc_to_tuple(loc: lsp.Location) -> tuple[str, int, int, int, int]:
"""Helper to normalize Location into a tuple for easy asserts."""
return (
loc.uri,
loc.range.start.line,
loc.range.start.character,
loc.range.end.line,
loc.range.end.character,
)
def _extract_first_location(result) -> lsp.Location | None:
if result is None:
return None
if isinstance(result, list):
return result[0] if result else None
return result
def test_goto_definition_same_file(tmp_path: Path):
source_lines = [
"proc add {a b} {",
" return [expr {$a + $b}]",
"}",
"",
"set x [add 1 2]",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "same.tcl").as_uri()
# Put a text document into the workspace
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)
)
# Position on the word 'add' in the last line
line_idx = 4
char_idx = source_lines[line_idx].index("add") + 1 # somewhere inside token
params = lsp.DefinitionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=line_idx, character=char_idx),
)
result = goto_definition(params)
loc = _extract_first_location(result)
assert loc is not None
assert loc.uri == uri
# Definition should be on line 0 at the token 'add'
start = loc.range.start
end = loc.range.end
assert start.line == 0
assert end.line == 0
assert source_lines[0][start.character : end.character] == "add"
def test_goto_definition_cross_file(tmp_path: Path):
# File A declares the proc
a_lines = [
"proc myproc {arg} {",
" return $arg",
"}",
]
a_src = "\n".join(a_lines)
a_path = tmp_path / "a.tcl"
a_uri = a_path.as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=a_uri, language_id="tcl", version=1, text=a_src)
)
# Update indices for file A so proc_signatures gets populated
doc_a = LSP_SERVER.workspace.get_text_document(a_uri)
LSP_SERVER.update_poco_completion_for_file(doc_a)
# File B calls the proc
b_lines = [
"set y [myproc 42]",
]
b_src = "\n".join(b_lines)
b_uri = (tmp_path / "b.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=b_uri, language_id="tcl", version=1, text=b_src)
)
call_line = 0
call_char = b_lines[0].index("myproc") + 2
params = lsp.DefinitionParams(
text_document=lsp.TextDocumentIdentifier(uri=b_uri),
position=lsp.Position(line=call_line, character=call_char),
)
result = goto_definition(params)
loc = _extract_first_location(result)
assert loc is not None
assert loc.uri == a_uri
start = loc.range.start
end = loc.range.end
assert start.line == 0
assert a_lines[start.line][start.character : end.character] == "myproc"
@@ -0,0 +1,103 @@
import os
import sys
from pathlib import Path
# Ensure server/src is on the path for imports
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from tools.proc_docs import build_proc_docs, is_proc_declaration_position
from lsp_server import LSP_SERVER, hover # type: ignore
def test_build_proc_docs_extracts_block_and_tags():
source = (
"#____________________________________________________________________________________________\n"
"# <Documentation>\n"
"# This procedure creates a new directory if it does not exist.\n"
"# <Arguments>\n"
"# directory\n"
"#\tThe full pathname of the directory to be created.\n"
"# <Returnvalue>\n"
"# 0 - directory created or already exists\n"
"# 1 - error\n"
"#______________________________________________________________________________________________\n"
"proc LIB_FH_create_directory {directory} {\n"
" return 0\n"
"}\n"
)
from tools.parser import CustomParser
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "LIB_FH_create_directory" in docs
md = docs["LIB_FH_create_directory"]
# Tags become markdown headings
assert "### Documentation" in md
assert "### Arguments" in md
assert "### Return value" in md
# Content preserved
assert "creates a new directory" in md
def test_hover_shows_doc_on_usage_but_not_on_declaration(tmp_path: Path):
# Build a TCL file with a documented proc and a usage
source_lines = [
"#_________________________________________________________________________________________________",
"# <Documentation>",
"# Function to delete the file",
"#_________________________________________________________________________________________________",
"proc SERVICE_remove_file {file} {",
" if {![SERVICE_check_file_exists $file]} {return}",
" MOM_remove_file $file",
"}",
"",
"proc SERVICE_check_file_exists {file} {",
" if {[file exists $file]} {return 1}",
" return 0",
"}",
"",
"# usage below",
"SERVICE_remove_file \"C:/tmp/x\"",
]
source = "\n".join(source_lines)
# Register document with server
uri = Path(tmp_path / "test.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)
)
# Force server to parse and build proc docs
doc = LSP_SERVER.workspace.get_text_document(uri)
LSP_SERVER.update_poco_completion_for_file(doc)
# 1) Hover on usage -> should return docs
usage_line = source_lines.index("SERVICE_remove_file \"C:/tmp/x\"")
char_index = source_lines[usage_line].find("SERVICE_remove_file") + 5 # inside the token
params = lsp.HoverParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=usage_line, character=char_index),
)
result = hover(params)
assert result is not None
assert "Function to delete the file" in result.contents.value # type: ignore[attr-defined]
# 2) Hover on declaration name -> should be None
decl_line = source_lines.index("proc SERVICE_remove_file {file} {")
decl_char = source_lines[decl_line].find("SERVICE_remove_file") + 2
assert is_proc_declaration_position(source, decl_line, decl_char)
params_decl = lsp.HoverParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=decl_line, character=decl_char),
)
none_result = hover(params_decl)
assert none_result is None
@@ -0,0 +1,75 @@
import sys
from pathlib import Path
# Ensure server/src is on sys.path for imports
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from tools.parser import CustomParser
from tools.proc_docs import build_proc_docs
def test_example_is_marked_as_tcl_code_block():
source_lines = [
"#____________________________________________________________________________________________",
"# <Documentation>",
"# This procedure creates a new directory if it does not exist.",
"# <Arguments>",
"# directory",
"#\tThe full pathname of the directory to be created.",
"# <Returnvalue>",
"# 0 - directory created or already exists",
"# 1 - error",
"# <Example>",
"# name: Example 1",
"# code: LIB_FH_create_directory \"C:/Temp/Test\"",
"# desc: If error = 0, the directory is created.",
"proc LIB_FH_create_directory {directory} {",
" return 0",
"}",
]
source = "\n".join(source_lines)
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "LIB_FH_create_directory" in docs
md = docs["LIB_FH_create_directory"]
# Headings preserved
assert "### Documentation" in md
assert "### Arguments" in md
assert "### Return value" in md
assert "### Example" in md
# Code fence with tcl language hint and the code line present
assert "```tcl" in md
assert "LIB_FH_create_directory \"C:/Temp/Test\"" in md
assert md.strip().endswith("```")
def test_internal_example_is_marked_as_tcl_code_block():
source_lines = [
"# <Internal Documentation>",
"# Helper utility",
"# <Internal Example>",
"# code: puts \"hello\"",
"proc helper {} {",
" return",
"}",
]
source = "\n".join(source_lines)
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "helper" in docs
md = docs["helper"]
assert "### Internal Documentation" in md
assert "### Internal Example" in md
assert "```tcl" in md and "puts \"hello\"" in md and md.strip().endswith("```")