refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s
build_and_puplish.yml / build_and_publish (release) Successful in 29s
This change adds cached and incremental analysis for the LSP server to improve responsiveness. The client now debounces diagnostic updates to avoid excessive recomputation. The server introduces per-document line caches and various caches for completions, inlay hints, and metadata to support faster, incremental updates. - Debounce diagnostics on text changes to reduce noise. - Add caches for completions, inlay hints, and metadata. - Introduce incremental analysis with per-document line caches.
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
- Add signature help for custom TCL procedures and built-in NX/MOM procedures
|
- Add signature help for custom TCL procedures and built-in NX/MOM procedures
|
||||||
- Clean stale TCL indexes on close, delete, and rename operations
|
- Clean stale TCL indexes on close, delete, and rename operations
|
||||||
- Make background parsing and index updates thread-safe
|
- Make background parsing and index updates thread-safe
|
||||||
|
- Improve TCL response times with debounced edits and cached semantic, inlay, hover, completion, and variable indexes
|
||||||
|
- Debounce CDL/DEF diagnostics and remove per-line diagnostic logging
|
||||||
|
|
||||||
## [0.0.1]
|
## [0.0.1]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
createCdlEventHandlerSnippet
|
createCdlEventHandlerSnippet
|
||||||
} from "./cdlEventHandler"
|
} from "./cdlEventHandler"
|
||||||
|
|
||||||
|
const MACHINE_HEADER_REGEX = /^MACHINE\s+\S+/
|
||||||
|
|
||||||
export function formatCdlFile(content: string): string {
|
export function formatCdlFile(content: string): string {
|
||||||
let indentLevel = 0
|
let indentLevel = 0
|
||||||
const formattedLines = []
|
const formattedLines = []
|
||||||
@@ -41,15 +43,19 @@ export function formatDefFile(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFirstLineMachine(content: string): boolean {
|
export function isFirstLineMachine(content: string): boolean {
|
||||||
const lines = content.split("\n").map((line) => line.trim())
|
let lineStart = 0
|
||||||
for (const line of lines) {
|
while (lineStart <= content.length) {
|
||||||
console.log(line)
|
const newline = content.indexOf("\n", lineStart)
|
||||||
|
const lineEnd = newline === -1 ? content.length : newline
|
||||||
|
const line = content.slice(lineStart, lineEnd).trim()
|
||||||
if (line === "" || line.startsWith("#")) {
|
if (line === "" || line.startsWith("#")) {
|
||||||
console.log("skipping line")
|
if (newline === -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lineStart = newline + 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const machineRegex = /^MACHINE\s+\S+/
|
return MACHINE_HEADER_REGEX.test(line)
|
||||||
return machineRegex.test(line)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -57,10 +63,11 @@ export function isFirstLineMachine(content: string): boolean {
|
|||||||
export function diagnosticHandler(document: vscode.TextDocument) {
|
export function diagnosticHandler(document: vscode.TextDocument) {
|
||||||
const diagnostics: vscode.Diagnostic[] = []
|
const diagnostics: vscode.Diagnostic[] = []
|
||||||
if (document.languageId === "cdl" || document.languageId === "def") {
|
if (document.languageId === "cdl" || document.languageId === "def") {
|
||||||
if (!isFirstLineMachine(document.getText())) {
|
const text = document.getText()
|
||||||
|
if (!isFirstLineMachine(text)) {
|
||||||
const range = new vscode.Range(
|
const range = new vscode.Range(
|
||||||
document.positionAt(0),
|
document.positionAt(0),
|
||||||
document.positionAt(document.getText().length)
|
document.positionAt(text.length)
|
||||||
)
|
)
|
||||||
const diagnostic = new vscode.Diagnostic(
|
const diagnostic = new vscode.Diagnostic(
|
||||||
range,
|
range,
|
||||||
|
|||||||
+50
-11
@@ -246,28 +246,67 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
const diagnosticCollectionDef = vscode.languages.createDiagnosticCollection("def")
|
const diagnosticCollectionDef = vscode.languages.createDiagnosticCollection("def")
|
||||||
context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef)
|
context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef)
|
||||||
|
|
||||||
|
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
const updateDiagnostics = (document: vscode.TextDocument) => {
|
||||||
|
if (document.languageId === "cdl") {
|
||||||
|
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
|
||||||
|
} else if (document.languageId === "def") {
|
||||||
|
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scheduleDiagnostics = (document: vscode.TextDocument) => {
|
||||||
|
const key = document.uri.toString()
|
||||||
|
const previous = diagnosticTimers.get(key)
|
||||||
|
if (previous !== undefined) {
|
||||||
|
clearTimeout(previous)
|
||||||
|
}
|
||||||
|
diagnosticTimers.set(
|
||||||
|
key,
|
||||||
|
setTimeout(() => {
|
||||||
|
diagnosticTimers.delete(key)
|
||||||
|
updateDiagnostics(document)
|
||||||
|
}, 120)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the first line of the CDL file contains "MACHINE"
|
// Check if the first line of the CDL file contains "MACHINE"
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.workspace.onDidOpenTextDocument((document) => {
|
vscode.workspace.onDidOpenTextDocument((document) => {
|
||||||
if (document.languageId === "cdl" || document.languageId === "def") {
|
if (document.languageId === "cdl" || document.languageId === "def") {
|
||||||
if (document.languageId === "cdl") {
|
updateDiagnostics(document)
|
||||||
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
|
|
||||||
} else if (document.languageId === "def") {
|
|
||||||
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
const document = event.document
|
const document = event.document
|
||||||
if (document.languageId === "cdl" || document.languageId === "def") {
|
if (document.languageId === "cdl" || document.languageId === "def") {
|
||||||
if (document.languageId === "cdl") {
|
scheduleDiagnostics(document)
|
||||||
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
|
|
||||||
} else if (document.languageId === "def") {
|
|
||||||
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
|
const key = document.uri.toString()
|
||||||
|
const timer = diagnosticTimers.get(key)
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
diagnosticTimers.delete(key)
|
||||||
|
}
|
||||||
|
diagnosticCollectionCdl.delete(document.uri)
|
||||||
|
diagnosticCollectionDef.delete(document.uri)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
dispose() {
|
||||||
|
for (const timer of diagnosticTimers.values()) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
diagnosticTimers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for (const document of vscode.workspace.textDocuments) {
|
||||||
|
if (document.languageId === "cdl" || document.languageId === "def") {
|
||||||
|
updateDiagnostics(document)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): Thenable<void> | undefined {
|
export function deactivate(): Thenable<void> | undefined {
|
||||||
|
|||||||
+65
-82
@@ -5,15 +5,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import operator
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from collections import ChainMap
|
||||||
from typing import Any, Optional
|
|
||||||
import operator
|
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
# **********************************************************
|
# **********************************************************
|
||||||
@@ -40,14 +40,14 @@ update_sys_path(
|
|||||||
# pylint: disable=wrong-import-position,import-error
|
# pylint: disable=wrong-import-position,import-error
|
||||||
import lsp_jsonrpc as jsonrpc
|
import lsp_jsonrpc as jsonrpc
|
||||||
import lsprotocol.types as lsp
|
import lsprotocol.types as lsp
|
||||||
from pygls import uris, workspace
|
|
||||||
from common.load_data import standard_items
|
from common.load_data import standard_items
|
||||||
|
from lsp_tclserver import TclLanguageServer
|
||||||
|
from pygls import uris, workspace
|
||||||
|
from pygls.workspace.text_document import TextDocument
|
||||||
from tools.folding_ranges import build_folding_ranges
|
from tools.folding_ranges import build_folding_ranges
|
||||||
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
|
|
||||||
from tools.inlay_hint import (
|
from tools.inlay_hint import (
|
||||||
InlayHintGenerator,
|
InlayHintGenerator,
|
||||||
build_builtin_inlay_signatures,
|
build_builtin_inlay_signatures,
|
||||||
build_custom_inlay_signatures,
|
|
||||||
)
|
)
|
||||||
from tools.navigation import (
|
from tools.navigation import (
|
||||||
SymbolIdentity,
|
SymbolIdentity,
|
||||||
@@ -56,10 +56,13 @@ from tools.navigation import (
|
|||||||
symbol_at_position,
|
symbol_at_position,
|
||||||
workspace_symbols,
|
workspace_symbols,
|
||||||
)
|
)
|
||||||
|
from tools.semantic_tokens import (
|
||||||
|
TOKEN_TYPE_INDEX,
|
||||||
|
TOKEN_TYPES,
|
||||||
|
TokenModifier,
|
||||||
|
_Highlighter,
|
||||||
|
)
|
||||||
from tools.signature_help import build_signature_help
|
from tools.signature_help import build_signature_help
|
||||||
from lsp_tclserver import TclLanguageServer
|
|
||||||
from pygls.workspace.text_document import TextDocument
|
|
||||||
|
|
||||||
|
|
||||||
WORKSPACE_SETTINGS = {}
|
WORKSPACE_SETTINGS = {}
|
||||||
GLOBAL_SETTINGS = {}
|
GLOBAL_SETTINGS = {}
|
||||||
@@ -75,6 +78,23 @@ BUILTIN_PROC_NAMES = {
|
|||||||
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
|
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
|
||||||
}
|
}
|
||||||
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
|
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
|
||||||
|
STATIC_COMPLETION_ITEMS = tuple(
|
||||||
|
standard_items.tcl_keyword_list
|
||||||
|
+ standard_items.nx_procs
|
||||||
|
+ standard_items.nx_variables
|
||||||
|
)
|
||||||
|
STATIC_COMPLETION_KEYS = frozenset(
|
||||||
|
(item.label, getattr(item, "kind", None)) for item in STATIC_COMPLETION_ITEMS
|
||||||
|
)
|
||||||
|
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(
|
||||||
|
standard_items.json_data.get("MOM_procs", [])
|
||||||
|
)
|
||||||
|
BUILTIN_HOVER_ITEMS = {}
|
||||||
|
for _hover_item in (
|
||||||
|
standard_items.json_data.get("MOM_procs", [])
|
||||||
|
+ standard_items.json_data.get("mom_variables", [])
|
||||||
|
):
|
||||||
|
BUILTIN_HOVER_ITEMS.setdefault(_hover_item.get("label"), _hover_item)
|
||||||
|
|
||||||
# **********************************************************
|
# **********************************************************
|
||||||
# Tool specific code goes below this.
|
# Tool specific code goes below this.
|
||||||
@@ -95,15 +115,14 @@ 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.clear_cache_for_uri(document.uri)
|
LSP_SERVER.clear_cache_for_uri(document.uri)
|
||||||
LSP_SERVER.compute_diagnostics(document)
|
LSP_SERVER.analyze_document_now(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)
|
||||||
def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
|
def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
|
||||||
"""LSP handler for textDocument/didSave request."""
|
"""LSP handler for textDocument/didSave request."""
|
||||||
_ = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||||
|
LSP_SERVER.analyze_document_now(document)
|
||||||
|
|
||||||
|
|
||||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
|
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
|
||||||
@@ -119,8 +138,7 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
|
|||||||
"""LSP handler for textDocument/didChange request"""
|
"""LSP handler for textDocument/didChange 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.clear_cache_for_uri(document.uri)
|
LSP_SERVER.clear_cache_for_uri(document.uri)
|
||||||
LSP_SERVER.compute_diagnostics(document)
|
LSP_SERVER.schedule_document_analysis(document)
|
||||||
LSP_SERVER.update_poco_completion_for_file(document)
|
|
||||||
|
|
||||||
|
|
||||||
FILE_OPERATION_OPTIONS = lsp.FileOperationRegistrationOptions(
|
FILE_OPERATION_OPTIONS = lsp.FileOperationRegistrationOptions(
|
||||||
@@ -203,10 +221,12 @@ def did_change_watched_files(params: lsp.DidChangeWatchedFilesParams) -> None:
|
|||||||
def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
||||||
"""Return diagnostics for the requested document"""
|
"""Return diagnostics for the requested document"""
|
||||||
uri = params.text_document.uri
|
uri = params.text_document.uri
|
||||||
|
doc = LSP_SERVER.workspace.get_text_document(uri)
|
||||||
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
||||||
was_cached = diagnostic_state is not None
|
was_cached = (
|
||||||
if diagnostic_state is None:
|
diagnostic_state is not None and diagnostic_state[0] == doc.version
|
||||||
doc = LSP_SERVER.workspace.get_text_document(uri)
|
)
|
||||||
|
if not was_cached:
|
||||||
LSP_SERVER.compute_diagnostics(doc)
|
LSP_SERVER.compute_diagnostics(doc)
|
||||||
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
||||||
|
|
||||||
@@ -224,24 +244,15 @@ 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) -> lsp.CompletionList:
|
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||||
from tools.variable_index import build_variable_index
|
|
||||||
from tools.completion_items import BUILTIN_VAR_LABELS
|
from tools.completion_items import BUILTIN_VAR_LABELS
|
||||||
|
|
||||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||||
|
|
||||||
# Base items
|
workspace_items = LSP_SERVER.completion_items_snapshot()
|
||||||
poco_completion, _, _ = LSP_SERVER.index_snapshot()
|
|
||||||
poco = [item for items in 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
|
|
||||||
tree = LSP_SERVER.get_tree(doc)
|
tree = LSP_SERVER.get_tree(doc)
|
||||||
globals_set, procs_locals, proc_ranges = build_variable_index(doc.source, tree)
|
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
|
||||||
|
doc, tree
|
||||||
|
)
|
||||||
|
|
||||||
# Always include globals (excluding built-ins)
|
# Always include globals (excluding built-ins)
|
||||||
dynamic_items = []
|
dynamic_items = []
|
||||||
@@ -268,9 +279,11 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
|||||||
|
|
||||||
# Merge with de-duplication. Each file keeps its complete index, so a proc
|
# Merge with de-duplication. Each file keeps its complete index, so a proc
|
||||||
# declared in multiple files must only appear once in the completion list.
|
# declared in multiple files must only appear once in the completion list.
|
||||||
merged: list[lsp.CompletionItem] = []
|
merged: list[lsp.CompletionItem] = list(STATIC_COMPLETION_ITEMS)
|
||||||
seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set()
|
seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set(
|
||||||
for it in base_items + dynamic_items:
|
STATIC_COMPLETION_KEYS
|
||||||
|
)
|
||||||
|
for it in (*workspace_items, *dynamic_items):
|
||||||
key = (it.label, getattr(it, "kind", None))
|
key = (it.label, getattr(it, "kind", None))
|
||||||
if key in seen_items:
|
if key in seen_items:
|
||||||
continue
|
continue
|
||||||
@@ -291,22 +304,9 @@ def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None:
|
|||||||
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.get_tree(document)
|
tree = LSP_SERVER.get_tree(document)
|
||||||
|
|
||||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
custom_signatures, custom_docs = LSP_SERVER.proc_metadata_snapshot(
|
||||||
custom_signatures: dict[str, list[str]] = {}
|
document.path
|
||||||
custom_docs: dict[str, str] = {}
|
)
|
||||||
_, proc_signatures, proc_docs = LSP_SERVER.index_snapshot()
|
|
||||||
|
|
||||||
# Prefer declarations from the current document if duplicate proc names
|
|
||||||
# exist in the workspace.
|
|
||||||
for indexed_path, signatures in proc_signatures.items():
|
|
||||||
if indexed_path != filepath:
|
|
||||||
custom_signatures.update(signatures)
|
|
||||||
custom_signatures.update(proc_signatures.get(filepath, {}))
|
|
||||||
|
|
||||||
for indexed_path, docs in proc_docs.items():
|
|
||||||
if indexed_path != filepath:
|
|
||||||
custom_docs.update(docs)
|
|
||||||
custom_docs.update(proc_docs.get(filepath, {}))
|
|
||||||
|
|
||||||
return build_signature_help(
|
return build_signature_help(
|
||||||
document.source,
|
document.source,
|
||||||
@@ -352,30 +352,22 @@ def inlay_hints(params: lsp.InlayHintParams):
|
|||||||
|
|
||||||
# Built-in NX procedures are the fallback. Workspace procedures replace them,
|
# Built-in NX procedures are the fallback. Workspace procedures replace them,
|
||||||
# and a declaration in the current file wins over duplicate workspace names.
|
# and a declaration in the current file wins over duplicate workspace names.
|
||||||
signatures = build_builtin_inlay_signatures(
|
custom_signatures = LSP_SERVER.custom_inlay_signatures_snapshot(
|
||||||
standard_items.json_data.get("MOM_procs", [])
|
document.path
|
||||||
)
|
|
||||||
_, proc_signatures, proc_docs = LSP_SERVER.index_snapshot()
|
|
||||||
signatures.update(
|
|
||||||
build_custom_inlay_signatures(
|
|
||||||
proc_signatures,
|
|
||||||
proc_docs,
|
|
||||||
LSP_SERVER.navigation_snapshot(),
|
|
||||||
document.path,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
signatures = ChainMap(custom_signatures, BUILTIN_INLAY_SIGNATURES)
|
||||||
|
|
||||||
generator = InlayHintGenerator(
|
generator = InlayHintGenerator(
|
||||||
document.source,
|
document.source,
|
||||||
signatures,
|
signatures,
|
||||||
|
source_lines=LSP_SERVER.get_lines(document),
|
||||||
requested_range=params.range,
|
requested_range=params.range,
|
||||||
parameter_names=parameter_names,
|
parameter_names=parameter_names,
|
||||||
suppress_when_argument_matches_name=inlay_settings.get(
|
suppress_when_argument_matches_name=inlay_settings.get(
|
||||||
"suppressWhenArgumentMatchesName", True
|
"suppressWhenArgumentMatchesName", True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
tree.accept(generator, recurse=True)
|
return generator.generate(tree)
|
||||||
return generator.hints
|
|
||||||
|
|
||||||
|
|
||||||
@LSP_SERVER.feature(
|
@LSP_SERVER.feature(
|
||||||
@@ -390,8 +382,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
plugins = []
|
plugins = []
|
||||||
poco_completion, _, _ = LSP_SERVER.index_snapshot()
|
hl = _Highlighter(plugins, LSP_SERVER.custom_function_names_snapshot())
|
||||||
hl = _Highlighter(plugins, poco_completion)
|
|
||||||
|
|
||||||
# Reuse cached AST
|
# Reuse cached AST
|
||||||
tree = LSP_SERVER.get_tree(document)
|
tree = LSP_SERVER.get_tree(document)
|
||||||
@@ -404,7 +395,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
|
|||||||
token.line,
|
token.line,
|
||||||
token.offset,
|
token.offset,
|
||||||
token.length,
|
token.length,
|
||||||
TOKEN_TYPES.index(token.tok_type),
|
TOKEN_TYPE_INDEX[token.tok_type],
|
||||||
reduce(operator.or_, token.tok_modifiers, 0),
|
reduce(operator.or_, token.tok_modifiers, 0),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -426,14 +417,14 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
|||||||
col = params.position.character
|
col = params.position.character
|
||||||
|
|
||||||
try:
|
try:
|
||||||
line = document.lines[pos.line]
|
line = LSP_SERVER.get_lines(document)[pos.line]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Do not show hover for proc name in its declaration
|
# Do not show hover for proc name in its declaration
|
||||||
from tools.proc_docs import is_proc_declaration_position
|
from tools.proc_docs import is_proc_declaration_line
|
||||||
|
|
||||||
if is_proc_declaration_position(document.source, pos.line, pos.character):
|
if is_proc_declaration_line(line, pos.character):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Identify the token under the cursor
|
# Identify the token under the cursor
|
||||||
@@ -445,11 +436,7 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# 1) If token is a known MOM proc/variable, return built-in hover
|
# 1) If token is a known MOM proc/variable, return built-in hover
|
||||||
command = token
|
match = BUILTIN_HOVER_ITEMS.get(token)
|
||||||
data = standard_items.json_data
|
|
||||||
all_items = data.get("MOM_procs", []) + data.get("mom_variables", [])
|
|
||||||
|
|
||||||
match = next((item for item in all_items if item["label"] == command), None)
|
|
||||||
if match and match.get("kind") == "function":
|
if match and match.get("kind") == "function":
|
||||||
label = match.get("label", "")
|
label = match.get("label", "")
|
||||||
parameters = match.get("parameters", [])
|
parameters = match.get("parameters", [])
|
||||||
@@ -482,14 +469,10 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
|||||||
|
|
||||||
# 2) Otherwise, check if the token is a custom proc and show its preceding doc block
|
# 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
|
# Build a merged map of proc -> docs gathered during initialization and updates
|
||||||
proc_docs: dict[str, str] = {}
|
proc_doc = LSP_SERVER.proc_documentation(token, document.path)
|
||||||
_, _, indexed_proc_docs = LSP_SERVER.index_snapshot()
|
if proc_doc is not None:
|
||||||
for file_docs in indexed_proc_docs.values():
|
|
||||||
proc_docs.update(file_docs)
|
|
||||||
|
|
||||||
if token in proc_docs:
|
|
||||||
return lsp.Hover(
|
return lsp.Hover(
|
||||||
lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_docs[token])
|
lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_doc)
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -517,7 +500,7 @@ def _navigation_context(uri: str, position: lsp.Position):
|
|||||||
indexes = LSP_SERVER.navigation_snapshot()
|
indexes = LSP_SERVER.navigation_snapshot()
|
||||||
filepath = str(pathlib.Path(uris.to_fs_path(uri)))
|
filepath = str(pathlib.Path(uris.to_fs_path(uri)))
|
||||||
index = indexes.get(filepath)
|
index = indexes.get(filepath)
|
||||||
if index is None:
|
if index is None or LSP_SERVER.index_update_pending(filepath):
|
||||||
document = LSP_SERVER.workspace.get_text_document(uri)
|
document = LSP_SERVER.workspace.get_text_document(uri)
|
||||||
LSP_SERVER.update_poco_completion_for_file(document)
|
LSP_SERVER.update_poco_completion_for_file(document)
|
||||||
indexes = LSP_SERVER.navigation_snapshot()
|
indexes = LSP_SERVER.navigation_snapshot()
|
||||||
|
|||||||
+320
-5
@@ -11,15 +11,16 @@ from pygls.workspace.text_document import TextDocument
|
|||||||
from tclint.format import FormatterOpts
|
from tclint.format import FormatterOpts
|
||||||
from tclint.lexer import TclSyntaxError
|
from tclint.lexer import TclSyntaxError
|
||||||
from tclint.violations import Violation
|
from tclint.violations import Violation
|
||||||
|
|
||||||
from tools import checks, parser
|
from tools import checks, parser
|
||||||
from tools.completion_items import CompletionCollector
|
from tools.completion_items import CompletionCollector
|
||||||
from tools.formatter import NxFormatter as Formatter
|
from tools.formatter import NxFormatter as Formatter
|
||||||
|
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
|
||||||
from tools.navigation import FileSymbolIndex, build_file_symbol_index
|
from tools.navigation import FileSymbolIndex, build_file_symbol_index
|
||||||
from tools.proc_docs import build_proc_docs
|
from tools.proc_docs import build_proc_docs
|
||||||
|
from tools.variable_index import ProcRange, build_variable_index
|
||||||
|
|
||||||
DIAGNOSTIC_SOURCE = "nx-post-support"
|
DIAGNOSTIC_SOURCE = "nx-post-support"
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TclLanguageServer(server.LanguageServer):
|
class TclLanguageServer(server.LanguageServer):
|
||||||
@@ -33,15 +34,40 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
self.proc_signatures: dict = {}
|
self.proc_signatures: dict = {}
|
||||||
self.proc_docs: dict = {}
|
self.proc_docs: dict = {}
|
||||||
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
|
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
|
||||||
|
self.variable_indexes: dict[
|
||||||
|
str,
|
||||||
|
tuple[
|
||||||
|
int | None,
|
||||||
|
tuple[set[str], dict[str, set[str]], list[ProcRange]],
|
||||||
|
],
|
||||||
|
] = {}
|
||||||
# Cache: (uri, version) -> (tree, violations)
|
# Cache: (uri, version) -> (tree, violations)
|
||||||
self._ast_cache = {}
|
self._ast_cache = {}
|
||||||
|
self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {}
|
||||||
self._parser_lock = threading.RLock()
|
self._parser_lock = threading.RLock()
|
||||||
self._index_lock = threading.RLock()
|
self._index_lock = threading.RLock()
|
||||||
self._index_tokens: dict[str, int] = {}
|
self._index_tokens: dict[str, int] = {}
|
||||||
self._index_versions: dict[str, int | None] = {}
|
self._index_versions: dict[str, int | None] = {}
|
||||||
|
self._committed_index_versions: dict[str, int | None] = {}
|
||||||
self._next_index_token = 0
|
self._next_index_token = 0
|
||||||
self._diagnostic_tokens: dict[str, int] = {}
|
self._diagnostic_tokens: dict[str, int] = {}
|
||||||
self._next_diagnostic_token = 0
|
self._next_diagnostic_token = 0
|
||||||
|
self._index_generation = 0
|
||||||
|
self._workspace_completion_cache: tuple[int, tuple] = (-1, ())
|
||||||
|
self._custom_function_names_cache: tuple[int, frozenset[str]] = (
|
||||||
|
-1,
|
||||||
|
frozenset(),
|
||||||
|
)
|
||||||
|
self._proc_metadata_cache: dict[
|
||||||
|
str, tuple[int, dict[str, list[str]], dict[str, str]]
|
||||||
|
] = {}
|
||||||
|
self._custom_inlay_cache: dict[
|
||||||
|
str, tuple[int, dict[str, InlayHintSignature]]
|
||||||
|
] = {}
|
||||||
|
self._analysis_lock = threading.RLock()
|
||||||
|
self._analysis_timers: dict[str, threading.Timer] = {}
|
||||||
|
self._analysis_tokens: dict[str, int] = {}
|
||||||
|
self._next_analysis_token = 0
|
||||||
|
|
||||||
def _parse_source(self, source: str):
|
def _parse_source(self, source: str):
|
||||||
self.parser.violations = []
|
self.parser.violations = []
|
||||||
@@ -74,11 +100,24 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
self._ast_cache[key] = (tree, violations)
|
self._ast_cache[key] = (tree, violations)
|
||||||
return tree, violations
|
return tree, violations
|
||||||
|
|
||||||
|
def get_lines(self, document: TextDocument) -> tuple[str, ...]:
|
||||||
|
"""Return split source lines once per document version."""
|
||||||
|
key = (document.uri, document.version)
|
||||||
|
with self._parser_lock:
|
||||||
|
lines = self._line_cache.get(key)
|
||||||
|
if lines is None:
|
||||||
|
lines = tuple(document.source.splitlines())
|
||||||
|
self._line_cache[key] = lines
|
||||||
|
return lines
|
||||||
|
|
||||||
def clear_cache_for_uri(self, uri: str):
|
def clear_cache_for_uri(self, uri: str):
|
||||||
with self._parser_lock:
|
with self._parser_lock:
|
||||||
to_delete = [key for key in self._ast_cache if key[0] == uri]
|
to_delete = [key for key in self._ast_cache if key[0] == uri]
|
||||||
for key in to_delete:
|
for key in to_delete:
|
||||||
del self._ast_cache[key]
|
del self._ast_cache[key]
|
||||||
|
for key in list(self._line_cache):
|
||||||
|
if key[0] == uri:
|
||||||
|
del self._line_cache[key]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalized_path(path: pathlib.Path | str) -> str:
|
def _normalized_path(path: pathlib.Path | str) -> str:
|
||||||
@@ -101,6 +140,263 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
return cls._normalized_path(first) == cls._normalized_path(second)
|
return cls._normalized_path(first) == cls._normalized_path(second)
|
||||||
|
|
||||||
|
def _invalidate_workspace_caches_locked(self) -> None:
|
||||||
|
"""Invalidate request-level aggregates after an index mutation."""
|
||||||
|
self._index_generation += 1
|
||||||
|
self._workspace_completion_cache = (-1, ())
|
||||||
|
self._custom_function_names_cache = (-1, frozenset())
|
||||||
|
self._proc_metadata_cache.clear()
|
||||||
|
self._custom_inlay_cache.clear()
|
||||||
|
|
||||||
|
def completion_items_snapshot(self) -> tuple:
|
||||||
|
"""Return de-duplicated workspace completion items, cached by generation."""
|
||||||
|
with self._index_lock:
|
||||||
|
generation, items = self._workspace_completion_cache
|
||||||
|
if generation == self._index_generation:
|
||||||
|
return items
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
for path_items in self.poco_completion.values():
|
||||||
|
for item in path_items:
|
||||||
|
key = (item.label, getattr(item, "kind", None))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.append(item)
|
||||||
|
|
||||||
|
items = tuple(merged)
|
||||||
|
self._workspace_completion_cache = (self._index_generation, items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def custom_function_names_snapshot(self) -> frozenset[str]:
|
||||||
|
"""Return custom completion labels for semantic highlighting."""
|
||||||
|
with self._index_lock:
|
||||||
|
generation, names = self._custom_function_names_cache
|
||||||
|
if generation == self._index_generation:
|
||||||
|
return names
|
||||||
|
|
||||||
|
names = frozenset(
|
||||||
|
item.label
|
||||||
|
for path_items in self.poco_completion.values()
|
||||||
|
for item in path_items
|
||||||
|
)
|
||||||
|
self._custom_function_names_cache = (self._index_generation, names)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def proc_metadata_snapshot(
|
||||||
|
self, current_path: pathlib.Path | str
|
||||||
|
) -> tuple[dict[str, list[str]], dict[str, str]]:
|
||||||
|
"""Return merged proc metadata, preferring declarations in the active file."""
|
||||||
|
normalized_current = self._normalized_path(current_path)
|
||||||
|
with self._index_lock:
|
||||||
|
cached = self._proc_metadata_cache.get(normalized_current)
|
||||||
|
if cached is not None and cached[0] == self._index_generation:
|
||||||
|
return cached[1], cached[2]
|
||||||
|
|
||||||
|
signatures: dict[str, list[str]] = {}
|
||||||
|
docs: dict[str, str] = {}
|
||||||
|
signature_paths = sorted(
|
||||||
|
self.proc_signatures,
|
||||||
|
key=lambda path: self._normalized_path(path).casefold(),
|
||||||
|
)
|
||||||
|
doc_paths = sorted(
|
||||||
|
self.proc_docs,
|
||||||
|
key=lambda path: self._normalized_path(path).casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for path in signature_paths:
|
||||||
|
if self._normalized_path(path) != normalized_current:
|
||||||
|
signatures.update(self.proc_signatures[path])
|
||||||
|
for path in signature_paths:
|
||||||
|
if self._normalized_path(path) == normalized_current:
|
||||||
|
signatures.update(self.proc_signatures[path])
|
||||||
|
|
||||||
|
for path in doc_paths:
|
||||||
|
if self._normalized_path(path) != normalized_current:
|
||||||
|
docs.update(self.proc_docs[path])
|
||||||
|
for path in doc_paths:
|
||||||
|
if self._normalized_path(path) == normalized_current:
|
||||||
|
docs.update(self.proc_docs[path])
|
||||||
|
|
||||||
|
cached_value = (self._index_generation, signatures, docs)
|
||||||
|
self._proc_metadata_cache[normalized_current] = cached_value
|
||||||
|
return signatures, docs
|
||||||
|
|
||||||
|
def proc_documentation(
|
||||||
|
self, name: str, current_path: pathlib.Path | str
|
||||||
|
) -> str | None:
|
||||||
|
_, docs = self.proc_metadata_snapshot(current_path)
|
||||||
|
return docs.get(name)
|
||||||
|
|
||||||
|
def custom_inlay_signatures_snapshot(
|
||||||
|
self, current_path: pathlib.Path | str
|
||||||
|
) -> dict[str, InlayHintSignature]:
|
||||||
|
"""Return custom inlay signatures cached until the workspace index changes."""
|
||||||
|
normalized_current = self._normalized_path(current_path)
|
||||||
|
with self._index_lock:
|
||||||
|
cached = self._custom_inlay_cache.get(normalized_current)
|
||||||
|
if cached is not None and cached[0] == self._index_generation:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
signatures = build_custom_inlay_signatures(
|
||||||
|
self.proc_signatures,
|
||||||
|
self.proc_docs,
|
||||||
|
self.navigation_indexes,
|
||||||
|
os.fspath(current_path),
|
||||||
|
)
|
||||||
|
self._custom_inlay_cache[normalized_current] = (
|
||||||
|
self._index_generation,
|
||||||
|
signatures,
|
||||||
|
)
|
||||||
|
return signatures
|
||||||
|
|
||||||
|
def variable_index_for_document(
|
||||||
|
self, document: TextDocument, tree=None
|
||||||
|
) -> tuple[set[str], dict[str, set[str]], list[ProcRange]]:
|
||||||
|
"""Return the per-version variable index used by completion requests."""
|
||||||
|
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||||
|
with self._index_lock:
|
||||||
|
cached = self.variable_indexes.get(filepath)
|
||||||
|
if cached is not None and cached[0] == document.version:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
if tree is None:
|
||||||
|
tree = self.get_tree(document)
|
||||||
|
variable_index = build_variable_index(document.source, tree)
|
||||||
|
|
||||||
|
with self._index_lock:
|
||||||
|
cached = self.variable_indexes.get(filepath)
|
||||||
|
if (
|
||||||
|
cached is None
|
||||||
|
or cached[0] is None
|
||||||
|
or document.version is None
|
||||||
|
or cached[0] <= document.version
|
||||||
|
):
|
||||||
|
self.variable_indexes[filepath] = (document.version, variable_index)
|
||||||
|
return variable_index
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
def index_is_current(self, document: TextDocument) -> bool:
|
||||||
|
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||||
|
with self._index_lock:
|
||||||
|
return (
|
||||||
|
filepath in self._committed_index_versions
|
||||||
|
and self._committed_index_versions[filepath] == document.version
|
||||||
|
)
|
||||||
|
|
||||||
|
def index_update_pending(self, filepath: pathlib.Path | str) -> bool:
|
||||||
|
filepath = os.fspath(filepath)
|
||||||
|
with self._index_lock:
|
||||||
|
return (
|
||||||
|
filepath not in self._committed_index_versions
|
||||||
|
or self._index_versions.get(filepath)
|
||||||
|
!= self._committed_index_versions[filepath]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_document_analysis(self, uri: str) -> None:
|
||||||
|
with self._analysis_lock:
|
||||||
|
timer = self._analysis_timers.pop(uri, None)
|
||||||
|
self._analysis_tokens.pop(uri, None)
|
||||||
|
if timer is not None:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
|
def cancel_analysis_under_uri(self, uri: str) -> None:
|
||||||
|
"""Cancel delayed analysis for a closed/deleted file or folder."""
|
||||||
|
try:
|
||||||
|
target = pathlib.Path(uris.to_fs_path(uri))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._cancel_document_analysis(uri)
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._analysis_lock:
|
||||||
|
matching_uris = []
|
||||||
|
for pending_uri in self._analysis_timers:
|
||||||
|
try:
|
||||||
|
pending_path = pathlib.Path(uris.to_fs_path(pending_uri))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if self._is_same_or_child(pending_path, target):
|
||||||
|
matching_uris.append(pending_uri)
|
||||||
|
|
||||||
|
timers = [self._analysis_timers.pop(key) for key in matching_uris]
|
||||||
|
for key in matching_uris:
|
||||||
|
self._analysis_tokens.pop(key, None)
|
||||||
|
|
||||||
|
for timer in timers:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
|
def _invalidate_document_work(self, document: TextDocument) -> None:
|
||||||
|
"""Prevent older diagnostic/index work from committing after a new edit."""
|
||||||
|
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||||
|
with self._index_lock:
|
||||||
|
self._next_diagnostic_token += 1
|
||||||
|
self._diagnostic_tokens[document.uri] = self._next_diagnostic_token
|
||||||
|
self._next_index_token += 1
|
||||||
|
self._index_tokens[filepath] = self._next_index_token
|
||||||
|
self._index_versions[filepath] = document.version
|
||||||
|
|
||||||
|
def schedule_document_analysis(
|
||||||
|
self, document: TextDocument, delay_seconds: float = 0.15
|
||||||
|
) -> None:
|
||||||
|
"""Coalesce rapid edits and analyze only the latest immutable snapshot."""
|
||||||
|
snapshot = TextDocument(
|
||||||
|
uri=document.uri,
|
||||||
|
source=document.source,
|
||||||
|
version=document.version,
|
||||||
|
language_id=document.language_id,
|
||||||
|
)
|
||||||
|
self._invalidate_document_work(snapshot)
|
||||||
|
|
||||||
|
with self._analysis_lock:
|
||||||
|
previous = self._analysis_timers.pop(snapshot.uri, None)
|
||||||
|
if previous is not None:
|
||||||
|
previous.cancel()
|
||||||
|
|
||||||
|
self._next_analysis_token += 1
|
||||||
|
token = self._next_analysis_token
|
||||||
|
self._analysis_tokens[snapshot.uri] = token
|
||||||
|
|
||||||
|
def analyze() -> None:
|
||||||
|
with self._analysis_lock:
|
||||||
|
if self._analysis_tokens.get(snapshot.uri) != token:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
diagnostic_state = self.diagnostic_snapshot(snapshot.uri)
|
||||||
|
if (
|
||||||
|
diagnostic_state is None
|
||||||
|
or diagnostic_state[0] != snapshot.version
|
||||||
|
):
|
||||||
|
self.compute_diagnostics(snapshot)
|
||||||
|
with self._analysis_lock:
|
||||||
|
if self._analysis_tokens.get(snapshot.uri) != token:
|
||||||
|
return
|
||||||
|
if not self.index_is_current(snapshot):
|
||||||
|
self.update_poco_completion_for_file(snapshot)
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("Delayed analysis failed for %s", snapshot.uri)
|
||||||
|
finally:
|
||||||
|
with self._analysis_lock:
|
||||||
|
if self._analysis_tokens.get(snapshot.uri) == token:
|
||||||
|
self._analysis_tokens.pop(snapshot.uri, None)
|
||||||
|
self._analysis_timers.pop(snapshot.uri, None)
|
||||||
|
|
||||||
|
timer = threading.Timer(delay_seconds, analyze)
|
||||||
|
timer.daemon = True
|
||||||
|
self._analysis_timers[snapshot.uri] = timer
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
def analyze_document_now(self, document: TextDocument) -> None:
|
||||||
|
"""Cancel delayed work and synchronously analyze the current document."""
|
||||||
|
self._cancel_document_analysis(document.uri)
|
||||||
|
self._invalidate_document_work(document)
|
||||||
|
diagnostic_state = self.diagnostic_snapshot(document.uri)
|
||||||
|
if diagnostic_state is None or diagnostic_state[0] != document.version:
|
||||||
|
self.compute_diagnostics(document)
|
||||||
|
if not self.index_is_current(document):
|
||||||
|
self.update_poco_completion_for_file(document)
|
||||||
|
|
||||||
def index_snapshot(self) -> tuple[dict, dict, dict]:
|
def index_snapshot(self) -> tuple[dict, dict, dict]:
|
||||||
"""Return stable copies for request handlers running beside the indexer."""
|
"""Return stable copies for request handlers running beside the indexer."""
|
||||||
with self._index_lock:
|
with self._index_lock:
|
||||||
@@ -148,6 +444,9 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
self.proc_signatures.pop(filepath, None)
|
self.proc_signatures.pop(filepath, None)
|
||||||
self.proc_docs.pop(filepath, None)
|
self.proc_docs.pop(filepath, None)
|
||||||
self.navigation_indexes.pop(filepath, None)
|
self.navigation_indexes.pop(filepath, None)
|
||||||
|
self.variable_indexes.pop(filepath, None)
|
||||||
|
self._committed_index_versions.pop(filepath, None)
|
||||||
|
self._invalidate_workspace_caches_locked()
|
||||||
|
|
||||||
def indexed_paths_under_uri(self, uri: str) -> list[pathlib.Path]:
|
def indexed_paths_under_uri(self, uri: str) -> list[pathlib.Path]:
|
||||||
target = pathlib.Path(uris.to_fs_path(uri))
|
target = pathlib.Path(uris.to_fs_path(uri))
|
||||||
@@ -156,6 +455,7 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
indexed_paths.update(self.proc_signatures)
|
indexed_paths.update(self.proc_signatures)
|
||||||
indexed_paths.update(self.proc_docs)
|
indexed_paths.update(self.proc_docs)
|
||||||
indexed_paths.update(self.navigation_indexes)
|
indexed_paths.update(self.navigation_indexes)
|
||||||
|
indexed_paths.update(self.variable_indexes)
|
||||||
indexed_paths.update(self._index_tokens)
|
indexed_paths.update(self._index_tokens)
|
||||||
return [
|
return [
|
||||||
pathlib.Path(path)
|
pathlib.Path(path)
|
||||||
@@ -165,20 +465,28 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
|
|
||||||
def remove_file_state(self, uri: str) -> None:
|
def remove_file_state(self, uri: str) -> None:
|
||||||
"""Remove cached and indexed state for a file or a complete folder."""
|
"""Remove cached and indexed state for a file or a complete folder."""
|
||||||
|
self.cancel_analysis_under_uri(uri)
|
||||||
target = pathlib.Path(uris.to_fs_path(uri))
|
target = pathlib.Path(uris.to_fs_path(uri))
|
||||||
|
|
||||||
with self._index_lock:
|
with self._index_lock:
|
||||||
|
index_changed = False
|
||||||
for store in (
|
for store in (
|
||||||
self.poco_completion,
|
self.poco_completion,
|
||||||
self.proc_signatures,
|
self.proc_signatures,
|
||||||
self.proc_docs,
|
self.proc_docs,
|
||||||
self.navigation_indexes,
|
self.navigation_indexes,
|
||||||
|
self.variable_indexes,
|
||||||
self._index_tokens,
|
self._index_tokens,
|
||||||
self._index_versions,
|
self._index_versions,
|
||||||
|
self._committed_index_versions,
|
||||||
):
|
):
|
||||||
for path in list(store):
|
for path in list(store):
|
||||||
if self._is_same_or_child(path, target):
|
if self._is_same_or_child(path, target):
|
||||||
del store[path]
|
del store[path]
|
||||||
|
index_changed = True
|
||||||
|
|
||||||
|
if index_changed:
|
||||||
|
self._invalidate_workspace_caches_locked()
|
||||||
|
|
||||||
diagnostic_uris = set(self.diagnostics)
|
diagnostic_uris = set(self.diagnostics)
|
||||||
diagnostic_uris.update(self._diagnostic_tokens)
|
diagnostic_uris.update(self._diagnostic_tokens)
|
||||||
@@ -192,13 +500,16 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
self._diagnostic_tokens.pop(diagnostic_uri, None)
|
self._diagnostic_tokens.pop(diagnostic_uri, None)
|
||||||
|
|
||||||
with self._parser_lock:
|
with self._parser_lock:
|
||||||
for key in list(self._ast_cache):
|
cached_keys = set(self._ast_cache)
|
||||||
|
cached_keys.update(self._line_cache)
|
||||||
|
for key in cached_keys:
|
||||||
try:
|
try:
|
||||||
cached_path = pathlib.Path(uris.to_fs_path(key[0]))
|
cached_path = pathlib.Path(uris.to_fs_path(key[0]))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if self._is_same_or_child(cached_path, target):
|
if self._is_same_or_child(cached_path, target):
|
||||||
del self._ast_cache[key]
|
self._ast_cache.pop(key, None)
|
||||||
|
self._line_cache.pop(key, None)
|
||||||
|
|
||||||
def update_poco_completion_for_file(
|
def update_poco_completion_for_file(
|
||||||
self,
|
self,
|
||||||
@@ -225,8 +536,9 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
navigation_index = build_file_symbol_index(
|
navigation_index = build_file_symbol_index(
|
||||||
filepath, document.uri, tree
|
filepath, document.uri, tree
|
||||||
)
|
)
|
||||||
|
variable_index = build_variable_index(document.source, tree)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.debug(f"Error parsing {filepath}: {e}")
|
LOGGER.debug("Error parsing %s: %s", filepath, e)
|
||||||
self._discard_index_update(filepath, token)
|
self._discard_index_update(filepath, token)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -241,6 +553,9 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
self.proc_signatures[filepath] = dict(collector.proc_signatures)
|
self.proc_signatures[filepath] = dict(collector.proc_signatures)
|
||||||
self.proc_docs[filepath] = docs
|
self.proc_docs[filepath] = docs
|
||||||
self.navigation_indexes[filepath] = navigation_index
|
self.navigation_indexes[filepath] = navigation_index
|
||||||
|
self.variable_indexes[filepath] = (document.version, variable_index)
|
||||||
|
self._committed_index_versions[filepath] = document.version
|
||||||
|
self._invalidate_workspace_caches_locked()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def format(
|
def format(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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
|
||||||
|
from tclint.syntax_tree import BareWord, Command, List, Visitor
|
||||||
|
|
||||||
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
|
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
|
||||||
|
BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs}
|
||||||
|
|
||||||
|
|
||||||
class CompletionItems:
|
class CompletionItems:
|
||||||
@@ -22,6 +23,7 @@ class CompletionCollector(Visitor):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._custom_functions: list[lsp.CompletionItem] = []
|
self._custom_functions: list[lsp.CompletionItem] = []
|
||||||
|
self._custom_function_keys: set[tuple[str, lsp.CompletionItemKind | None]] = set()
|
||||||
self._proc_signatures = {}
|
self._proc_signatures = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,8 +36,11 @@ class CompletionCollector(Visitor):
|
|||||||
|
|
||||||
def _append_unique(self, item: lsp.CompletionItem):
|
def _append_unique(self, item: lsp.CompletionItem):
|
||||||
# Avoid duplicate labels within the same file scan
|
# Avoid duplicate labels within the same file scan
|
||||||
if not any(ci.label == item.label for ci in self._custom_functions):
|
key = (item.label, item.kind)
|
||||||
self._custom_functions.append(item)
|
if key in self._custom_function_keys:
|
||||||
|
return
|
||||||
|
self._custom_function_keys.add(key)
|
||||||
|
self._custom_functions.append(item)
|
||||||
|
|
||||||
def visit_command(self, command: Command):
|
def visit_command(self, command: Command):
|
||||||
routine = command.routine
|
routine = command.routine
|
||||||
@@ -46,7 +51,7 @@ class CompletionCollector(Visitor):
|
|||||||
if not getattr(first_arg, "value", None):
|
if not getattr(first_arg, "value", None):
|
||||||
return
|
return
|
||||||
|
|
||||||
if any(item.label == first_arg.value for item in standard_items.nx_procs):
|
if first_arg.value in BUILTIN_PROC_LABELS:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Record proc name as a completion item
|
# Record proc name as a completion item
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import lsprotocol.types as lsp
|
import lsprotocol.types as lsp
|
||||||
from tclint.syntax_tree import Command, VarSub, Visitor
|
from tclint.syntax_tree import Command, VarSub, Visitor
|
||||||
|
|
||||||
from tools.navigation import FileSymbolIndex
|
from tools.navigation import FileSymbolIndex
|
||||||
|
|
||||||
|
|
||||||
@@ -30,21 +30,22 @@ def _normalized_path(path: str) -> str:
|
|||||||
return os.path.normcase(os.path.abspath(path))
|
return os.path.normcase(os.path.abspath(path))
|
||||||
|
|
||||||
|
|
||||||
def _definition_location(
|
def _definition_locations(
|
||||||
index: FileSymbolIndex | None, proc_name: str
|
index: FileSymbolIndex | None,
|
||||||
) -> lsp.Location | None:
|
) -> dict[str, lsp.Location]:
|
||||||
if index is None:
|
if index is None:
|
||||||
return None
|
return {}
|
||||||
|
|
||||||
basename = proc_name.removeprefix("::").rsplit("::", 1)[-1]
|
locations = {}
|
||||||
for occurrence in reversed(index.occurrences):
|
for occurrence in index.occurrences:
|
||||||
if (
|
if (
|
||||||
occurrence.is_definition
|
occurrence.is_definition
|
||||||
and occurrence.identity.kind == "proc"
|
and occurrence.identity.kind == "proc"
|
||||||
and occurrence.placeholder == basename
|
|
||||||
):
|
):
|
||||||
return lsp.Location(uri=index.uri, range=occurrence.range)
|
locations[occurrence.placeholder] = lsp.Location(
|
||||||
return None
|
uri=index.uri, range=occurrence.range
|
||||||
|
)
|
||||||
|
return locations
|
||||||
|
|
||||||
|
|
||||||
def build_custom_inlay_signatures(
|
def build_custom_inlay_signatures(
|
||||||
@@ -63,7 +64,7 @@ def build_custom_inlay_signatures(
|
|||||||
result: dict[str, InlayHintSignature] = {}
|
result: dict[str, InlayHintSignature] = {}
|
||||||
for path in paths:
|
for path in paths:
|
||||||
docs = docs_by_path.get(path, {})
|
docs = docs_by_path.get(path, {})
|
||||||
index = indexes_by_path.get(path)
|
definition_locations = _definition_locations(indexes_by_path.get(path))
|
||||||
for proc_name, parameter_names in signatures_by_path[path].items():
|
for proc_name, parameter_names in signatures_by_path[path].items():
|
||||||
parameters = tuple(
|
parameters = tuple(
|
||||||
InlayHintParameter(
|
InlayHintParameter(
|
||||||
@@ -79,7 +80,9 @@ def build_custom_inlay_signatures(
|
|||||||
parameters=parameters,
|
parameters=parameters,
|
||||||
display_label=" ".join([proc_name, *parameter_names]),
|
display_label=" ".join([proc_name, *parameter_names]),
|
||||||
documentation=docs.get(proc_name),
|
documentation=docs.get(proc_name),
|
||||||
location=_definition_location(index, proc_name),
|
location=definition_locations.get(
|
||||||
|
proc_name.removeprefix("::").rsplit("::", 1)[-1]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -147,19 +150,52 @@ class InlayHintGenerator(Visitor):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
source: str,
|
source: str,
|
||||||
proc_signatures: dict[str, InlayHintSignature],
|
proc_signatures: Mapping[str, InlayHintSignature],
|
||||||
*,
|
*,
|
||||||
|
source_lines: Sequence[str] | None = None,
|
||||||
requested_range: lsp.Range | None = None,
|
requested_range: lsp.Range | None = None,
|
||||||
parameter_names: str = "all",
|
parameter_names: str = "all",
|
||||||
suppress_when_argument_matches_name: bool = True,
|
suppress_when_argument_matches_name: bool = True,
|
||||||
):
|
):
|
||||||
self.source_lines = source.splitlines()
|
self.source_lines = (
|
||||||
|
source_lines if source_lines is not None else source.splitlines()
|
||||||
|
)
|
||||||
self.proc_signatures = proc_signatures
|
self.proc_signatures = proc_signatures
|
||||||
self.requested_range = requested_range
|
self.requested_range = requested_range
|
||||||
self.parameter_names = parameter_names
|
self.parameter_names = parameter_names
|
||||||
self.suppress_when_argument_matches_name = suppress_when_argument_matches_name
|
self.suppress_when_argument_matches_name = suppress_when_argument_matches_name
|
||||||
self.hints: list[lsp.InlayHint] = []
|
self.hints: list[lsp.InlayHint] = []
|
||||||
|
|
||||||
|
def _node_intersects_requested_range(self, node) -> bool:
|
||||||
|
if self.requested_range is None:
|
||||||
|
return True
|
||||||
|
start = getattr(node, "pos", None)
|
||||||
|
end = getattr(node, "end_pos", None)
|
||||||
|
if start is None or end is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
node_start_line = start[0] - 1
|
||||||
|
node_end_line = end[0] - 1
|
||||||
|
return not (
|
||||||
|
node_end_line < self.requested_range.start.line
|
||||||
|
or node_start_line > self.requested_range.end.line
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate(self, tree) -> list[lsp.InlayHint]:
|
||||||
|
"""Walk only syntax-tree branches overlapping the requested editor range."""
|
||||||
|
self.hints.clear()
|
||||||
|
|
||||||
|
def walk(node) -> None:
|
||||||
|
if not self._node_intersects_requested_range(node):
|
||||||
|
return
|
||||||
|
if isinstance(node, Command):
|
||||||
|
self.visit_command(node)
|
||||||
|
for child in getattr(node, "children", []):
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(tree)
|
||||||
|
return self.hints
|
||||||
|
|
||||||
def _position(self, line: int, column: int) -> lsp.Position:
|
def _position(self, line: int, column: int) -> lsp.Position:
|
||||||
line_index = line - 1
|
line_index = line - 1
|
||||||
character_index = column - 1
|
character_index = column - 1
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from tclint.syntax_tree import Visitor, Command
|
from tclint.syntax_tree import Command, Visitor
|
||||||
from tools.parser import CustomParser
|
|
||||||
|
PROC_DECLARATION_RE = re.compile(r"^\s*proc\s+([^\s\{]+)")
|
||||||
|
|
||||||
|
|
||||||
def _strip_comment_prefix(line: str) -> str:
|
def _strip_comment_prefix(line: str) -> str:
|
||||||
@@ -135,43 +136,18 @@ def build_proc_docs(tree, source_text: str) -> Dict[str, str]:
|
|||||||
return extractor.docs
|
return extractor.docs
|
||||||
|
|
||||||
|
|
||||||
def is_proc_declaration_position(source_text: str, line_zero_based: int, char_zero_based: int) -> bool:
|
def is_proc_declaration_line(line: str, char_zero_based: int) -> bool:
|
||||||
|
"""Return True if the position is on the proc name on this source line."""
|
||||||
|
match = PROC_DECLARATION_RE.match(line)
|
||||||
|
return bool(match and match.start(1) <= char_zero_based <= match.end(1))
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
"""Return True if the position is on a proc name within its declaration."""
|
||||||
parser = CustomParser()
|
try:
|
||||||
tree = parser.parse(source_text)
|
line = source_text.splitlines()[line_zero_based]
|
||||||
|
except IndexError:
|
||||||
# Walk commands to find 'proc' declarations and check if position intersects the name arg
|
return False
|
||||||
class _DeclFinder(Visitor):
|
return is_proc_declaration_line(line, char_zero_based)
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import enum
|
import enum
|
||||||
from typing import List
|
from typing import List
|
||||||
from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord
|
|
||||||
from tclint.commands.plugins import PluginManager
|
|
||||||
import attrs
|
import attrs
|
||||||
from common.load_data import standard_items
|
from common.load_data import standard_items
|
||||||
import lsprotocol.types as lsp
|
from tclint.commands.plugins import PluginManager
|
||||||
|
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor
|
||||||
|
|
||||||
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
|
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
|
||||||
# the builtin command set on every call. Semantic tokens are requested often, so
|
# the builtin command set on every call. Semantic tokens are requested often, so
|
||||||
# cache the manager and the resolved commands per plugin set.
|
# cache the manager and the resolved commands per plugin set.
|
||||||
_PLUGIN_MANAGER = None
|
_PLUGIN_MANAGER = None
|
||||||
_COMMANDS_CACHE = {}
|
_COMMANDS_CACHE = {}
|
||||||
|
_STANDARD_PROC_NAMES = frozenset(item.label for item in standard_items.nx_procs)
|
||||||
|
|
||||||
|
|
||||||
def _load_commands(plugins):
|
def _load_commands(plugins):
|
||||||
@@ -60,13 +60,23 @@ TOKEN_TYPES = [
|
|||||||
"string",
|
"string",
|
||||||
"parameter",
|
"parameter",
|
||||||
]
|
]
|
||||||
|
TOKEN_TYPE_INDEX = {}
|
||||||
|
for _token_index, _token_name in enumerate(TOKEN_TYPES):
|
||||||
|
TOKEN_TYPE_INDEX.setdefault(_token_name, _token_index)
|
||||||
|
|
||||||
|
|
||||||
class _Highlighter(Visitor):
|
class _Highlighter(Visitor):
|
||||||
def __init__(self, plugins, custom_functions: dict[str : list[lsp.CompletionItem]]):
|
def __init__(self, plugins, custom_functions):
|
||||||
self._commands = _load_commands(plugins)
|
self._commands = _load_commands(plugins)
|
||||||
self._tokens = []
|
self._tokens = []
|
||||||
self.custom_functions = custom_functions
|
if isinstance(custom_functions, dict):
|
||||||
|
self._custom_function_names = frozenset(
|
||||||
|
item.label
|
||||||
|
for items in custom_functions.values()
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._custom_function_names = frozenset(custom_functions)
|
||||||
|
|
||||||
def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None):
|
def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None):
|
||||||
if position is None or length <= 0:
|
if position is None or length <= 0:
|
||||||
@@ -129,9 +139,7 @@ class _Highlighter(Visitor):
|
|||||||
# Highlight functions (custom or standard) when used as the routine
|
# Highlight functions (custom or standard) when used as the routine
|
||||||
name = getattr(routine, "contents", None)
|
name = getattr(routine, "contents", None)
|
||||||
if name:
|
if name:
|
||||||
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items)
|
if name in self._custom_function_names or name in _STANDARD_PROC_NAMES:
|
||||||
in_standard = any(item.label == name for item in standard_items.nx_procs)
|
|
||||||
if in_custom or in_standard:
|
|
||||||
line, col = routine.contents_pos
|
line, col = routine.contents_pos
|
||||||
self._append_token((line - 1, col - 1), len(name), "function", [])
|
self._append_token((line - 1, col - 1), len(name), "function", [])
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
|
||||||
|
|
||||||
THIS_DIR = Path(__file__).parent
|
THIS_DIR = Path(__file__).parent
|
||||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||||
if str(SRC_DIR) not in sys.path:
|
if str(SRC_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(SRC_DIR))
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
import lsprotocol.types as lsp # type: ignore
|
|
||||||
from pygls.workspace.text_document import TextDocument
|
|
||||||
|
|
||||||
import lsp_server
|
import lsp_server
|
||||||
|
import lsprotocol.types as lsp # type: ignore
|
||||||
from lsp_tclserver import TclLanguageServer
|
from lsp_tclserver import TclLanguageServer
|
||||||
|
from pygls.workspace.text_document import TextDocument
|
||||||
|
|
||||||
|
|
||||||
def _server() -> TclLanguageServer:
|
def _server() -> TclLanguageServer:
|
||||||
@@ -63,6 +61,7 @@ def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatc
|
|||||||
server = _server()
|
server = _server()
|
||||||
server.update_poco_completion_for_file(document)
|
server.update_poco_completion_for_file(document)
|
||||||
server.get_tree(document)
|
server.get_tree(document)
|
||||||
|
server.get_lines(document)
|
||||||
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||||
|
|
||||||
lsp_server.did_close(
|
lsp_server.did_close(
|
||||||
@@ -75,6 +74,7 @@ def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatc
|
|||||||
assert "unsaved_proc" not in signatures[document.path]
|
assert "unsaved_proc" not in signatures[document.path]
|
||||||
assert "saved_proc" in signatures[document.path]
|
assert "saved_proc" in signatures[document.path]
|
||||||
assert all(key[0] != document.uri for key in server._ast_cache)
|
assert all(key[0] != document.uri for key in server._ast_cache)
|
||||||
|
assert all(key[0] != document.uri for key in server._line_cache)
|
||||||
|
|
||||||
|
|
||||||
def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatch):
|
def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatch):
|
||||||
@@ -218,3 +218,52 @@ def test_delete_invalidates_in_flight_diagnostics(tmp_path: Path, monkeypatch):
|
|||||||
future.result(timeout=5)
|
future.result(timeout=5)
|
||||||
|
|
||||||
assert server.diagnostic_snapshot(document.uri) is None
|
assert server.diagnostic_snapshot(document.uri) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rapid_changes_analyze_only_latest_snapshot(tmp_path: Path, monkeypatch):
|
||||||
|
server = _server()
|
||||||
|
path = tmp_path / "debounced.tcl"
|
||||||
|
first = _document(path, "set value 1", version=1)
|
||||||
|
latest = _document(path, "set value 2", version=2)
|
||||||
|
calls = []
|
||||||
|
completed = Event()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"compute_diagnostics",
|
||||||
|
lambda document: calls.append(("diagnostics", document.version)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_index(document):
|
||||||
|
calls.append(("index", document.version))
|
||||||
|
completed.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "update_poco_completion_for_file", record_index)
|
||||||
|
|
||||||
|
server.schedule_document_analysis(first, delay_seconds=0.05)
|
||||||
|
server.schedule_document_analysis(latest, delay_seconds=0.05)
|
||||||
|
|
||||||
|
assert completed.wait(timeout=2)
|
||||||
|
assert calls == [("diagnostics", 2), ("index", 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_variable_and_workspace_request_caches_are_reused(tmp_path: Path):
|
||||||
|
server = _server()
|
||||||
|
document = _document(
|
||||||
|
tmp_path / "cached.tcl",
|
||||||
|
"proc cached_proc {argument} { set local_value $argument }",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert server.update_poco_completion_for_file(document)
|
||||||
|
first_variables = server.variable_index_for_document(document)
|
||||||
|
second_variables = server.variable_index_for_document(document)
|
||||||
|
first_completions = server.completion_items_snapshot()
|
||||||
|
second_completions = server.completion_items_snapshot()
|
||||||
|
first_names = server.custom_function_names_snapshot()
|
||||||
|
second_names = server.custom_function_names_snapshot()
|
||||||
|
|
||||||
|
assert first_variables is second_variables
|
||||||
|
assert first_completions is second_completions
|
||||||
|
assert first_names is second_names
|
||||||
|
assert "cached_proc" in first_names
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ if str(SRC_DIR) not in sys.path:
|
|||||||
sys.path.insert(0, str(SRC_DIR))
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
import lsprotocol.types as lsp # type: ignore
|
import lsprotocol.types as lsp # type: ignore
|
||||||
|
|
||||||
from tools.inlay_hint import (
|
from tools.inlay_hint import (
|
||||||
InlayHintGenerator,
|
InlayHintGenerator,
|
||||||
InlayHintParameter,
|
InlayHintParameter,
|
||||||
@@ -40,8 +39,7 @@ def _generate(
|
|||||||
) -> list[lsp.InlayHint]:
|
) -> list[lsp.InlayHint]:
|
||||||
tree = CustomParser().parse(source)
|
tree = CustomParser().parse(source)
|
||||||
generator = InlayHintGenerator(source, signatures, **options)
|
generator = InlayHintGenerator(source, signatures, **options)
|
||||||
tree.accept(generator, recurse=True)
|
return generator.generate(tree)
|
||||||
return generator.hints
|
|
||||||
|
|
||||||
|
|
||||||
def _labels(hints: list[lsp.InlayHint]) -> list[str]:
|
def _labels(hints: list[lsp.InlayHint]) -> list[str]:
|
||||||
@@ -81,6 +79,23 @@ def test_only_hints_inside_requested_range_are_returned():
|
|||||||
assert hints[0].position.line == 2
|
assert hints[0].position.line == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_walk_keeps_nested_commands_inside_proc_body():
|
||||||
|
source = "proc wrapper {} {\n test_proc nested\n}"
|
||||||
|
requested_range = lsp.Range(
|
||||||
|
start=lsp.Position(line=1, character=0),
|
||||||
|
end=lsp.Position(line=2, character=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
hints = _generate(
|
||||||
|
source,
|
||||||
|
{"test_proc": _signature("value")},
|
||||||
|
requested_range=requested_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _labels(hints) == ["value:"]
|
||||||
|
assert hints[0].position.line == 1
|
||||||
|
|
||||||
|
|
||||||
def test_matching_variable_name_can_be_suppressed():
|
def test_matching_variable_name_can_be_suppressed():
|
||||||
source = "test_proc $value $other"
|
source = "test_proc $value $other"
|
||||||
signature = _signature("value", "result")
|
signature = _signature("value", "result")
|
||||||
|
|||||||
Reference in New Issue
Block a user