From 4d79f268b8bdfd2098e26ac511649e2e25ee6613 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Sun, 27 Jul 2025 22:11:16 +0200 Subject: [PATCH] add linter --- server/src/lsp_server.py | 137 ++++++++++++++++++++++++--- server/src/nx_plugins/__init__.py | 0 server/src/nx_plugins/poco_plugin.py | 13 +++ server/src/tools/nx_plugins.py | 16 ---- server/src/tools/poco_check.py | 53 +++++++++++ 5 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 server/src/nx_plugins/__init__.py create mode 100644 server/src/nx_plugins/poco_plugin.py delete mode 100644 server/src/tools/nx_plugins.py create mode 100644 server/src/tools/poco_check.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 16ebe47..a359eb1 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -12,7 +12,7 @@ import re import sys import sysconfig import traceback -from typing import Any, Optional, Sequence, Tuple +from typing import Any, List, Optional, Sequence, Tuple import re @@ -46,27 +46,35 @@ from pygls.workspace.text_document import TextDocument from common.load_data import standard_items from common.formatter import format_tcl from tclint.parser import Parser +from tclint.lexer import TclSyntaxError from tclint.format import Formatter, FormatterOpts +from tclint.violations import Violation from tools.semantic_tokens import ( SemanticTokenCollector, collect_semantic_tokens, encode_tokens, ) -from tools.nx_plugins import commands +from nx_plugins.poco_plugin import commands +from tools import poco_check + +DIAGNOSTIC_SOURCE = "tclint" class TclLanguageServer(server.LanguageServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.parser = Parser() + self.parser._commands.update(commands) + self.diagnostics = {} + def format( self, document: TextDocument, options: lsp.FormattingOptions, range: Optional[Tuple[int, int]] = None, ): - parser = Parser() - parser._commands.update(commands) - tree = parser.parse(document.source) - - log_to_output(tree.pretty(2)) + # parser = Parser(command_plugins=["nx_plugins.poco_plugin.py"]) + # parser._commands.update(commands) indent = "\t" if not options.insert_spaces else " " * options.tab_size @@ -81,9 +89,87 @@ class TclLanguageServer(server.LanguageServer): if range is not None: start, end = range - return formatter.format_partial(document.source[start:end], parser) + return formatter.format_partial(document.source[start:end], self.parser) - return formatter.format_top(document.source, parser) + return formatter.format_top(document.source, self.parser) + + def linter( + self, + document: TextDocument, + ) -> List[Violation]: + violations = [] + tree = self.parser.parse(document.source) + violations += self.parser.violations + + # if debug > 0: + # print(tree.pretty(positions=(debug > 1))) + + for checker in poco_check.get_checkers(): + violations += checker.check(document.source, tree) + + # v = CommentVisitor() + # ignore_lines = v.run(tree, path) + # violations = filter_violations(violations, config.ignore, ignore_lines) + + return violations + + def lint(self, document: TextDocument): + diagnostics = [] + + try: + violations = self.linter(document) + except TclSyntaxError as e: + return [ + lsp.Diagnostic( + message=str(e), + severity=lsp.DiagnosticSeverity.Error, + range=lsp.Range( + start=lsp.Position(e.start[0] - 1, e.start[1] - 1), + end=lsp.Position(e.end[0] - 1, e.end[1] - 1), + ), + code="syntax error", + source=DIAGNOSTIC_SOURCE, + ) + ] + + for violation in violations: + message = violation.message + severity = lsp.DiagnosticSeverity.Warning + start = lsp.Position( + line=violation.start[0] - 1, character=violation.start[1] - 1 + ) + end = lsp.Position( + line=violation.end[0] - 1, character=violation.end[1] - 1 + ) + + diagnostics.append( + lsp.Diagnostic( + message=message, + severity=severity, + range=lsp.Range( + start=start, + end=end, + ), + code=violation.id, + source=DIAGNOSTIC_SOURCE, + ) + ) + + return diagnostics + + def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]: + return self.lint(document) + + def compute_diagnostics(self, document: TextDocument): + # `None` sentinel ensures that `diagnostics` gets updated if the URI is not + # present. + _, previous = self.diagnostics.get(document, (0, None)) + + diagnostics = self._compute_diagnostics(document) + + # Only update if the list has changed + if previous != diagnostics: + self.diagnostics[document.uri] = (document.version, diagnostics) WORKSPACE_SETTINGS = {} @@ -126,6 +212,7 @@ TOOL_ARGS = [] # default arguments always passed to your tool. def did_open(params: lsp.DidOpenTextDocumentParams) -> None: """LSP handler for textDocument/didOpen request.""" document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + LSP_SERVER.compute_diagnostics(document) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE) @@ -142,7 +229,33 @@ def did_close(params: lsp.DidCloseTextDocumentParams) -> None: @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CHANGE) def did_change(params: lsp.DidChangeTextDocumentParams) -> None: """LSP handler for textDocument/didChange request""" - log_to_output("Document has changed") + document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + LSP_SERVER.compute_diagnostics(document) + + +@LSP_SERVER.feature( + lsp.TEXT_DOCUMENT_DIAGNOSTIC, + lsp.DiagnosticOptions( + identifier="pull-diagnostics", + inter_file_dependencies=False, + workspace_diagnostics=False, + ), +) +def document_diagnostic(params: lsp.DocumentDiagnosticParams): + """Return diagnostics for the requested document""" + was_cached = True + if (uri := params.text_document.uri) not in LSP_SERVER.diagnostics: + was_cached = False + doc = LSP_SERVER.workspace.get_text_document(uri) + LSP_SERVER.compute_diagnostics(doc) + + version, diagnostics = LSP_SERVER.diagnostics[uri] + result_id = f"{uri}@{version}" + + if was_cached and result_id == params.previous_result_id: + return lsp.UnchangedDocumentDiagnosticReport(result_id) + + return lsp.FullDocumentDiagnosticReport(items=diagnostics, result_id=result_id) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) @@ -271,9 +384,7 @@ def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | Non # ********************************************************** @LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION) def did_change_configuration(params: lsp.DidChangeConfigurationParams): - settings = params.settings - - log_to_output(str(settings)) + """LSP Handler for Config Changes""" @LSP_SERVER.feature(lsp.INITIALIZE) diff --git a/server/src/nx_plugins/__init__.py b/server/src/nx_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/nx_plugins/poco_plugin.py b/server/src/nx_plugins/poco_plugin.py new file mode 100644 index 0000000..622864e --- /dev/null +++ b/server/src/nx_plugins/poco_plugin.py @@ -0,0 +1,13 @@ +from tclint.commands.checks import CommandArgError + + +def _lib_ge_command_buffer(args, parser): + if len(args) != 4: + raise CommandArgError( + f"wrong # of args to LIB_GE_command_buffer_edit_*: got {len(args)}, expected 4" + ) + args[2] = parser.parse_script(args[2]) + return args + + +commands = {"LIB_GE_command_buffer_edit_prepend": _lib_ge_command_buffer} diff --git a/server/src/tools/nx_plugins.py b/server/src/tools/nx_plugins.py deleted file mode 100644 index dd9ac25..0000000 --- a/server/src/tools/nx_plugins.py +++ /dev/null @@ -1,16 +0,0 @@ -from tclint.syntax_tree import BracedWord - - -def lib_spf_prepend(args, parser): - if len(args) >= 5: - # Heuristik: myTag wurde zu früh getrennt, hänge ihn an den BracedBlock - merged_contents = args[3].contents + "\n" + args[4].contents - merged = BracedWord(merged_contents, pos=args[3].pos, end_pos=args[4].end_pos) - script = parser.parse_script(merged) - return args[:3] + [script] # [routine, arg1, arg2, script] - elif len(args) >= 4: - args[2] = parser.parse_script(args[2]) - return args - - -commands = {"LIB_SPF_prepend": lib_spf_prepend} diff --git a/server/src/tools/poco_check.py b/server/src/tools/poco_check.py new file mode 100644 index 0000000..933fb4a --- /dev/null +++ b/server/src/tools/poco_check.py @@ -0,0 +1,53 @@ +from enum import Enum +from tclint.syntax_tree import Visitor +from tclint.violations import Violation + + +class PoCoRule(Enum): + POCO_VALIDATION = "poco-validation" + + def __str__(self): + return self.value + + +class PocoCommandChecker(Visitor): + def __init__(self): + self._violations = [] + + def check(self, _, tree): + self._violations.clear() + tree.accept(self, recurse=True) + return self._violations + + def visit_command(self, command): + name = command.routine.contents + if name not in {"LIB_GE_command_buffer_edit_prepend"}: + return + + if len(command.args) != 4: + self._violations.append( + Violation( + PoCoRule.POCO_VALIDATION, + f"{name}: expected 4 arguments, got {len(command.args)}", + command.pos, + command.end_pos, + ) + ) + return + + script_arg = command.args[2] + if script_arg.contents is None: + self._violations.append( + Violation( + PoCoRule.POCO_VALIDATION, + f"{name}: third argument must be a braced script", + script_arg.pos, + script_arg.end_pos, + ) + ) + + +def get_checkers(): + checkers = (PocoCommandChecker(),) + + return checkers