From 64fa802cd6e648a2826977238f8e6176768186cf Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Jul 2025 18:41:20 +0200 Subject: [PATCH 01/10] update parser --- server/src/tools/lexer.py | 234 ++++++++++++++++++++++++++++++++ server/src/tools/parser.py | 9 ++ server/src/tools/syntax_tree.py | 59 ++++++++ 3 files changed, 302 insertions(+) create mode 100644 server/src/tools/lexer.py create mode 100644 server/src/tools/parser.py create mode 100644 server/src/tools/syntax_tree.py diff --git a/server/src/tools/lexer.py b/server/src/tools/lexer.py new file mode 100644 index 0000000..1375c4b --- /dev/null +++ b/server/src/tools/lexer.py @@ -0,0 +1,234 @@ +from enum import Enum +import ply.lex as lex +from typing import Tuple + + +class Tok(str, Enum): + TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" + TOK_BACKSLASH_SUB = "BACKSLASH_SUB" + TOK_NEWLINE = "NEWLINE" + TOK_SEMI = "SEMI" + TOK_WS = "WS" + TOK_QUOTE = "QUOTE" + TOK_ARG_EXPANSION = "ARG_EXPANSION" + TOK_LBRACE = "LBRACE" + TOK_RBRACE = "RBRACE" + TOK_STAR = "STAR" + TOK_LBRACKET = "LBRACKET" + TOK_RBRACKET = "RBRACKET" + TOK_DOLLAR = "DOLLAR" + TOK_LPAREN = "LPAREN" + TOK_RPAREN = "RPAREN" + TOK_HASH = "HASH" + TOK_ALPHA_CHARS = "ALPHA_CHARS" + TOK_NUM_CHARS = "NUM_CHARS" + TOK_NAMESPACE_SEP = "NAMESPACE_SEP" + TOK_CHAR = "CHAR" + TOK_CONTENTS = "CONTENTS" + + +STATE_BRACEDWORD = "bracedword" +TOK_EOF = None + + +class TclSyntaxError(Exception): + def __init__(self, message, start: Tuple[int, int], end: Tuple[int, int]): + super().__init__(message) + self.start = start + self.end = end + + +class _LexTable: + tokens = tuple(t.value for t in Tok) + + states = ((STATE_BRACEDWORD, "exclusive"),) + + def __init__(self): + self.lexer = lex.lex(object=self) + self.lexer.lineno = 1 + self.lexer.colno = 1 + + def new_lexer(self, pos=None): + lexer = self.lexer.clone() + lexer.lineno = 1 + lexer.colno = 1 + + if pos is not None: + line, col = pos + lexer.lineno = line + lexer.colno = col + + return lexer + + def _tok(self, t): + pos = (t.lexer.lineno, t.lexer.colno) + t.lexer.lineno += t.value.count("\n") + index = t.value.rfind("\n") + if index == -1: + t.lexer.colno += len(t.value) + else: + remaining = t.value[index + 1 :] + t.lexer.colno = len(remaining) + 1 + + t.value = (t.value, pos) + return t + + # Priority important + def t_bracedword_INITIAL_BACKSLASH_NEWLINE(self, t): + r"\\\n" + return self._tok(t) + + # Priority important + def t_bracedword_INITIAL_BACKSLASH_SUB(self, t): + r"\\." + return self._tok(t) + + def t_NEWLINE(self, t): + r"\n" + return self._tok(t) + + def t_SEMI(self, t): + r";" + return self._tok(t) + + # TODO: should use \s? + def t_WS(self, t): + r"[\t\v\f\r ]+" + return self._tok(t) + + def t_QUOTE(self, t): + r'"' + return self._tok(t) + + # Must be higher priority than LBRACE + def t_ARG_EXPANSION(self, t): + r"\{\*\}" + return self._tok(t) + + def t_bracedword_INITIAL_LBRACE(self, t): + r"\{" + return self._tok(t) + + def t_bracedword_INITIAL_RBRACE(self, t): + r"\}" + return self._tok(t) + + def t_STAR(self, t): + r"\*" + return self._tok(t) + + def t_LBRACKET(self, t): + r"\[" + return self._tok(t) + + def t_RBRACKET(self, t): + r"\]" + return self._tok(t) + + def t_DOLLAR(self, t): + r"\$" + return self._tok(t) + + def t_LPAREN(self, t): + r"\(" + return self._tok(t) + + def t_RPAREN(self, t): + r"\)" + return self._tok(t) + + def t_HASH(self, t): + r"\#" + return self._tok(t) + + # Valid non-numeric chars in variable names + def t_ALPHA_CHARS(self, t): + r"[A-Za-z_]+" + return self._tok(t) + + def t_NUM_CHARS(self, t): + r"[0-9]+" + return self._tok(t) + + def t_NAMESPACE_SEP(self, t): + r"::+" + return self._tok(t) + + def t_bracedword_CONTENTS(self, t): + r"[^{}\\]+" + return self._tok(t) + + # Catch-all. TODO: inefficient, should probably munch multiple chars + def t_CHAR(self, t): + r"." + return self._tok(t) + + # Error handling rule + # TODO: do we need this? since we have a catch-all... + # there is a warning + def t_bracedword_INITIAL_error(self, t): + print("Illegal character '%s'" % t.value[0]) + t.lexer.skip(1) + + +# Calling `lex.lex()` performs an expensive reflection process to generate the lexer. +# This singleton class holds a preinitialized lexer that can then be cloned to create +# individual instances. +LexTable = _LexTable() + + +class Lexer: + def __init__(self, pos=None): + self.lexer = LexTable.new_lexer(pos) + self.current = None + + def input(self, text): + self.lexer.input(text) + self.current = self.lexer.token() + + def type(self): + if self.current is None: + return TOK_EOF + return self.current.type + + def value(self): + if self.current is None: + return None + return self.current.value[0] + + def pos(self): + if self.current is None: + return (self.lexer.lineno, self.lexer.colno) + return self.current.value[1] + + def next(self): + self.current = self.lexer.token() + + def expect(self, *tokens, message, pos): + if self.type() not in tokens: + self.next() # munch another token to update position + raise TclSyntaxError(message, pos, self.pos()) + + self.next() + + def assert_(self, *tokens): + assert self.current.type in tokens + self.next() + + +def dump_tokens(code): + lx = Lexer() + lx.input(code) + out = [] + while lx.type() is not TOK_EOF: + out.append((lx.type(), lx.value(), lx.pos())) + lx.next() + return out + + +if __name__ == "__main__": + code = ( + "set a 1\nputs $a\nnamespace eval test {}\n proc myProc {arg1 {optArg 10}} {}" + ) + for ttype, val, (ln, col) in dump_tokens(code): + print(f"{ttype:<18} {val!r:<10} @ ({ln},{col})") diff --git a/server/src/tools/parser.py b/server/src/tools/parser.py new file mode 100644 index 0000000..3a5d5b9 --- /dev/null +++ b/server/src/tools/parser.py @@ -0,0 +1,9 @@ +class _Word: + def __init__(self): + self.segements = [] + self.current_segment = "" + self.current_start = None + + def add_tok(self, tok): + if self.current_start is None: + self.current_start = tok.value[1] diff --git a/server/src/tools/syntax_tree.py b/server/src/tools/syntax_tree.py new file mode 100644 index 0000000..d77de03 --- /dev/null +++ b/server/src/tools/syntax_tree.py @@ -0,0 +1,59 @@ +"""Classes for representing and interacting with Tcl syntax trees.""" + + +class Visitor: + """Abstract base class for Visitors that operate on syntax tree.""" + + def visit_script(self, script): + pass + + def visit_comment(self, comment): + pass + + def visit_command(self, command): + pass + + def visit_command_sub(self, command_sub): + pass + + def visit_bare_word(self, word): + pass + + def visit_braced_word(self, word): + pass + + def visit_quoted_word(self, word): + pass + + def visit_compound_bare_word(self, word): + pass + + def visit_var_sub(self, var_sub): + pass + + def visit_arg_expansion(self, arg_expansion): + pass + + def visit_list(self, list): + pass + + def visit_expression(self, expression): + pass + + def visit_braced_expression(self, expression): + pass + + def visit_paren_expression(self, expression): + pass + + def visit_unary_op(self, unary_op): + pass + + def visit_binary_op(self, binary_op): + pass + + def visit_ternary_op(self, ternary_op): + pass + + def visit_function(self, function): + pass -- 2.54.0 From c9a07ae1b926393792ac4745176a14af37e3bfd2 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 23 Jul 2025 17:12:24 +0200 Subject: [PATCH 02/10] use tclint --- server/src/tools/checks.py | 240 ++++++++ server/src/tools/commands/__init__.py | 37 ++ server/src/tools/parser.py | 847 +++++++++++++++++++++++++- server/src/tools/plugins.py | 81 +++ server/src/tools/schema.py | 35 ++ server/src/tools/syntax_tree.py | 379 ++++++++++++ 6 files changed, 1618 insertions(+), 1 deletion(-) create mode 100644 server/src/tools/checks.py create mode 100644 server/src/tools/commands/__init__.py create mode 100644 server/src/tools/plugins.py create mode 100644 server/src/tools/schema.py diff --git a/server/src/tools/checks.py b/server/src/tools/checks.py new file mode 100644 index 0000000..08be911 --- /dev/null +++ b/server/src/tools/checks.py @@ -0,0 +1,240 @@ +"""Helpers for checking command arguments.""" + +from collections.abc import Callable +from typing import List, Optional, Union + +from syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node + + +class CommandArgError(Exception): + pass + + +def arg_count(args, parser): + # TODO: graceful handling of argsub going into things with recursive parsing. + # if the argsub happens to be "concrete", we can technically do the right + # thing (although this should probably be flagged as a readability issue...) + # otherwise, we should flag that the non-concrete argsub is not okay for + # these cases. however, I think its not okay-ness doesn't need to be absolute, e.g. + # I think we could allow: + # + # catch {puts "my script"} {*}$catchopts + # + + arg_count = 0 + has_arg_expansion = False + for arg in args: + if isinstance(arg, ArgExpansion): + if arg.contents is None: + has_arg_expansion = True + continue + arg_count += len(parser.parse_list(arg.contents)) + else: + arg_count += 1 + + return arg_count, has_arg_expansion + + +def check_count(command, min=None, max=None, args_name="args"): + def check(args, parser): + if min is None and max is None: + return None + + count, has_arg_expansion = arg_count(args, parser) + + if not has_arg_expansion and min == max and count != min: + raise CommandArgError( + f"wrong # of {args_name} for {command}: got {count}, expected {min}" + ) + + if not has_arg_expansion and min is not None and count < min: + raise CommandArgError( + f"not enough {args_name} for {command}: got {count}, expected at least" + f" {min}" + ) + + if max is not None and count > max: + raise CommandArgError( + f"too many {args_name} for {command}: got {count}, expected no more" + f" than {max}" + ) + + return None + + return check + + +def eval(args, parser, command): + if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args): + # Slightly odd restriction, but our syntax tree doesn't have a great way + # to handle this case. We require each command argument to correspond to + # one child node, but multiple quoted or braced word arguments can be + # combined into a single subcommand when interpreted eval-style. This + # requirement exists to facilitate style checking, if we had a separate + # CST for style checks and AST for logical checks we may be able to + # handle it. + + raise CommandArgError( + f"unable to parse multiple {command} arguments when one includes a braced" + " or quoted word" + ) + + # Construct the body of the eval taking whitespace into account to ensure we get + # style checking. + + eval_script = "" + prev_arg_end_pos = None + for arg in args: + contents = arg.contents + if contents is None: + # TODO: flag sort of eval-specific violation? Common patterns will + # often trigger this, and it seems useful to be able to turn it off + raise CommandArgError( + f"{command} received an argument with a substitution, unable to parse" + " its arguments" + ) + + if prev_arg_end_pos is not None: + if prev_arg_end_pos[0] != arg.line: + # If we have multiple args on the same line, we know there must be a + # backslash newline. Add it so the parsing works. + eval_script += "\\\n" * (arg.line - prev_arg_end_pos[0]) + eval_script += " " * (arg.col - 1) + else: + eval_script += " " * (arg.col - prev_arg_end_pos[1]) + eval_script += contents + + prev_arg_end_pos = arg.end_pos + + script = parser.parse(eval_script, pos=(args[0].pos)) + script.end_pos = args[-1].end_pos + + return [script] + + +def check_command( + command: str, args: List[Node], parser, command_spec: Union[Callable, dict, None] +) -> Optional[List[Node]]: + if command_spec is None: + return None + + if isinstance(command_spec, dict): + return check_arg_spec(command, args, parser, command_spec) + + return command_spec(args, parser) + + +def check_arg_spec( + command: str, args: List[Node], parser, arg_spec: dict +) -> Optional[List[Node]]: + if "subcommands" in arg_spec: + subcommands = arg_spec["subcommands"] + try: + subcommand = args[0].contents + except IndexError: + subcommand = None + + if subcommand in subcommands: + new_args = check_command( + f"{command} {subcommand}", args[1:], parser, subcommands[subcommand] + ) + if new_args is None: + return new_args + return args[0:1] + new_args + + if "" in subcommands: + return check_command(command, args, parser, subcommands[""]) + + if subcommand is not None: + msg = f"invalid subcommand for {command}: got {subcommand}" + else: + msg = f"no subcommand provided for {command}" + + raise CommandArgError(f"{msg}, expected one of {', '.join(subcommands.keys())}") + + switches = arg_spec["switches"] + args_allowed = set(switches) + args_required = {switch for switch in switches if switches[switch]["required"]} + positional_args = [] + + args = list(args) + while len(args) > 0: + arg = args.pop(0) + + # To facilitate better error messages, we expect that switches are always + # specified as BareWords that start with "-" or ">". This lets us throw an + # error when a switch-like thing doesn't match any supported arguments, + # rather than counting it towards the positional arguments (which usually + # ends up in a vague "too many arguments" error). To make tclint interpret a + # switch-like word as a positional argument, users should wrap it in "", and + # any switches should be BareWords. + contents = arg.contents + if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}): + positional_args.append(arg) + continue + + # TODO check required arguments + if contents in args_allowed: + if switches[contents]["value"]: + try: + args.pop(0) + except IndexError: + raise CommandArgError( + f"invalid arguments for {command}: expected value after" + f" {contents}" + ) + if not switches[contents]["repeated"]: + args_allowed.remove(contents) + if contents in args_required: + args_required.remove(contents) + elif contents in arg_spec: + raise CommandArgError(f"duplicate argument for {command}: {contents}") + else: + prefix_matches = [] + for switch in switches: + if switch.startswith(contents): + prefix_matches.append(switch) + + if len(prefix_matches) == 1: + raise CommandArgError( + f"shortened argument for {command}: expand {contents} to" + f" {prefix_matches[0]}" + ) + + if len(prefix_matches) > 1: + raise CommandArgError( + f"ambiguous argument for {command}: {contents} could be any of" + f" {', '.join(prefix_matches)}" + ) + + raise CommandArgError(f"unrecognized argument for {command}: {contents}") + + if len(args_required) > 1: + raise CommandArgError( + f"missing required arguments for {command}: {', '.join(args_required)}" + ) + elif len(args_required) == 1: + raise CommandArgError( + f"missing required argument for {command}: {args_required.pop()}" + ) + + min_positionals = 0 + max_positionals: Optional[int] = 0 + for positional in arg_spec["positionals"]: + if positional["value"]["type"] == "variadic": + max_positionals = None + + if positional["required"]: + min_positionals += 1 + if max_positionals is not None: + max_positionals += 1 + + check = check_count( + command, + min=min_positionals, + max=max_positionals, + args_name="positional args", + ) + check(positional_args, None) + + return None diff --git a/server/src/tools/commands/__init__.py b/server/src/tools/commands/__init__.py new file mode 100644 index 0000000..1202114 --- /dev/null +++ b/server/src/tools/commands/__init__.py @@ -0,0 +1,37 @@ +import pathlib +from typing import List, Dict, Union + +from tools.commands import builtin as _builtin +from tools.commands.plugins import PluginManager + +# import to expose in package +from tools.commands.checks import CommandArgError + +__all__ = ["CommandArgError", "validate_command_plugins", "get_commands"] + + +def validate_command_plugins(plugins: List[str]) -> List[str]: + valid_plugins = [] + for plugin in set(plugins): + if PluginManager.load(plugin) is not None: + valid_plugins.append(plugin) + + return valid_plugins + + +def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict: + commands = {} + commands.update(_builtin.commands) + + for plugin in plugins: + if isinstance(plugin, str): + plugin_commands = PluginManager.load(plugin) + elif isinstance(plugin, pathlib.Path): + plugin_commands = PluginManager.load_from_spec(plugin) + else: + raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}") + + if plugin_commands is not None: + commands.update(plugin_commands) + + return commands diff --git a/server/src/tools/parser.py b/server/src/tools/parser.py index 3a5d5b9..abf6553 100644 --- a/server/src/tools/parser.py +++ b/server/src/tools/parser.py @@ -1,9 +1,854 @@ +from tools.lexer import Lexer, TclSyntaxError, Tok, TOK_EOF +from tools import syntax_tree as st +from tools.commands import CommandArgError, get_commands +from tools.checks import check_command + + +def _strip_ws(parse_func): + """Decorator used by expression parser for stripping whitespace around a node.""" + + def func(parser, ts): + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: + ts.next() + + node = parse_func(parser, ts) + + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: + ts.next() + + return node + + return func + + class _Word: def __init__(self): - self.segements = [] + self.segments = [] self.current_segment = "" self.current_start = None def add_tok(self, tok): if self.current_start is None: self.current_start = tok.value[1] + + def add_node(self, node): + if self.current_segment != "": + self.segments.append( + st.BareWord( + self.current_segment, pos=self.current_start, end_pos=node.pos + ) + ) + self.current_segment = "" + self.current_start = None + self.segments.append(node) + + def resolve(self, end_pos): + if self.current_segment: + self.segments.append( + st.BareWord( + self.current_segment, pos=self.current_start, end_pos=end_pos + ) + ) + + return self.segments + + +class Parser: + def __init__(self, debug=False, command_plugins=None): + self._debug = debug + self._debug_indent = 0 + self.violations = [] + if command_plugins is None: + command_plugins = [] + self._commands = get_commands(command_plugins) + + def debug(self, *msg): + if self._debug: + print(" " * self._debug_indent, end="") + print(*msg) + + def parse(self, script, pos=None): + lexer = Lexer(pos=pos) + lexer.input(script) + tree = self._parse_script(lexer, in_command_sub=False) + assert lexer.type() == TOK_EOF, ( + "Didn't reach EOF parsing script, please file a bug report." + ) + + return tree + + def _parse_command_args(self, routine, args): + """Since many built-in Tcl commands take in Tcl scripts or expressions + as arguments, building a complete parse tree requires checking command + names and possibly parsing their arguments. + + The node of any argument that gets parsed by this method is replaced with + the parse tree of that argument. Since this process requires checking + the arguments provided to these commands, this method may report lint + violations. + + This parsing process is analogous to how the Tcl interpreter interprets + scripts, and better handles weird edge cases compared to a traditional + parsing technique. For example, this may look like valid Tcl: + + proc foo {a} { + # output } + puts "}" + } + + But really, it is invalid since the } in the comment terminates the body + of the proc - Tcl blindly constructs the body of the proc until it + reaches the first }. tclint handles this correctly. + """ + if routine not in self._commands: + return args + + spec = self._commands[routine] + + try: + new_args = check_command(routine, args, self, spec) + except TclSyntaxError as e: + raise e + except CommandArgError as e: + raise e + except Exception: + if self._debug: + raise + raise CommandArgError( + f"error parsing command arguments, possibly malformed {routine} command" + ) + + if new_args is None: + return args + + return new_args + + def parse_script(self, node): + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as script" + ) + + script = self.parse(node.contents, pos=node.contents_pos) + if isinstance(node, BracedWord): + script.braced = True + + script.line = node.line + script.col = node.col + script.end_pos = node.end_pos + + return script + + def _parse_script(self, ts, in_command_sub): + self.debug(f"parse_script({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + if in_command_sub: + script = CommandSub(pos=pos) + else: + script = Script(pos=pos) + + while ts.type() is not TOK_EOF: + if ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + # strip whitespace at start of command + ts.next() + continue + + if ts.type() == TOK_HASH: + script.add(self.parse_comment(ts)) + else: + cmd = self.parse_command(ts, in_command_sub=in_command_sub) + if cmd is not None: + script.add(cmd) + + # when in command sub mode, a script is terminated by ] + if in_command_sub and ts.type() == TOK_RBRACKET: + return script + + ts.expect( + TOK_EOF, + TOK_NEWLINE, + TOK_SEMI, + message=f"expected newline or semicolon, got {ts.value()}", + pos=ts.pos(), + ) + + if in_command_sub and ts.type() is TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding end of command substitution", pos, ts.pos() + ) + + self._debug_indent -= 1 + + script.end_pos = ts.pos() + + return script + + def parse_comment(self, ts): + self.debug(f"parse_comment({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_HASH) + + value = "" + while ts.type() not in {TOK_NEWLINE, TOK_EOF}: + value += ts.value() + ts.next() + + # Stripping trailing whitespace from comments here allows tclfmt to clean it up + # without affecting the AST. + value = value.rstrip() + + return Comment(value, pos=pos, end_pos=ts.pos()) + + def parse_command(self, ts, in_command_sub): + self.debug(f"parse_command({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + routine = self.parse_word(ts, in_command_sub) + if routine is None: + self._debug_indent -= 1 + return None + + args = [] + while True: + if ts.type() not in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + break + + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + ts.next() + + word = self.parse_word(ts, in_command_sub) + if word is None: + break + + args.append(word) + + self._debug_indent -= 1 + + try: + parsed_args = self._parse_command_args(routine.contents, args) + except CommandArgError as e: + self.violations.append(Violation(Rule.COMMAND_ARGS, str(e), pos, ts.pos())) + parsed_args = args + + children = [routine, *parsed_args] + # We need to inherit end pos of last child to prevent us from counting + # extra whitespace at end of command, which is important for + # spaces-in-braces check. + return Command(*children, pos=pos, end_pos=children[-1].end_pos) + + def parse_word(self, ts, in_command_sub): + self.debug(f"parse_word({ts.current})") + if ts.type() == TOK_ARG_EXPANSION: + return self.parse_arg_expansion(ts, in_command_sub) + elif ts.type() == TOK_LBRACE: + return self.parse_braced_word(ts) + elif ts.type() == TOK_QUOTE: + return self.parse_quoted_word(ts) + else: + return self.parse_bare_word(ts, in_command_sub) + + def parse_arg_expansion(self, ts, in_command_sub): + self.debug(f"parse_arg_expansion({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_ARG_EXPANSION) + + delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + if in_command_sub: + delimiters.append(TOK_RBRACKET) + + # Arg expansion is just a regular braced word if followed by whitespace, + # or other word boundaries such as semicolon or right bracket + # (in command substitution) + if ts.type() in delimiters: + return BracedWord("*", pos=pos, end_pos=ts.pos()) + + return ArgExpansion( + self.parse_word(ts, in_command_sub), pos=pos, end_pos=ts.pos() + ) + + def parse_quoted_word(self, ts): + self.debug(f"parse_quoted_word({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + ts.assert_(TOK_QUOTE) + + word = _Word() + while ts.type() not in {TOK_QUOTE, TOK_EOF}: + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + word.add_node(var_sub) + else: + word.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + word.add_node(command_sub) + else: + word.add_tok(ts.current) + ts.next() + + res = word.resolve(ts.pos()) + + ts.expect( + TOK_QUOTE, message="reached EOF without finding match for quote", pos=pos + ) + + self._debug_indent -= 1 + + if not res: + res = [] + + return QuotedWord(*res, pos=pos, end_pos=ts.pos()) + + def parse_braced_word(self, ts): + self.debug(f"parse_braced_word({ts.current})") + pos = ts.pos() + + ts.lexer.push_state(STATE_BRACEDWORD) + + ts.assert_(TOK_LBRACE) + + word = "" + # store position for each brace we want to match, facilitating good + # error messages + expected_braces = [pos] + while True: + toktype = ts.type() + if toktype == TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for brace", + expected_braces[-1], + ts.pos(), + ) + + if toktype == TOK_LBRACE: + expected_braces.append(ts.pos()) + elif toktype == TOK_RBRACE: + try: + expected_braces.pop() + except IndexError: + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "found closing brace without matching open brace", start, end + ) + + if len(expected_braces) == 0: + ts.lexer.pop_state() + ts.next() + break + word += ts.value() + ts.next() + + end_pos = ts.pos() + return BracedWord(word, pos=pos, end_pos=end_pos) + + def parse_bare_word(self, ts, in_command_sub): + self.debug(f"parse_bare_word({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + word = _Word() + delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + + # In command sub mode, words are ended by ] + if in_command_sub: + delimiters.append(TOK_RBRACKET) + + while ts.type() not in delimiters: + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + word.add_node(var_sub) + else: + word.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + word.add_node(command_sub) + else: + word.add_tok(ts.current) + ts.next() + + res = word.resolve(ts.pos()) + + self._debug_indent -= 1 + + if not res: + return None + if len(res) == 1: + return res[0] + return CompoundBareWord(*res, pos=pos, end_pos=ts.pos()) + + def parse_var_sub(self, ts): + self.debug(f"parse_var_sub({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_DOLLAR) + + var = "" + if ts.type() == TOK_LBRACE: + brace_pos = ts.pos() + ts.next() + while ts.type() != TOK_RBRACE: + if ts.type() is TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for brace", + brace_pos, + ts.pos(), + ) + var += ts.value() + ts.next() + ts.next() + + return VarSub(var, pos=pos, end_pos=ts.pos(), braced=True) + + while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS, TOK_NAMESPACE_SEP}: + var += ts.value() + ts.next() + + if not var: + return None + + index_nodes = [] + if ts.type() == TOK_LPAREN: + paren_pos = ts.pos() + index = _Word() + ts.next() + while ts.type() != TOK_RPAREN: + if ts.type() == TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for paren", + paren_pos, + ts.pos(), + ) + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + index.add_node(var_sub) + else: + index.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + index.add_node(command_sub) + else: + index.add_tok(ts.current) + ts.next() + + index_nodes = index.resolve(ts.pos()) + ts.next() + + var_sub = VarSub(var, pos=pos, end_pos=ts.pos()) + + for index_segment in index_nodes: + var_sub.add(index_segment) + + return var_sub + + def parse_command_sub(self, ts): + self.debug(f"parse_command_sub({ts.current})") + self._debug_indent += 1 + + pos = ts.pos() + ts.assert_(TOK_LBRACKET) + + script = self._parse_script(ts, in_command_sub=True) + + ts.assert_(TOK_RBRACKET) + end_pos = ts.pos() + + script.line = pos[0] + script.col = pos[1] + script.end_pos = end_pos + + self._debug_indent -= 1 + return script + + def parse_list(self, node): + """Parse contents of node as Tcl list. This is a distinct entry point + that doesn't get used when generating the main syntax tree, but is used + in command-specific argument parsing. + """ + if isinstance(node, List): + return node + + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as list" + ) + + ts = Lexer(pos=node.contents_pos) + ts.input(node.contents) + + DELIMITERS = {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE} + + list_node = List(pos=node.pos, end_pos=node.end_pos) + while ts.type() is not TOK_EOF: + while ts.type() in DELIMITERS: + ts.next() + + if ts.type() is TOK_EOF: + break + + if ts.type() == TOK_LBRACE: + # we can reuse parse_braced_word, since it doesn't use + # substitutions in any case + list_node.add(self.parse_braced_word(ts)) + elif ts.type() == TOK_QUOTE: + quote_word_pos = ts.pos() + + ts.assert_(TOK_QUOTE) + + bare_word_pos = ts.pos() + contents = "" + while ts.type() not in {TOK_QUOTE, TOK_EOF}: + contents += ts.value() + ts.next() + word = BareWord(contents, pos=bare_word_pos, end_pos=ts.pos()) + + ts.expect( + TOK_QUOTE, + message="reached EOF without finding match for quote", + pos=quote_word_pos, + ) + + list_node.add(QuotedWord(word, pos=quote_word_pos, end_pos=ts.pos())) + else: + pos = ts.pos() + contents = "" + while ts.type() not in {*DELIMITERS, TOK_EOF}: + contents += ts.value() + ts.next() + list_node.add(BareWord(contents, pos=pos, end_pos=ts.pos())) + + return list_node + + def parse_expression(self, node): + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as expr" + ) + + ts = Lexer(pos=node.contents_pos) + ts.input(node.contents) + + contents = self._parse_expression(ts) + ts.expect( + TOK_EOF, + message=f"expected end of expression, got {ts.value()}", + pos=ts.pos(), + ) + if isinstance(node, BracedWord): + return BracedExpression(contents, pos=node.pos, end_pos=node.end_pos) + + return Expression(contents, pos=node.pos, end_pos=node.end_pos) + + @_strip_ws + def _parse_expression(self, ts): + op1 = self._parse_operand(ts) + expr = op1 + + # last condition is hack to break out of expression in case we're in ternary op + if ts.type() not in {TOK_EOF, TOK_RPAREN} and ts.value() not in {":", ","}: + if ts.value() == "?": + # weird hack to record operator + start = ts.pos() + ts.next() + q = BareWord("?", pos=start, end_pos=ts.pos()) + + op2 = self._parse_expression(ts) + if ts.value() != ":": + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "expected ':' to continue ternary expression", start, end + ) + + # weird hack again + start = ts.pos() + ts.next() + colon = BareWord(":", pos=start, end_pos=ts.pos()) + + op3 = self._parse_expression(ts) + expr = TernaryOp( + op1, q, op2, colon, op3, pos=op1.pos, end_pos=op3.end_pos + ) + else: + operator = self._parse_operator(ts) + op2 = self._parse_expression(ts) + expr = BinaryOp(op1, operator, op2, pos=op1.pos, end_pos=op2.end_pos) + + if ts.type() != TOK_RPAREN and ts.value() not in {":", ","}: + ts.expect(TOK_EOF, message="expected end of expression", pos=ts.pos()) + + return expr + + @_strip_ws + def _parse_operand(self, ts): + if ts.type() == TOK_DOLLAR: + return self.parse_var_sub(ts) + if ts.type() == TOK_QUOTE: + return self.parse_quoted_word(ts) + if ts.type() == TOK_LBRACE: + return self.parse_braced_word(ts) + if ts.type() == TOK_LBRACKET: + return self.parse_command_sub(ts) + if ts.type() == TOK_LPAREN: + start = ts.pos() + ts.next() + expr = self._parse_expression(ts) + ts.expect( + TOK_RPAREN, + message="reached EOF without finding match for paren", + pos=expr.pos, + ) + end = ts.pos() + return ParenExpression(expr, start, end) + if ts.value() in {"-", "+", "~", "!"}: + operator_val = ts.value() + operator_pos = ts.pos() + ts.next() + operator = BareWord(operator_val, pos=operator_pos, end_pos=ts.pos()) + operand = self._parse_operand(ts) + # Since _parse_operand() munches whitespace after the operand, we + # set the end of the UnaryOp to the end of the operand rather than + # ts.pos(). Otherwise, the bounds of the UnaryOp would include all + # that whitespace. + return UnaryOp(operator, operand, pos=operator_pos, end_pos=operand.end_pos) + + # If none of these, collect tokens that may comprise an operand + operand = "" + operand_pos = ts.pos() + + # First, we want to check for numeric operands (either ints or numeric + # floats) by consuming tokens as long as they comprise the prefix of a + # numeric operand + while ts.type() != TOK_EOF and ( + _is_int_prefix(operand + ts.value()) + or _is_float_prefix(operand + ts.value()) + ): + operand += ts.value() + ts.next() + + # Next, we check if we've consumed an entire numeric literal. If so, we + # move on. If not, we keep consuming tokens that may correspond to a + # valid bareword (pretty much just alphanumeric chars). + if not (_is_int_literal(operand) or _is_float_literal(operand)): + while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS}: + operand += ts.value() + ts.next() + + # The above method is a little hacky. Note that it doesn't parse things + # exactly the same as Tcl. E.g. if a script includes `expr {1foo}`, + # tclint will report an invalid operator "foo", whereas tclsh will + # report an invalid bareword "1foo". Despite reporting them differently + # both tools should still catch the same syntax errors, since there are + # no legal barewords that begin with a numeric literal prefix, and tclsh + # will stop parsing numeric operands if they're actually followed by a + # legal operator (e.g. `expr {1eq1}` will be handled properly). + + is_func = _is_function(operand) + + if not ( + _is_int_literal(operand) + or _is_float_literal(operand) + or _is_bool_literal(operand) + or is_func + ): + raise TclSyntaxError( + f"invalid bareword in expression: {operand}", operand_pos, ts.pos() + ) + + node = BareWord(operand, pos=operand_pos, end_pos=ts.pos()) + + if is_func: + node = self._parse_function(ts, node) + + return node + + def _parse_operator(self, ts): + pos = ts.pos() + + # hacky logic to handle parsing legal operators + + if ts.value() in {"*", "&", "|"}: + # one or two of these characters are legal operators + operator = ts.value() + ts.next() + if ts.value() == operator: + operator += ts.value() + ts.next() + elif ts.value() in {"<", ">"}: + operator = ts.value() + ts.next() + if ts.value() in {operator, "="}: + operator += ts.value() + ts.next() + elif ts.value() in {"=", "!"}: + operator = ts.value() + ts.next() + if ts.value() != "=": + raise TclSyntaxError( + f"invalid operator in expression: {operator}", pos, ts.pos() + ) + operator += ts.value() + ts.next() + elif ts.value() in {"*", "/", "%", "+", "-", "^", "eq", "ne", "in", "ni"}: + operator = ts.value() + ts.next() + else: + raise TclSyntaxError( + f"invalid operator in expression: {ts.value()}", pos, ts.pos() + ) + + return BareWord(operator, pos=pos, end_pos=ts.pos()) + + def _parse_function(self, ts, name): + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + ts.next() + + ts.expect( + TOK_LPAREN, + message="expected open paren after function name", + pos=name.pos, + ) + + delims = {Tok.TOK_RPAREN, TOK_EOF} + + arguments = [] + if ts.type() not in delims: + arguments.append(self._parse_expression(ts)) + + while ts.type() not in delims: + if ts.value() != ",": + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "expected comma between function arguments", start, end + ) + ts.next() + + arguments.append(self._parse_expression(ts)) + + ts.expect( + Tok.TOK_RPAREN, + message="expected close paren after function arguments", + pos=name.pos, + ) + return st.Function(name, *arguments, pos=name.pos, end_pos=ts.pos()) + + +def _all(_list, non_empty=False): + """Like all(), but if non_empty is True, list must also have at least 1 element.""" + if non_empty and len(_list) == 0: + return False + return all(_list) + + +def _is_int(operand, full=False): + """Returns whether operand is a valid Tcl integer literal. + + If full is False, will also return True if operand is the prefix of an + integer literal. An empty string is not a valid full literal, but is a + valid prefix. + """ + # prefixes + if operand.startswith("0b"): + return _all([digit in "01" for digit in operand[2:]], non_empty=full) + if operand.startswith("0o"): + return _all( + [digit in string.octdigits for digit in operand[2:]], non_empty=full + ) + if operand.startswith("0x"): + return _all( + [digit in string.hexdigits for digit in operand[2:]], non_empty=full + ) + if operand.startswith("0"): + # fun fact: apparently a lone 0 prefix is interpreted as octal + return _all( + [digit in string.octdigits for digit in operand[1:]], non_empty=full + ) + + return _all([digit in string.digits for digit in operand], non_empty=full) + + +def _is_int_literal(operand): + return _is_int(operand, full=True) + + +def _is_int_prefix(operand): + return _is_int(operand, full=False) + + +def _is_float_literal(operand): + if operand.lower() in {"nan", "inf"}: + return True + + return ( + operand != "" and re.fullmatch(r"\d*\.?\d*([Ee][+-]?\d+)?", operand) is not None + ) + + +def _is_float_prefix(operand): + """Returns whether operand is the prefix of a valid numeric float literal.""" + return re.fullmatch(r"\d*\.?\d*([Ee][+-]?)?\d*", operand) is not None + + +def _is_bool_literal(operand): + return operand in {"false", "no", "off", "true", "yes", "on"} + + +# map of function names to # arguments accepted +# None indicates 1 or more arguments +# TODO: use these values in an actual separate check. they might want to live elsewhere +_FUNCTIONS = { + "abs": 1, + "acos": 1, + "asin": 1, + "atan": 1, + "atan2": 2, + "bool": 1, + "ceil": 1, + "cos": 1, + "cosh": 1, + "double": 1, + "entier": 1, + "exp": 1, + "floor": 1, + "fmod": 2, + "hypot": 2, + "int": 1, + "isqrt": 1, + "log": 1, + "log10": 1, + "max": None, + "min": None, + "pow": 2, + "rand": 0, + "round": 1, + "sin": 1, + "sinh": 1, + "sqrt": 1, + "srand": 1, + "tan": 1, + "tanh": 1, + "wide": 1, +} + + +def _is_function(operand): + return operand in _FUNCTIONS.keys() diff --git a/server/src/tools/plugins.py b/server/src/tools/plugins.py new file mode 100644 index 0000000..927e874 --- /dev/null +++ b/server/src/tools/plugins.py @@ -0,0 +1,81 @@ +from importlib.metadata import entry_points +import json +import pathlib +from typing import Dict, Optional + + +class _PluginManager: + def __init__(self): + self._loaded = {} + self._installed = {} + self._loaded_specs = {} + for plugin in entry_points(group="tclint.plugins"): + if plugin.name in self._installed: + print(f"Warning: found duplicate definitions for plugin {plugin.name}") + self._installed[plugin.name] = plugin + + def load(self, name: str) -> Optional[Dict]: + if name in self._loaded: + return self._loaded[name] + + mod = self._load(name) + self._loaded[name] = mod + return mod + + def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + if path in self._loaded_specs: + return self._loaded_specs[path] + + spec = self._load_from_spec(path) + self._loaded_specs[path] = spec + return spec + + def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + try: + with open(path.expanduser(), "r") as f: + spec = json.load(f) + except (FileNotFoundError, RuntimeError): + print(f"Warning: command spec {path} not found, skipping...") + return None + + try: + # Apply defaults and validate the spec. + spec = command_schema(spec) + except voluptuous.Invalid as e: + print(f"Warning: invalid command spec {path}: {e}") + return None + + return spec["commands"] + + def get_mod(self, name: str) -> Optional[ModuleType]: + if name not in self._installed: + print(f"Warning: plugin {name} is not installed") + return None + + plugin = self._installed[name] + + try: + module = plugin.load() + except Exception as e: + print(f"Warning: error loading plugin {name}: {e}") + return None + + return module + + def _load(self, name: str): + module = self.get_mod(name) + if module is None: + print(f"Skipping requested plugin {name}") + return None + + if not hasattr(module, "commands"): + print(f"Warning: skipping plugin {name} since it does not define commands") + return None + + return getattr(module, "commands") + + +# TODO: we'll probably want to construct this in the tclint entry point and pass +# it around rather than using a singleton instance, but this made for an easier +# refactor. +PluginManager = _PluginManager() diff --git a/server/src/tools/schema.py b/server/src/tools/schema.py new file mode 100644 index 0000000..7662a10 --- /dev/null +++ b/server/src/tools/schema.py @@ -0,0 +1,35 @@ +from collections.abc import Callable +from voluptuous import Schema, Optional, Or, Self + +# Need to define this as a Schema with required=True to ensure that this requirement +# persists through the Or in the main schema definition. +_command_args = Schema( + { + Optional("positionals", default=[]): [ + { + "name": str, + "required": bool, + "value": Or({"type": "any"}, {"type": "variadic"}), + } + ], + Optional("switches", default={}): { + Optional(str): { + "required": bool, + "repeated": bool, + "value": Or({"type": "any"}, None), + Optional("metavar"): str, + } + }, + }, + required=True, +) + +commands_schema = Schema( + {Optional(str): Or(_command_args, None, {"subcommands": Self}, Callable)}, + required=True, +) + +schema = Schema( + {"name": str, "commands": commands_schema}, + required=True, +) diff --git a/server/src/tools/syntax_tree.py b/server/src/tools/syntax_tree.py index d77de03..97f646c 100644 --- a/server/src/tools/syntax_tree.py +++ b/server/src/tools/syntax_tree.py @@ -57,3 +57,382 @@ class Visitor: def visit_function(self, function): pass + + +class Node: + """ + Invariants: + - self.value is some sort of base Python type + - self.children is a list of Node types + """ + + def __init__(self, *init, pos=None, end_pos=None): + """pos: line, column of first character of parsed region (1-indexed) + end_pos: line, column of first character after parsed region (1-indexed) + """ + self.line = None + self.col = None + if pos is not None: + self.line, self.col = pos + self.end_pos = end_pos + + self.value = None + if len(init) > 0 and not isinstance(init[0], Node): + self.value = init[0] + init = init[1:] + + if not all(isinstance(v, Node) for v in init): + raise TypeError("Children must be Node instances") + + self.children = list(init) + + def add(self, node): + self.children.append(node) + + @property + def contents(self): + """This is overloaded by word Nodes that may have concrete contents. + + TODO: I prefer the name value, but that's currently taken... + """ + return None + + @property + def contents_pos(self): + """This is overloaded by word Nodes that may have concrete contents. + + Returns the position at which the contents start. + + TODO: consider combining with with `contents`? + """ + return None + + def _pos_str(self): + start_pos_str = "?" + if self.pos is not None: + start_pos_str = f"{self.pos[0]}:{self.pos[1]}" + end_pos_str = "?" + if self.end_pos is not None: + end_pos_str = f"{self.end_pos[0]}:{self.end_pos[1]}" + + return f" # {start_pos_str}-{end_pos_str}" + + def _make_str(self, indent=None, positions=False): + if indent is not None: + s = " " * indent + else: + s = "" + + s += self.__class__.__name__ + s += "(" + + if self.value: + s += repr(self.value) + if self.children: + s += ", " + + if positions and self.children: + s += self._pos_str() + + for i, child in enumerate(self.children): + if indent is not None: + s += "\n" + s += child._make_str( + indent=None if indent is None else indent + 1, positions=positions + ) + if i < len(self.children) - 1: + s += ", " + s += ")" + + if positions and not self.children: + s += self._pos_str() + + return s + + def pretty(self, positions=False): + return self._make_str(indent=0, positions=positions) + + def __str__(self): + return self._make_str() + + def __eq__(self, other): + if type(self) is not type(other): + return False + + if self.value != other.value: + return False + + if len(self.children) != len(other.children): + return False + for my_child, other_child in zip(self.children, other.children): + if my_child != other_child: + return False + + return True + + def diff(self, other, indent_depth=0): + lines = [] + indent = " " * indent_depth + + my_cls = self.__class__.__name__ + other_cls = other.__class__.__name__ + + if my_cls != other_cls: + lines += [f"{indent}-{my_cls}("] + lines += [f"{indent}+{other_cls}("] + return lines + + if self.value != other.value: + lines += [f'{indent}-{my_cls}("{self.value}"'] + lines += [f'{indent}+{other_cls}("{other.value}"'] + return lines + + if len(self.children) != len(other.children): + my_children = ",".join( + [child.__class__.__name__ for child in self.children] + ) + other_children = ",".join( + [child.__class__.__name__ for child in other.children] + ) + + lines += [f"{indent}-{my_cls}({my_children})"] + lines += [f"{indent}+{other_cls}({other_children})"] + return lines + + if self.value is not None: + lines += [f"{indent}{my_cls}({self.value}"] + else: + lines += [f"{indent}{my_cls}("] + + for my_child, other_child in zip(self.children, other.children): + lines += my_child.diff(other_child, indent_depth=indent_depth + 1) + + lines += [f"{indent})"] + + return lines + + @property + def pos(self): + if self.line is None or self.col is None: + return None + + return (self.line, self.col) + + def _recurse(self, visitor): + for child in self.children: + child.accept(visitor, recurse=True) + + +class Script(Node): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # hack for spaces-in-braces check + self.braced = False + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_script(self) + + +class Comment(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_comment(self) + + +class Command(Node): + def __init__(self, routine, *args, pos=None, end_pos=None): + self.routine = routine + self.args = args + super().__init__(routine, *args, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_command(self) + + +class CommandSub(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_command_sub(self) + + +class BareWord(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_bare_word(self) + + @property + def contents(self): + return self.value + + @property + def contents_pos(self): + return self.pos + + +class BracedWord(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_braced_word(self) + + @property + def contents(self): + return self.value + + @property + def contents_pos(self): + return (self.line, self.col + 1) + + +class QuotedWord(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_quoted_word(self) + + @property + def contents(self): + """A QuotedWord with concrete contents is put in the tree as + QuotedWord(BareWord()), so return contents of its child.""" + if len(self.children) > 1: + return None + if len(self.children) == 0: + # weird special case to handle blank quoted string ("") - it doesn't + # make sense to put in a child, but setting/returning the value is + # also inconsistent + return "" + + return self.children[0].contents + + @property + def contents_pos(self): + if self.contents is None: + return None + return (self.line, self.col + 1) + + +class CompoundBareWord(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_compound_bare_word(self) + + +class VarSub(Node): + def __init__(self, *args, braced=False, **kwargs): + self.braced = braced + return super().__init__(*args, **kwargs) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_var_sub(self) + + +class ArgExpansion(Node): + def __init__(self, list, pos=None, end_pos=None): + self.list = list + super().__init__(list, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_arg_expansion(self) + + +class List(Node): + """This Node currently exists exclusively for implementing the switch + command in a way that facilitates style checks. Might be nice to find + another way to handle this that doesn't require a special Node.""" + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_list(self) + + +class Expression(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_expression(self) + + +class BracedExpression(Node): + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_braced_expression(self) + + +class ParenExpression(Node): + def __init__(self, body: Expression, pos=None, end_pos=None): + self.body = body + super().__init__(body, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_paren_expression(self) + + +class UnaryOp(Node): + def __init__(self, operator, operand, pos=None, end_pos=None): + self.operator = operator + self.operand = operand + super().__init__(operator, operand, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_unary_op(self) + + +class BinaryOp(Node): + def __init__(self, operator1, operand, operator2, pos=None, end_pos=None): + self.operator1 = operator1 + self.operand = operand + self.operator2 = operator2 + super().__init__(operator1, operand, operator2, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_binary_op(self) + + +class TernaryOp(Node): + def __init__(self, condition, question, true, colon, false, pos=None, end_pos=None): + self.condition = condition + self.question = question + self.true = true + self.colon = colon + self.false = false + + super().__init__( + condition, question, true, colon, false, pos=pos, end_pos=end_pos + ) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_ternary_op(self) + + +class Function(Node): + def __init__(self, name, *args, pos=None, end_pos=None): + self.name = name + self.args = args + super().__init__(name, *args, pos=pos, end_pos=end_pos) + + def accept(self, visitor, recurse=False): + if recurse: + self._recurse(visitor) + visitor.visit_function(self) -- 2.54.0 From e842d9d74e8dd6bf3adc0438e3161179e220e010 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 24 Jul 2025 17:16:38 +0200 Subject: [PATCH 03/10] update completion list --- server/src/common/completion_list.json | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/server/src/common/completion_list.json b/server/src/common/completion_list.json index 159a3d3..44b6666 100644 --- a/server/src/common/completion_list.json +++ b/server/src/common/completion_list.json @@ -12,6 +12,10 @@ "label": "lappend", "kind": "keyword" }, + { + "label": "append", + "kind": "keyword" + }, { "label": "string", "kind": "function" @@ -40,9 +44,33 @@ "label": "set", "kind": "keyword" }, + { + "label": "unset", + "kind": "keyword" + }, { "label": "namespace", "kind": "keyword" + }, + { + "label": "info", + "kind": "keyword" + }, + { + "label": "global", + "kind": "keyword" + }, + { + "label": "incr", + "kind": "keyword" + }, + { + "label": "string", + "kind": "keyword" + }, + { + "label": "expr", + "kind": "keyword" } ], "MOM_procs": [ @@ -696,6 +724,86 @@ "MOM_force_block \"Once\" \"linear_move\"" ] }, + { + "label": "MOM_has_definition_element", + "kind": "function", + "description": "Interrogates whether the specified element or element and optional sub-element are defined in a definition file.", + "format": "MOM_has_definition_element []", + "parameters": [ + { + "name": "FORMAT|ADDRESS|BLOCK", + "desc": "Name of the definition element." + }, + { + "name": "element", + "desc": "Name of the definition element." + }, + { + "name": "sub-element", + "desc": "Optional. Name of the definition sub-element." + } + ], + "returns": [ + "1 - Element is defined.", + "0 - Does not exist." + ], + "example": [ + "# Example 1", + "MOM_has_definition_element BLOCK \"linear_move\" \"X\"" + ] + }, + { + "label": "MOM_incremental", + "kind": "function", + "description": "The next time that a block template that contains a reference to any of the input address names is evaluated, the deference (increment) from the previous value is output.", + "format": "MOM_incremental ", + "parameters": [ + { + "name": "On|Off", + "desc": "`On` - For each address specified, always output incremantal values. `Off` - For each address specified, always output absolute values." + }, + { + "name": "address_1 ... address_n", + "desc": "Name of output address(es)." + } + ], + "returns": [ + "1 - On.", + "0 - Off." + ], + "example": [ + "# Example 1", + "MOM_incremental ON X Y Z" + ] + }, + { + "label": "MOM_limit_output_angle", + "kind": "function", + "description": "Activates or deactivates the rotary axis output limits in a Post Configurator postprocessor. The command is called in the TCL procedure LIB_SPF_limit_output_angle. This command will set the following MOM variables:`mom_enable_4th_axis_output_limit` `mom_enable_5th_axis_output_limit` `mom_4th_axis_output_limit_max` `mom_4th_axis_output_limit_min` `mom_5th_axis_output_limit_max` `mom_5th_axis_output_limit_min`", + "format": "MOM_limit_output_angle [<4|5>] []", + "parameters": [ + { + "name": "ON|OFF", + "desc": "urns rotary axis output limit ON or OFF." + }, + { + "name": "4|5", + "desc": "Rotary axis." + }, + { + "name": "value1 value2", + "desc": "`value1` - Maximum output angle. `value2` - Minumum output angle." + } + ], + "example": [ + "# Example 1", + "MOM_limit_output_angle OFF", + "# Example 2", + "MOM_limit_output_angle OFF 4", + "# Example 3", + "MOM_limit_output_angle ON 5 360.0 0.0" + ] + }, { "label": "MOM_source", "kind": "function", -- 2.54.0 From 666c537f077eba1695dc78b5dbd5aee77e075c05 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Thu, 24 Jul 2025 21:31:08 +0200 Subject: [PATCH 04/10] add tclint --- .../libs/voluptuous-0.15.2.dist-info/COPYING | 25 + .../voluptuous-0.15.2.dist-info/INSTALLER | 1 + .../libs/voluptuous-0.15.2.dist-info/METADATA | 743 ++++++++++ .../libs/voluptuous-0.15.2.dist-info/RECORD | 20 + .../voluptuous-0.15.2.dist-info/REQUESTED | 0 server/libs/voluptuous-0.15.2.dist-info/WHEEL | 5 + .../voluptuous-0.15.2.dist-info/top_level.txt | 1 + server/libs/voluptuous/__init__.py | 88 ++ server/libs/voluptuous/error.py | 219 +++ server/libs/voluptuous/humanize.py | 57 + server/libs/voluptuous/py.typed | 0 server/libs/voluptuous/schema_builder.py | 1315 +++++++++++++++++ server/libs/voluptuous/util.py | 149 ++ server/libs/voluptuous/validators.py | 1248 ++++++++++++++++ server/requirements.in | 3 +- server/requirements.txt | 4 + server/src/tools/__init__.py | 0 server/src/tools/checks.py | 425 +++--- server/src/tools/commands/__init__.py | 6 +- server/src/tools/commands/builtin.py | 1082 ++++++++++++++ server/src/tools/commands/checks.py | 240 +++ server/src/tools/commands/plugins.py | 86 ++ server/src/tools/commands/schema.py | 35 + server/src/tools/comments.py | 91 ++ server/src/tools/config.py | 429 ++++++ server/src/tools/format.py | 480 ++++++ server/src/tools/lexer.py | 133 +- server/src/tools/parser.py | 76 +- server/src/tools/syntax_tree.py | 14 +- server/src/tools/violations.py | 50 + test/test.tcl | 4 +- 31 files changed, 6721 insertions(+), 308 deletions(-) create mode 100644 server/libs/voluptuous-0.15.2.dist-info/COPYING create mode 100644 server/libs/voluptuous-0.15.2.dist-info/INSTALLER create mode 100644 server/libs/voluptuous-0.15.2.dist-info/METADATA create mode 100644 server/libs/voluptuous-0.15.2.dist-info/RECORD create mode 100644 server/libs/voluptuous-0.15.2.dist-info/REQUESTED create mode 100644 server/libs/voluptuous-0.15.2.dist-info/WHEEL create mode 100644 server/libs/voluptuous-0.15.2.dist-info/top_level.txt create mode 100644 server/libs/voluptuous/__init__.py create mode 100644 server/libs/voluptuous/error.py create mode 100644 server/libs/voluptuous/humanize.py create mode 100644 server/libs/voluptuous/py.typed create mode 100644 server/libs/voluptuous/schema_builder.py create mode 100644 server/libs/voluptuous/util.py create mode 100644 server/libs/voluptuous/validators.py create mode 100644 server/src/tools/__init__.py create mode 100644 server/src/tools/commands/builtin.py create mode 100644 server/src/tools/commands/checks.py create mode 100644 server/src/tools/commands/plugins.py create mode 100644 server/src/tools/commands/schema.py create mode 100644 server/src/tools/comments.py create mode 100644 server/src/tools/config.py create mode 100644 server/src/tools/format.py create mode 100644 server/src/tools/violations.py diff --git a/server/libs/voluptuous-0.15.2.dist-info/COPYING b/server/libs/voluptuous-0.15.2.dist-info/COPYING new file mode 100644 index 0000000..a19b705 --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/COPYING @@ -0,0 +1,25 @@ +Copyright (c) 2010, Alec Thomas +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + - Neither the name of SwapOff.org nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/libs/voluptuous-0.15.2.dist-info/INSTALLER b/server/libs/voluptuous-0.15.2.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/libs/voluptuous-0.15.2.dist-info/METADATA b/server/libs/voluptuous-0.15.2.dist-info/METADATA new file mode 100644 index 0000000..85d2ef1 --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/METADATA @@ -0,0 +1,743 @@ +Metadata-Version: 2.1 +Name: voluptuous +Version: 0.15.2 +Summary: Python data validation library +Home-page: https://github.com/alecthomas/voluptuous +Download-URL: https://pypi.python.org/pypi/voluptuous +Author: Alec Thomas +Author-email: alec@swapoff.org +License: BSD-3-Clause +Platform: any +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-File: COPYING + + +# CONTRIBUTIONS ONLY + +**What does this mean?** I do not have time to fix issues myself. The only way fixes or new features will be added is by people submitting PRs. + +**Current status:** Voluptuous is largely feature stable. There hasn't been a need to add new features in a while, but there are some bugs that should be fixed. + +**Why?** I no longer use Voluptuous personally (in fact I no longer regularly write Python code). Rather than leave the project in a limbo of people filing issues and wondering why they're not being worked on, I believe this notice will more clearly set expectations. + +# Voluptuous is a Python data validation library + +[![image](https://img.shields.io/pypi/v/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![image](https://img.shields.io/pypi/l/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![image](https://img.shields.io/pypi/pyversions/voluptuous.svg)](https://python.org/pypi/voluptuous) +[![Test status](https://github.com/alecthomas/voluptuous/actions/workflows/tests.yml/badge.svg)](https://github.com/alecthomas/voluptuous/actions/workflows/tests.yml) +[![Coverage status](https://coveralls.io/repos/github/alecthomas/voluptuous/badge.svg?branch=master)](https://coveralls.io/github/alecthomas/voluptuous?branch=master) +[![Gitter chat](https://badges.gitter.im/alecthomas.svg)](https://gitter.im/alecthomas/Lobby) + +Voluptuous, *despite* the name, is a Python data validation library. It +is primarily intended for validating data coming into Python as JSON, +YAML, etc. + +It has three goals: + +1. Simplicity. +2. Support for complex data structures. +3. Provide useful error messages. + +## Contact + +Voluptuous now has a mailing list! Send a mail to +[](mailto:voluptuous@librelist.com) to subscribe. Instructions +will follow. + +You can also contact me directly via [email](mailto:alec@swapoff.org) or +[Twitter](https://twitter.com/alecthomas). + +To file a bug, create a [new issue](https://github.com/alecthomas/voluptuous/issues/new) on GitHub with a short example of how to replicate the issue. + +## Documentation + +The documentation is provided [here](http://alecthomas.github.io/voluptuous/). + +## Contribution to Documentation + +Documentation is built using `Sphinx`. You can install it by + + pip install -r requirements.txt + +For building `sphinx-apidoc` from scratch you need to set PYTHONPATH to `voluptuous/voluptuous` repository. + +The documentation is provided [here.](http://alecthomas.github.io/voluptuous/) + +## Changelog + +See [CHANGELOG.md](https://github.com/alecthomas/voluptuous/blob/master/CHANGELOG.md). + +## Why use Voluptuous over another validation library? + +**Validators are simple callables:** +No need to subclass anything, just use a function. + +**Errors are simple exceptions:** +A validator can just `raise Invalid(msg)` and expect the user to get +useful messages. + +**Schemas are basic Python data structures:** +Should your data be a dictionary of integer keys to strings? +`{int: str}` does what you expect. List of integers, floats or +strings? `[int, float, str]`. + +**Designed from the ground up for validating more than just forms:** +Nested data structures are treated in the same way as any other +type. Need a list of dictionaries? `[{}]` + +**Consistency:** +Types in the schema are checked as types. Values are compared as +values. Callables are called to validate. Simple. + +## Show me an example + +Twitter's [user search API](https://dev.twitter.com/rest/reference/get/users/search) accepts +query URLs like: + +```bash +$ curl 'https://api.twitter.com/1.1/users/search.json?q=python&per_page=20&page=1' +``` + +To validate this we might use a schema like: + +```pycon +>>> from voluptuous import Schema +>>> schema = Schema({ +... 'q': str, +... 'per_page': int, +... 'page': int, +... }) +``` + +This schema very succinctly and roughly describes the data required by +the API, and will work fine. But it has a few problems. Firstly, it +doesn't fully express the constraints of the API. According to the API, +`per_page` should be restricted to at most 20, defaulting to 5, for +example. To describe the semantics of the API more accurately, our +schema will need to be more thoroughly defined: + +```pycon +>>> from voluptuous import Required, All, Length, Range +>>> schema = Schema({ +... Required('q'): All(str, Length(min=1)), +... Required('per_page', default=5): All(int, Range(min=1, max=20)), +... 'page': All(int, Range(min=0)), +... }) +``` + +This schema fully enforces the interface defined in Twitter's +documentation, and goes a little further for completeness. + +"q" is required: + +```pycon +>>> from voluptuous import MultipleInvalid, Invalid +>>> try: +... schema({}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "required key not provided @ data['q']" +True +``` + +...must be a string: + +```pycon +>>> try: +... schema({'q': 123}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "expected str for dictionary value @ data['q']" +True +``` + +...and must be at least one character in length: + +```pycon +>>> try: +... schema({'q': ''}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "length of value must be at least 1 for dictionary value @ data['q']" +True +>>> schema({'q': '#topic'}) == {'q': '#topic', 'per_page': 5} +True +``` + +"per\_page" is a positive integer no greater than 20: + +```pycon +>>> try: +... schema({'q': '#topic', 'per_page': 900}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "value must be at most 20 for dictionary value @ data['per_page']" +True +>>> try: +... schema({'q': '#topic', 'per_page': -10}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "value must be at least 1 for dictionary value @ data['per_page']" +True +``` + +"page" is an integer \>= 0: + +```pycon +>>> try: +... schema({'q': '#topic', 'per_page': 'one'}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) +"expected int for dictionary value @ data['per_page']" +>>> schema({'q': '#topic', 'page': 1}) == {'q': '#topic', 'page': 1, 'per_page': 5} +True +``` + +## Defining schemas + +Schemas are nested data structures consisting of dictionaries, lists, +scalars and *validators*. Each node in the input schema is pattern +matched against corresponding nodes in the input data. + +### Literals + +Literals in the schema are matched using normal equality checks: + +```pycon +>>> schema = Schema(1) +>>> schema(1) +1 +>>> schema = Schema('a string') +>>> schema('a string') +'a string' +``` + +### Types + +Types in the schema are matched by checking if the corresponding value +is an instance of the type: + +```pycon +>>> schema = Schema(int) +>>> schema(1) +1 +>>> try: +... schema('one') +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "expected int" +True +``` + +### URLs + +URLs in the schema are matched by using `urlparse` library. + +```pycon +>>> from voluptuous import Url +>>> schema = Schema(Url()) +>>> schema('http://w3.org') +'http://w3.org' +>>> try: +... schema('one') +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "expected a URL" +True +``` + +### Lists + +Lists in the schema are treated as a set of valid values. Each element +in the schema list is compared to each value in the input data: + +```pycon +>>> schema = Schema([1, 'a', 'string']) +>>> schema([1]) +[1] +>>> schema([1, 1, 1]) +[1, 1, 1] +>>> schema(['a', 1, 'string', 1, 'string']) +['a', 1, 'string', 1, 'string'] +``` + +However, an empty list (`[]`) is treated as is. If you want to specify a list that can +contain anything, specify it as `list`: + +```pycon +>>> schema = Schema([]) +>>> try: +... schema([1]) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "not a valid value @ data[1]" +True +>>> schema([]) +[] +>>> schema = Schema(list) +>>> schema([]) +[] +>>> schema([1, 2]) +[1, 2] +``` + +### Sets and frozensets + +Sets and frozensets are treated as a set of valid values. Each element +in the schema set is compared to each value in the input data: + +```pycon +>>> schema = Schema({42}) +>>> schema({42}) == {42} +True +>>> try: +... schema({43}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "invalid value in set" +True +>>> schema = Schema({int}) +>>> schema({1, 2, 3}) == {1, 2, 3} +True +>>> schema = Schema({int, str}) +>>> schema({1, 2, 'abc'}) == {1, 2, 'abc'} +True +>>> schema = Schema(frozenset([int])) +>>> try: +... schema({3}) +... raise AssertionError('Invalid not raised') +... except Invalid as e: +... exc = e +>>> str(exc) == 'expected a frozenset' +True +``` + +However, an empty set (`set()`) is treated as is. If you want to specify a set +that can contain anything, specify it as `set`: + +```pycon +>>> schema = Schema(set()) +>>> try: +... schema({1}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "invalid value in set" +True +>>> schema(set()) == set() +True +>>> schema = Schema(set) +>>> schema({1, 2}) == {1, 2} +True +``` + +### Validation functions + +Validators are simple callables that raise an `Invalid` exception when +they encounter invalid data. The criteria for determining validity is +entirely up to the implementation; it may check that a value is a valid +username with `pwd.getpwnam()`, it may check that a value is of a +specific type, and so on. + +The simplest kind of validator is a Python function that raises +ValueError when its argument is invalid. Conveniently, many builtin +Python functions have this property. Here's an example of a date +validator: + +```pycon +>>> from datetime import datetime +>>> def Date(fmt='%Y-%m-%d'): +... return lambda v: datetime.strptime(v, fmt) +``` + +```pycon +>>> schema = Schema(Date()) +>>> schema('2013-03-03') +datetime.datetime(2013, 3, 3, 0, 0) +>>> try: +... schema('2013-03') +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "not a valid value" +True +``` + +In addition to simply determining if a value is valid, validators may +mutate the value into a valid form. An example of this is the +`Coerce(type)` function, which returns a function that coerces its +argument to the given type: + +```python +def Coerce(type, msg=None): + """Coerce a value to a type. + + If the type constructor throws a ValueError, the value will be marked as + Invalid. + """ + def f(v): + try: + return type(v) + except ValueError: + raise Invalid(msg or ('expected %s' % type.__name__)) + return f +``` + +This example also shows a common idiom where an optional human-readable +message can be provided. This can vastly improve the usefulness of the +resulting error messages. + +### Dictionaries + +Each key-value pair in a schema dictionary is validated against each +key-value pair in the corresponding data dictionary: + +```pycon +>>> schema = Schema({1: 'one', 2: 'two'}) +>>> schema({1: 'one'}) +{1: 'one'} +``` + +#### Extra dictionary keys + +By default any additional keys in the data, not in the schema will +trigger exceptions: + +```pycon +>>> schema = Schema({2: 3}) +>>> try: +... schema({1: 2, 2: 3}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "extra keys not allowed @ data[1]" +True +``` + +This behaviour can be altered on a per-schema basis. To allow +additional keys use +`Schema(..., extra=ALLOW_EXTRA)`: + +```pycon +>>> from voluptuous import ALLOW_EXTRA +>>> schema = Schema({2: 3}, extra=ALLOW_EXTRA) +>>> schema({1: 2, 2: 3}) +{1: 2, 2: 3} +``` + +To remove additional keys use +`Schema(..., extra=REMOVE_EXTRA)`: + +```pycon +>>> from voluptuous import REMOVE_EXTRA +>>> schema = Schema({2: 3}, extra=REMOVE_EXTRA) +>>> schema({1: 2, 2: 3}) +{2: 3} +``` + +It can also be overridden per-dictionary by using the catch-all marker +token `extra` as a key: + +```pycon +>>> from voluptuous import Extra +>>> schema = Schema({1: {Extra: object}}) +>>> schema({1: {'foo': 'bar'}}) +{1: {'foo': 'bar'}} +``` + +#### Required dictionary keys + +By default, keys in the schema are not required to be in the data: + +```pycon +>>> schema = Schema({1: 2, 3: 4}) +>>> schema({3: 4}) +{3: 4} +``` + +Similarly to how extra\_ keys work, this behaviour can be overridden +per-schema: + +```pycon +>>> schema = Schema({1: 2, 3: 4}, required=True) +>>> try: +... schema({3: 4}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "required key not provided @ data[1]" +True +``` + +And per-key, with the marker token `Required(key)`: + +```pycon +>>> schema = Schema({Required(1): 2, 3: 4}) +>>> try: +... schema({3: 4}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "required key not provided @ data[1]" +True +>>> schema({1: 2}) +{1: 2} +``` + +#### Optional dictionary keys + +If a schema has `required=True`, keys may be individually marked as +optional using the marker token `Optional(key)`: + +```pycon +>>> from voluptuous import Optional +>>> schema = Schema({1: 2, Optional(3): 4}, required=True) +>>> try: +... schema({}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "required key not provided @ data[1]" +True +>>> schema({1: 2}) +{1: 2} +>>> try: +... schema({1: 2, 4: 5}) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "extra keys not allowed @ data[4]" +True +``` + +```pycon +>>> schema({1: 2, 3: 4}) +{1: 2, 3: 4} +``` + +### Recursive / nested schema + +You can use `voluptuous.Self` to define a nested schema: + +```pycon +>>> from voluptuous import Schema, Self +>>> recursive = Schema({"more": Self, "value": int}) +>>> recursive({"more": {"value": 42}, "value": 41}) == {'more': {'value': 42}, 'value': 41} +True +``` + +### Extending an existing Schema + +Often it comes handy to have a base `Schema` that is extended with more +requirements. In that case you can use `Schema.extend` to create a new +`Schema`: + +```pycon +>>> from voluptuous import Schema +>>> person = Schema({'name': str}) +>>> person_with_age = person.extend({'age': int}) +>>> sorted(list(person_with_age.schema.keys())) +['age', 'name'] +``` + +The original `Schema` remains unchanged. + +### Objects + +Each key-value pair in a schema dictionary is validated against each +attribute-value pair in the corresponding object: + +```pycon +>>> from voluptuous import Object +>>> class Structure(object): +... def __init__(self, q=None): +... self.q = q +... def __repr__(self): +... return ''.format(self) +... +>>> schema = Schema(Object({'q': 'one'}, cls=Structure)) +>>> schema(Structure(q='one')) + +``` + +### Allow None values + +To allow value to be None as well, use Any: + +```pycon +>>> from voluptuous import Any + +>>> schema = Schema(Any(None, int)) +>>> schema(None) +>>> schema(5) +5 +``` + +## Error reporting + +Validators must throw an `Invalid` exception if invalid data is passed +to them. All other exceptions are treated as errors in the validator and +will not be caught. + +Each `Invalid` exception has an associated `path` attribute representing +the path in the data structure to our currently validating value, as well +as an `error_message` attribute that contains the message of the original +exception. This is especially useful when you want to catch `Invalid` +exceptions and give some feedback to the user, for instance in the context of +an HTTP API. + +```pycon +>>> def validate_email(email): +... """Validate email.""" +... if not "@" in email: +... raise Invalid("This email is invalid.") +... return email +>>> schema = Schema({"email": validate_email}) +>>> exc = None +>>> try: +... schema({"email": "whatever"}) +... except MultipleInvalid as e: +... exc = e +>>> str(exc) +"This email is invalid. for dictionary value @ data['email']" +>>> exc.path +['email'] +>>> exc.msg +'This email is invalid.' +>>> exc.error_message +'This email is invalid.' +``` + +The `path` attribute is used during error reporting, but also during matching +to determine whether an error should be reported to the user or if the next +match should be attempted. This is determined by comparing the depth of the +path where the check is, to the depth of the path where the error occurred. If +the error is more than one level deeper, it is reported. + +The upshot of this is that *matching is depth-first and fail-fast*. + +To illustrate this, here is an example schema: + +```pycon +>>> schema = Schema([[2, 3], 6]) +``` + +Each value in the top-level list is matched depth-first in-order. Given +input data of `[[6]]`, the inner list will match the first element of +the schema, but the literal `6` will not match any of the elements of +that list. This error will be reported back to the user immediately. No +backtracking is attempted: + +```pycon +>>> try: +... schema([[6]]) +... raise AssertionError('MultipleInvalid not raised') +... except MultipleInvalid as e: +... exc = e +>>> str(exc) == "not a valid value @ data[0][0]" +True +``` + +If we pass the data `[6]`, the `6` is not a list type and so will not +recurse into the first element of the schema. Matching will continue on +to the second element in the schema, and succeed: + +```pycon +>>> schema([6]) +[6] +``` + +## Multi-field validation + +Validation rules that involve multiple fields can be implemented as +custom validators. It's recommended to use `All()` to do a two-pass +validation - the first pass checking the basic structure of the data, +and only after that, the second pass applying your cross-field +validator: + +```python +def passwords_must_match(passwords): + if passwords['password'] != passwords['password_again']: + raise Invalid('passwords must match') + return passwords + +schema = Schema(All( + # First "pass" for field types + {'password': str, 'password_again': str}, + # Follow up the first "pass" with your multi-field rules + passwords_must_match +)) + +# valid +schema({'password': '123', 'password_again': '123'}) + +# raises MultipleInvalid: passwords must match +schema({'password': '123', 'password_again': 'and now for something completely different'}) + +``` + +With this structure, your multi-field validator will run with +pre-validated data from the first "pass" and so will not have to do +its own type checking on its inputs. + +The flipside is that if the first "pass" of validation fails, your +cross-field validator will not run: + +```python +# raises Invalid because password_again is not a string +# passwords_must_match() will not run because first-pass validation already failed +schema({'password': '123', 'password_again': 1337}) +``` + +## Running tests + +Voluptuous is using `pytest`: + +```bash +$ pip install pytest +$ pytest +``` + +To also include a coverage report: + +```bash +$ pip install pytest pytest-cov coverage>=3.0 +$ pytest --cov=voluptuous voluptuous/tests/ +``` + +## Other libraries and inspirations + +Voluptuous is heavily inspired by +[Validino](http://code.google.com/p/validino/), and to a lesser extent, +[jsonvalidator](http://code.google.com/p/jsonvalidator/) and +[json\_schema](http://blog.sendapatch.se/category/json_schema.html). + +[pytest-voluptuous](https://github.com/F-Secure/pytest-voluptuous) is a +[pytest](https://github.com/pytest-dev/pytest) plugin that helps in +using voluptuous validators in `assert`s. + +I greatly prefer the light-weight style promoted by these libraries to +the complexity of libraries like FormEncode. + diff --git a/server/libs/voluptuous-0.15.2.dist-info/RECORD b/server/libs/voluptuous-0.15.2.dist-info/RECORD new file mode 100644 index 0000000..07b7692 --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/RECORD @@ -0,0 +1,20 @@ +voluptuous-0.15.2.dist-info/COPYING,sha256=JHtJdren-k2J2Vh8qlCVVh60bcVFfyJ59ipitUUq3qk,1486 +voluptuous-0.15.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +voluptuous-0.15.2.dist-info/METADATA,sha256=skO8Rp2Rq3VpxIPpE5LWhWiiWWXWHf9HL_-TFOkEz60,20641 +voluptuous-0.15.2.dist-info/RECORD,, +voluptuous-0.15.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +voluptuous-0.15.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92 +voluptuous-0.15.2.dist-info/top_level.txt,sha256=TTdVb7M-vndb67UqTmAxuVjpAUakrlAWJYqvo3w4Iqc,11 +voluptuous/__init__.py,sha256=6_S65O_9lnoewl5dQSLIz_BKrsfxmOK-lG_i3Djd8Z8,2227 +voluptuous/__pycache__/__init__.cpython-311.pyc,, +voluptuous/__pycache__/error.cpython-311.pyc,, +voluptuous/__pycache__/humanize.cpython-311.pyc,, +voluptuous/__pycache__/schema_builder.cpython-311.pyc,, +voluptuous/__pycache__/util.cpython-311.pyc,, +voluptuous/__pycache__/validators.cpython-311.pyc,, +voluptuous/error.py,sha256=qipmadJhLycX4zIju6j8T8rjJHiiELVDv3CSoBCDnwM,4606 +voluptuous/humanize.py,sha256=CWBrrE6fK73iOM19w1CK9_f_Qrc92u2PQIjngG8-EC0,1905 +voluptuous/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +voluptuous/schema_builder.py,sha256=QDt5o1ZtLdqTtOd5IVzKczNBPftLKGk77Cz4UFJUD0g,43730 +voluptuous/util.py,sha256=BNxkVJZ6qbg8pDWY_TOMloLLgNgzixV1ZQ9rhTdbFgs,3174 +voluptuous/validators.py,sha256=wp3fmKr-KC7saw8aeUWw1CLOoxwrcj8YiteXJN9eUIQ,36501 diff --git a/server/libs/voluptuous-0.15.2.dist-info/REQUESTED b/server/libs/voluptuous-0.15.2.dist-info/REQUESTED new file mode 100644 index 0000000..e69de29 diff --git a/server/libs/voluptuous-0.15.2.dist-info/WHEEL b/server/libs/voluptuous-0.15.2.dist-info/WHEEL new file mode 100644 index 0000000..bab98d6 --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.43.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/server/libs/voluptuous-0.15.2.dist-info/top_level.txt b/server/libs/voluptuous-0.15.2.dist-info/top_level.txt new file mode 100644 index 0000000..55356d5 --- /dev/null +++ b/server/libs/voluptuous-0.15.2.dist-info/top_level.txt @@ -0,0 +1 @@ +voluptuous diff --git a/server/libs/voluptuous/__init__.py b/server/libs/voluptuous/__init__.py new file mode 100644 index 0000000..d030b35 --- /dev/null +++ b/server/libs/voluptuous/__init__.py @@ -0,0 +1,88 @@ +"""Schema validation for Python data structures. + +Given eg. a nested data structure like this: + + { + 'exclude': ['Users', 'Uptime'], + 'include': [], + 'set': { + 'snmp_community': 'public', + 'snmp_timeout': 15, + 'snmp_version': '2c', + }, + 'targets': { + 'localhost': { + 'exclude': ['Uptime'], + 'features': { + 'Uptime': { + 'retries': 3, + }, + 'Users': { + 'snmp_community': 'monkey', + 'snmp_port': 15, + }, + }, + 'include': ['Users'], + 'set': { + 'snmp_community': 'monkeys', + }, + }, + }, + } + +A schema like this: + + >>> settings = { + ... 'snmp_community': str, + ... 'retries': int, + ... 'snmp_version': All(Coerce(str), Any('3', '2c', '1')), + ... } + >>> features = ['Ping', 'Uptime', 'Http'] + >>> schema = Schema({ + ... 'exclude': features, + ... 'include': features, + ... 'set': settings, + ... 'targets': { + ... 'exclude': features, + ... 'include': features, + ... 'features': { + ... str: settings, + ... }, + ... }, + ... }) + +Validate like so: + + >>> schema({ + ... 'set': { + ... 'snmp_community': 'public', + ... 'snmp_version': '2c', + ... }, + ... 'targets': { + ... 'exclude': ['Ping'], + ... 'features': { + ... 'Uptime': {'retries': 3}, + ... 'Users': {'snmp_community': 'monkey'}, + ... }, + ... }, + ... }) == { + ... 'set': {'snmp_version': '2c', 'snmp_community': 'public'}, + ... 'targets': { + ... 'exclude': ['Ping'], + ... 'features': {'Uptime': {'retries': 3}, + ... 'Users': {'snmp_community': 'monkey'}}}} + True +""" + +# flake8: noqa +# fmt: off +from voluptuous.schema_builder import * +from voluptuous.util import * +from voluptuous.validators import * + +from voluptuous.error import * # isort: skip + +# fmt: on + +__version__ = '0.15.2' +__author__ = 'alecthomas' diff --git a/server/libs/voluptuous/error.py b/server/libs/voluptuous/error.py new file mode 100644 index 0000000..9dab943 --- /dev/null +++ b/server/libs/voluptuous/error.py @@ -0,0 +1,219 @@ +# fmt: off +import typing + +# fmt: on + + +class Error(Exception): + """Base validation exception.""" + + +class SchemaError(Error): + """An error was encountered in the schema.""" + + +class Invalid(Error): + """The data was invalid. + + :attr msg: The error message. + :attr path: The path to the error, as a list of keys in the source data. + :attr error_message: The actual error message that was raised, as a + string. + + """ + + def __init__( + self, + message: str, + path: typing.Optional[typing.List[typing.Hashable]] = None, + error_message: typing.Optional[str] = None, + error_type: typing.Optional[str] = None, + ) -> None: + Error.__init__(self, message) + self._path = path or [] + self._error_message = error_message or message + self.error_type = error_type + + @property + def msg(self) -> str: + return self.args[0] + + @property + def path(self) -> typing.List[typing.Hashable]: + return self._path + + @property + def error_message(self) -> str: + return self._error_message + + def __str__(self) -> str: + path = ' @ data[%s]' % ']['.join(map(repr, self.path)) if self.path else '' + output = Exception.__str__(self) + if self.error_type: + output += ' for ' + self.error_type + return output + path + + def prepend(self, path: typing.List[typing.Hashable]) -> None: + self._path = path + self.path + + +class MultipleInvalid(Invalid): + def __init__(self, errors: typing.Optional[typing.List[Invalid]] = None) -> None: + self.errors = errors[:] if errors else [] + + def __repr__(self) -> str: + return 'MultipleInvalid(%r)' % self.errors + + @property + def msg(self) -> str: + return self.errors[0].msg + + @property + def path(self) -> typing.List[typing.Hashable]: + return self.errors[0].path + + @property + def error_message(self) -> str: + return self.errors[0].error_message + + def add(self, error: Invalid) -> None: + self.errors.append(error) + + def __str__(self) -> str: + return str(self.errors[0]) + + def prepend(self, path: typing.List[typing.Hashable]) -> None: + for error in self.errors: + error.prepend(path) + + +class RequiredFieldInvalid(Invalid): + """Required field was missing.""" + + +class ObjectInvalid(Invalid): + """The value we found was not an object.""" + + +class DictInvalid(Invalid): + """The value found was not a dict.""" + + +class ExclusiveInvalid(Invalid): + """More than one value found in exclusion group.""" + + +class InclusiveInvalid(Invalid): + """Not all values found in inclusion group.""" + + +class SequenceTypeInvalid(Invalid): + """The type found is not a sequence type.""" + + +class TypeInvalid(Invalid): + """The value was not of required type.""" + + +class ValueInvalid(Invalid): + """The value was found invalid by evaluation function.""" + + +class ContainsInvalid(Invalid): + """List does not contain item""" + + +class ScalarInvalid(Invalid): + """Scalars did not match.""" + + +class CoerceInvalid(Invalid): + """Impossible to coerce value to type.""" + + +class AnyInvalid(Invalid): + """The value did not pass any validator.""" + + +class AllInvalid(Invalid): + """The value did not pass all validators.""" + + +class MatchInvalid(Invalid): + """The value does not match the given regular expression.""" + + +class RangeInvalid(Invalid): + """The value is not in given range.""" + + +class TrueInvalid(Invalid): + """The value is not True.""" + + +class FalseInvalid(Invalid): + """The value is not False.""" + + +class BooleanInvalid(Invalid): + """The value is not a boolean.""" + + +class UrlInvalid(Invalid): + """The value is not a URL.""" + + +class EmailInvalid(Invalid): + """The value is not an email address.""" + + +class FileInvalid(Invalid): + """The value is not a file.""" + + +class DirInvalid(Invalid): + """The value is not a directory.""" + + +class PathInvalid(Invalid): + """The value is not a path.""" + + +class LiteralInvalid(Invalid): + """The literal values do not match.""" + + +class LengthInvalid(Invalid): + pass + + +class DatetimeInvalid(Invalid): + """The value is not a formatted datetime string.""" + + +class DateInvalid(Invalid): + """The value is not a formatted date string.""" + + +class InInvalid(Invalid): + pass + + +class NotInInvalid(Invalid): + pass + + +class ExactSequenceInvalid(Invalid): + pass + + +class NotEnoughValid(Invalid): + """The value did not pass enough validations.""" + + pass + + +class TooManyValid(Invalid): + """The value passed more than expected validations.""" + + pass diff --git a/server/libs/voluptuous/humanize.py b/server/libs/voluptuous/humanize.py new file mode 100644 index 0000000..eabfd02 --- /dev/null +++ b/server/libs/voluptuous/humanize.py @@ -0,0 +1,57 @@ +# fmt: off +import typing + +from voluptuous import Invalid, MultipleInvalid +from voluptuous.error import Error +from voluptuous.schema_builder import Schema + +# fmt: on + +MAX_VALIDATION_ERROR_ITEM_LENGTH = 500 + + +def _nested_getitem( + data: typing.Any, path: typing.List[typing.Hashable] +) -> typing.Optional[typing.Any]: + for item_index in path: + try: + data = data[item_index] + except (KeyError, IndexError, TypeError): + # The index is not present in the dictionary, list or other + # indexable or data is not subscriptable + return None + return data + + +def humanize_error( + data, + validation_error: Invalid, + max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, +) -> str: + """Provide a more helpful + complete validation error message than that provided automatically + Invalid and MultipleInvalid do not include the offending value in error messages, + and MultipleInvalid.__str__ only provides the first error. + """ + if isinstance(validation_error, MultipleInvalid): + return '\n'.join( + sorted( + humanize_error(data, sub_error, max_sub_error_length) + for sub_error in validation_error.errors + ) + ) + else: + offending_item_summary = repr(_nested_getitem(data, validation_error.path)) + if len(offending_item_summary) > max_sub_error_length: + offending_item_summary = ( + offending_item_summary[: max_sub_error_length - 3] + '...' + ) + return '%s. Got %s' % (validation_error, offending_item_summary) + + +def validate_with_humanized_errors( + data, schema: Schema, max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH +) -> typing.Any: + try: + return schema(data) + except (Invalid, MultipleInvalid) as e: + raise Error(humanize_error(data, e, max_sub_error_length)) diff --git a/server/libs/voluptuous/py.typed b/server/libs/voluptuous/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/libs/voluptuous/schema_builder.py b/server/libs/voluptuous/schema_builder.py new file mode 100644 index 0000000..cdeb514 --- /dev/null +++ b/server/libs/voluptuous/schema_builder.py @@ -0,0 +1,1315 @@ +# fmt: off +from __future__ import annotations + +import collections +import inspect +import itertools +import re +import sys +import typing +from collections.abc import Generator +from contextlib import contextmanager +from functools import cache, wraps + +from voluptuous import error as er +from voluptuous.error import Error + +# fmt: on + +# options for extra keys +PREVENT_EXTRA = 0 # any extra key not in schema will raise an error +ALLOW_EXTRA = 1 # extra keys not in schema will be included in output +REMOVE_EXTRA = 2 # extra keys not in schema will be excluded from output + + +def _isnamedtuple(obj): + return isinstance(obj, tuple) and hasattr(obj, '_fields') + + +class Undefined(object): + def __nonzero__(self): + return False + + def __repr__(self): + return '...' + + +UNDEFINED = Undefined() + + +def Self() -> None: + raise er.SchemaError('"Self" should never be called') + + +DefaultFactory = typing.Union[Undefined, typing.Callable[[], typing.Any]] + + +def default_factory(value) -> DefaultFactory: + if value is UNDEFINED or callable(value): + return value + return lambda: value + + +@contextmanager +def raises( + exc, msg: typing.Optional[str] = None, regex: typing.Optional[re.Pattern] = None +) -> Generator[None, None, None]: + try: + yield + except exc as e: + if msg is not None: + assert str(e) == msg, '%r != %r' % (str(e), msg) + if regex is not None: + assert re.search(regex, str(e)), '%r does not match %r' % (str(e), regex) + else: + raise AssertionError(f"Did not raise exception {exc.__name__}") + + +def Extra(_) -> None: + """Allow keys in the data that are not present in the schema.""" + raise er.SchemaError('"Extra" should never be called') + + +# As extra() is never called there's no way to catch references to the +# deprecated object, so we just leave an alias here instead. +extra = Extra + +primitive_types = (bool, bytes, int, str, float, complex) + +# fmt: off +Schemable = typing.Union[ + 'Schema', 'Object', + collections.abc.Mapping, + list, tuple, frozenset, set, + bool, bytes, int, str, float, complex, + type, object, dict, None, typing.Callable +] +# fmt: on + + +class Schema(object): + """A validation schema. + + The schema is a Python tree-like structure where nodes are pattern + matched against corresponding trees of values. + + Nodes can be values, in which case a direct comparison is used, types, + in which case an isinstance() check is performed, or callables, which will + validate and optionally convert the value. + + We can equate schemas also. + + For Example: + + >>> v = Schema({Required('a'): str}) + >>> v1 = Schema({Required('a'): str}) + >>> v2 = Schema({Required('b'): str}) + >>> assert v == v1 + >>> assert v != v2 + + """ + + _extra_to_name = { + REMOVE_EXTRA: 'REMOVE_EXTRA', + ALLOW_EXTRA: 'ALLOW_EXTRA', + PREVENT_EXTRA: 'PREVENT_EXTRA', + } + + def __init__( + self, schema: Schemable, required: bool = False, extra: int = PREVENT_EXTRA + ) -> None: + """Create a new Schema. + + :param schema: Validation schema. See :module:`voluptuous` for details. + :param required: Keys defined in the schema must be in the data. + :param extra: Specify how extra keys in the data are treated: + - :const:`~voluptuous.PREVENT_EXTRA`: to disallow any undefined + extra keys (raise ``Invalid``). + - :const:`~voluptuous.ALLOW_EXTRA`: to include undefined extra + keys in the output. + - :const:`~voluptuous.REMOVE_EXTRA`: to exclude undefined extra keys + from the output. + - Any value other than the above defaults to + :const:`~voluptuous.PREVENT_EXTRA` + """ + self.schema: typing.Any = schema + self.required = required + self.extra = int(extra) # ensure the value is an integer + self._compiled = self._compile(schema) + + @classmethod + def infer(cls, data, **kwargs) -> Schema: + """Create a Schema from concrete data (e.g. an API response). + + For example, this will take a dict like: + + { + 'foo': 1, + 'bar': { + 'a': True, + 'b': False + }, + 'baz': ['purple', 'monkey', 'dishwasher'] + } + + And return a Schema: + + { + 'foo': int, + 'bar': { + 'a': bool, + 'b': bool + }, + 'baz': [str] + } + + Note: only very basic inference is supported. + """ + + def value_to_schema_type(value): + if isinstance(value, dict): + if len(value) == 0: + return dict + return {k: value_to_schema_type(v) for k, v in value.items()} + if isinstance(value, list): + if len(value) == 0: + return list + else: + return [value_to_schema_type(v) for v in value] + return type(value) + + return cls(value_to_schema_type(data), **kwargs) + + def __eq__(self, other): + if not isinstance(other, Schema): + return False + return other.schema == self.schema + + def __ne__(self, other): + return not (self == other) + + def __str__(self): + return str(self.schema) + + def __repr__(self): + return "" % ( + self.schema, + self._extra_to_name.get(self.extra, '??'), + self.required, + id(self), + ) + + def __call__(self, data): + """Validate data against this schema.""" + try: + return self._compiled([], data) + except er.MultipleInvalid: + raise + except er.Invalid as e: + raise er.MultipleInvalid([e]) + # return self.validate([], self.schema, data) + + def _compile(self, schema): + if schema is Extra: + return lambda _, v: v + if schema is Self: + return lambda p, v: self._compiled(p, v) + elif hasattr(schema, "__voluptuous_compile__"): + return schema.__voluptuous_compile__(self) + if isinstance(schema, Object): + return self._compile_object(schema) + if isinstance(schema, collections.abc.Mapping): + return self._compile_dict(schema) + elif isinstance(schema, list): + return self._compile_list(schema) + elif isinstance(schema, tuple): + return self._compile_tuple(schema) + elif isinstance(schema, (frozenset, set)): + return self._compile_set(schema) + type_ = type(schema) + if inspect.isclass(schema): + type_ = schema + if type_ in (*primitive_types, object, type(None)) or callable(schema): + return _compile_scalar(schema) + raise er.SchemaError('unsupported schema data type %r' % type(schema).__name__) + + def _compile_mapping(self, schema, invalid_msg=None): + """Create validator for given mapping.""" + invalid_msg = invalid_msg or 'mapping value' + + # Keys that may be required + all_required_keys = set( + key + for key in schema + if key is not Extra + and ( + (self.required and not isinstance(key, (Optional, Remove))) + or isinstance(key, Required) + ) + ) + + # Keys that may have defaults + all_default_keys = set( + key + for key in schema + if isinstance(key, Required) or isinstance(key, Optional) + ) + + _compiled_schema = {} + for skey, svalue in schema.items(): + new_key = self._compile(skey) + new_value = self._compile(svalue) + _compiled_schema[skey] = (new_key, new_value) + + candidates = list(_iterate_mapping_candidates(_compiled_schema)) + + # After we have the list of candidates in the correct order, we want to apply some optimization so that each + # key in the data being validated will be matched against the relevant schema keys only. + # No point in matching against different keys + additional_candidates = [] + candidates_by_key = {} + for skey, (ckey, cvalue) in candidates: + if type(skey) in primitive_types: + candidates_by_key.setdefault(skey, []).append((skey, (ckey, cvalue))) + elif isinstance(skey, Marker) and type(skey.schema) in primitive_types: + candidates_by_key.setdefault(skey.schema, []).append( + (skey, (ckey, cvalue)) + ) + else: + # These are wildcards such as 'int', 'str', 'Remove' and others which should be applied to all keys + additional_candidates.append((skey, (ckey, cvalue))) + + def validate_mapping(path, iterable, out): + required_keys = all_required_keys.copy() + + # Build a map of all provided key-value pairs. + # The type(out) is used to retain ordering in case a ordered + # map type is provided as input. + key_value_map = type(out)() + for key, value in iterable: + key_value_map[key] = value + + # Insert default values for non-existing keys. + for key in all_default_keys: + if ( + not isinstance(key.default, Undefined) + and key.schema not in key_value_map + ): + # A default value has been specified for this missing + # key, insert it. + key_value_map[key.schema] = key.default() + + errors = [] + for key, value in key_value_map.items(): + key_path = path + [key] + remove_key = False + + # Optimization. Validate against the matching key first, then fallback to the rest + relevant_candidates = itertools.chain( + candidates_by_key.get(key, []), additional_candidates + ) + + # compare each given key/value against all compiled key/values + # schema key, (compiled key, compiled value) + error = None + for skey, (ckey, cvalue) in relevant_candidates: + try: + new_key = ckey(key_path, key) + except er.Invalid as e: + if len(e.path) > len(key_path): + raise + if not error or len(e.path) > len(error.path): + error = e + continue + # Backtracking is not performed once a key is selected, so if + # the value is invalid we immediately throw an exception. + exception_errors = [] + # check if the key is marked for removal + is_remove = new_key is Remove + try: + cval = cvalue(key_path, value) + # include if it's not marked for removal + if not is_remove: + out[new_key] = cval + else: + remove_key = True + continue + except er.MultipleInvalid as e: + exception_errors.extend(e.errors) + except er.Invalid as e: + exception_errors.append(e) + + if exception_errors: + if is_remove or remove_key: + continue + for err in exception_errors: + if len(err.path) <= len(key_path): + err.error_type = invalid_msg + errors.append(err) + # If there is a validation error for a required + # key, this means that the key was provided. + # Discard the required key so it does not + # create an additional, noisy exception. + required_keys.discard(skey) + break + + # Key and value okay, mark as found in case it was + # a Required() field. + required_keys.discard(skey) + + break + else: + if remove_key: + # remove key + continue + elif self.extra == ALLOW_EXTRA: + out[key] = value + elif error: + errors.append(error) + elif self.extra != REMOVE_EXTRA: + errors.append(er.Invalid('extra keys not allowed', key_path)) + # else REMOVE_EXTRA: ignore the key so it's removed from output + + # for any required keys left that weren't found and don't have defaults: + for key in required_keys: + msg = ( + key.msg + if hasattr(key, 'msg') and key.msg + else 'required key not provided' + ) + errors.append(er.RequiredFieldInvalid(msg, path + [key])) + if errors: + raise er.MultipleInvalid(errors) + + return out + + return validate_mapping + + def _compile_object(self, schema): + """Validate an object. + + Has the same behavior as dictionary validator but work with object + attributes. + + For example: + + >>> class Structure(object): + ... def __init__(self, one=None, three=None): + ... self.one = one + ... self.three = three + ... + >>> validate = Schema(Object({'one': 'two', 'three': 'four'}, cls=Structure)) + >>> with raises(er.MultipleInvalid, "not a valid value for object value @ data['one']"): + ... validate(Structure(one='three')) + + """ + base_validate = self._compile_mapping(schema, invalid_msg='object value') + + def validate_object(path, data): + if schema.cls is not UNDEFINED and not isinstance(data, schema.cls): + raise er.ObjectInvalid('expected a {0!r}'.format(schema.cls), path) + iterable = _iterate_object(data) + iterable = filter(lambda item: item[1] is not None, iterable) + out = base_validate(path, iterable, {}) + return type(data)(**out) + + return validate_object + + def _compile_dict(self, schema): + """Validate a dictionary. + + A dictionary schema can contain a set of values, or at most one + validator function/type. + + A dictionary schema will only validate a dictionary: + + >>> validate = Schema({}) + >>> with raises(er.MultipleInvalid, 'expected a dictionary'): + ... validate([]) + + An invalid dictionary value: + + >>> validate = Schema({'one': 'two', 'three': 'four'}) + >>> with raises(er.MultipleInvalid, "not a valid value for dictionary value @ data['one']"): + ... validate({'one': 'three'}) + + An invalid key: + + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data['two']"): + ... validate({'two': 'three'}) + + + Validation function, in this case the "int" type: + + >>> validate = Schema({'one': 'two', 'three': 'four', int: str}) + + Valid integer input: + + >>> validate({10: 'twenty'}) + {10: 'twenty'} + + By default, a "type" in the schema (in this case "int") will be used + purely to validate that the corresponding value is of that type. It + will not Coerce the value: + + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data['10']"): + ... validate({'10': 'twenty'}) + + Wrap them in the Coerce() function to achieve this: + >>> from voluptuous import Coerce + >>> validate = Schema({'one': 'two', 'three': 'four', + ... Coerce(int): str}) + >>> validate({'10': 'twenty'}) + {10: 'twenty'} + + Custom message for required key + + >>> validate = Schema({Required('one', 'required'): 'two'}) + >>> with raises(er.MultipleInvalid, "required @ data['one']"): + ... validate({}) + + (This is to avoid unexpected surprises.) + + Multiple errors for nested field in a dict: + + >>> validate = Schema({ + ... 'adict': { + ... 'strfield': str, + ... 'intfield': int + ... } + ... }) + >>> try: + ... validate({ + ... 'adict': { + ... 'strfield': 123, + ... 'intfield': 'one' + ... } + ... }) + ... except er.MultipleInvalid as e: + ... print(sorted(str(i) for i in e.errors)) # doctest: +NORMALIZE_WHITESPACE + ["expected int for dictionary value @ data['adict']['intfield']", + "expected str for dictionary value @ data['adict']['strfield']"] + + """ + base_validate = self._compile_mapping(schema, invalid_msg='dictionary value') + + groups_of_exclusion = {} + groups_of_inclusion = {} + for node in schema: + if isinstance(node, Exclusive): + g = groups_of_exclusion.setdefault(node.group_of_exclusion, []) + g.append(node) + elif isinstance(node, Inclusive): + g = groups_of_inclusion.setdefault(node.group_of_inclusion, []) + g.append(node) + + def validate_dict(path, data): + if not isinstance(data, dict): + raise er.DictInvalid('expected a dictionary', path) + + errors = [] + for label, group in groups_of_exclusion.items(): + exists = False + for exclusive in group: + if exclusive.schema in data: + if exists: + msg = ( + exclusive.msg + if hasattr(exclusive, 'msg') and exclusive.msg + else "two or more values in the same group of exclusion '%s'" + % label + ) + next_path = path + [VirtualPathComponent(label)] + errors.append(er.ExclusiveInvalid(msg, next_path)) + break + exists = True + + if errors: + raise er.MultipleInvalid(errors) + + for label, group in groups_of_inclusion.items(): + included = [node.schema in data for node in group] + if any(included) and not all(included): + msg = ( + "some but not all values in the same group of inclusion '%s'" + % label + ) + for g in group: + if hasattr(g, 'msg') and g.msg: + msg = g.msg + break + next_path = path + [VirtualPathComponent(label)] + errors.append(er.InclusiveInvalid(msg, next_path)) + break + + if errors: + raise er.MultipleInvalid(errors) + + out = data.__class__() + return base_validate(path, data.items(), out) + + return validate_dict + + def _compile_sequence(self, schema, seq_type): + """Validate a sequence type. + + This is a sequence of valid values or validators tried in order. + + >>> validator = Schema(['one', 'two', int]) + >>> validator(['one']) + ['one'] + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator([3.5]) + >>> validator([1]) + [1] + """ + _compiled = [self._compile(s) for s in schema] + seq_type_name = seq_type.__name__ + + def validate_sequence(path, data): + if not isinstance(data, seq_type): + raise er.SequenceTypeInvalid('expected a %s' % seq_type_name, path) + + # Empty seq schema, reject any data. + if not schema: + if data: + raise er.MultipleInvalid( + [er.ValueInvalid('not a valid value', path if path else data)] + ) + return data + + out = [] + invalid = None + errors = [] + index_path = UNDEFINED + for i, value in enumerate(data): + index_path = path + [i] + invalid = None + for validate in _compiled: + try: + cval = validate(index_path, value) + if cval is not Remove: # do not include Remove values + out.append(cval) + break + except er.Invalid as e: + if len(e.path) > len(index_path): + raise + invalid = e + else: + errors.append(invalid) + if errors: + raise er.MultipleInvalid(errors) + + if _isnamedtuple(data): + return type(data)(*out) + else: + return type(data)(out) + + return validate_sequence + + def _compile_tuple(self, schema): + """Validate a tuple. + + A tuple is a sequence of valid values or validators tried in order. + + >>> validator = Schema(('one', 'two', int)) + >>> validator(('one',)) + ('one',) + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator((3.5,)) + >>> validator((1,)) + (1,) + """ + return self._compile_sequence(schema, tuple) + + def _compile_list(self, schema): + """Validate a list. + + A list is a sequence of valid values or validators tried in order. + + >>> validator = Schema(['one', 'two', int]) + >>> validator(['one']) + ['one'] + >>> with raises(er.MultipleInvalid, 'expected int @ data[0]'): + ... validator([3.5]) + >>> validator([1]) + [1] + """ + return self._compile_sequence(schema, list) + + def _compile_set(self, schema): + """Validate a set. + + A set is an unordered collection of unique elements. + + >>> validator = Schema({int}) + >>> validator(set([42])) == set([42]) + True + >>> with raises(er.Invalid, 'expected a set'): + ... validator(42) + >>> with raises(er.MultipleInvalid, 'invalid value in set'): + ... validator(set(['a'])) + """ + type_ = type(schema) + type_name = type_.__name__ + + def validate_set(path, data): + if not isinstance(data, type_): + raise er.Invalid('expected a %s' % type_name, path) + + _compiled = [self._compile(s) for s in schema] + errors = [] + for value in data: + for validate in _compiled: + try: + validate(path, value) + break + except er.Invalid: + pass + else: + invalid = er.Invalid('invalid value in %s' % type_name, path) + errors.append(invalid) + + if errors: + raise er.MultipleInvalid(errors) + + return data + + return validate_set + + def extend( + self, + schema: Schemable, + required: typing.Optional[bool] = None, + extra: typing.Optional[int] = None, + ) -> Schema: + """Create a new `Schema` by merging this and the provided `schema`. + + Neither this `Schema` nor the provided `schema` are modified. The + resulting `Schema` inherits the `required` and `extra` parameters of + this, unless overridden. + + Both schemas must be dictionary-based. + + :param schema: dictionary to extend this `Schema` with + :param required: if set, overrides `required` of this `Schema` + :param extra: if set, overrides `extra` of this `Schema` + """ + + assert isinstance(self.schema, dict) and isinstance( + schema, dict + ), 'Both schemas must be dictionary-based' + + result = self.schema.copy() + + # returns the key that may have been passed as an argument to Marker constructor + def key_literal(key): + return key.schema if isinstance(key, Marker) else key + + # build a map that takes the key literals to the needed objects + # literal -> Required|Optional|literal + result_key_map = dict((key_literal(key), key) for key in result) + + # for each item in the extension schema, replace duplicates + # or add new keys + for key, value in schema.items(): + # if the key is already in the dictionary, we need to replace it + # transform key to literal before checking presence + if key_literal(key) in result_key_map: + result_key = result_key_map[key_literal(key)] + result_value = result[result_key] + + # if both are dictionaries, we need to extend recursively + # create the new extended sub schema, then remove the old key and add the new one + if isinstance(result_value, dict) and isinstance(value, dict): + new_value = Schema(result_value).extend(value).schema + del result[result_key] + result[key] = new_value + # one or the other or both are not sub-schemas, simple replacement is fine + # remove old key and add new one + else: + del result[result_key] + result[key] = value + + # key is new and can simply be added + else: + result[key] = value + + # recompile and send old object + result_cls = type(self) + result_required = required if required is not None else self.required + result_extra = extra if extra is not None else self.extra + return result_cls(result, required=result_required, extra=result_extra) + + +def _compile_scalar(schema): + """A scalar value. + + The schema can either be a value or a type. + + >>> _compile_scalar(int)([], 1) + 1 + >>> with raises(er.Invalid, 'expected float'): + ... _compile_scalar(float)([], '1') + + Callables have + >>> _compile_scalar(lambda v: float(v))([], '1') + 1.0 + + As a convenience, ValueError's are trapped: + + >>> with raises(er.Invalid, 'not a valid value'): + ... _compile_scalar(lambda v: float(v))([], 'a') + """ + if inspect.isclass(schema): + + def validate_instance(path, data): + if isinstance(data, schema): + return data + else: + msg = 'expected %s' % schema.__name__ + raise er.TypeInvalid(msg, path) + + return validate_instance + + if callable(schema): + + def validate_callable(path, data): + try: + return schema(data) + except ValueError: + raise er.ValueInvalid('not a valid value', path) + except er.Invalid as e: + e.prepend(path) + raise + + return validate_callable + + def validate_value(path, data): + if data != schema: + raise er.ScalarInvalid('not a valid value', path) + return data + + return validate_value + + +def _compile_itemsort(): + '''return sort function of mappings''' + + def is_extra(key_): + return key_ is Extra + + def is_remove(key_): + return isinstance(key_, Remove) + + def is_marker(key_): + return isinstance(key_, Marker) + + def is_type(key_): + return inspect.isclass(key_) + + def is_callable(key_): + return callable(key_) + + # priority list for map sorting (in order of checking) + # We want Extra to match last, because it's a catch-all. On the other hand, + # Remove markers should match first (since invalid values will not + # raise an Error, instead the validator will check if other schemas match + # the same value). + priority = [ + (1, is_remove), # Remove highest priority after values + (2, is_marker), # then other Markers + (4, is_type), # types/classes lowest before Extra + (3, is_callable), # callables after markers + (5, is_extra), # Extra lowest priority + ] + + def item_priority(item_): + key_ = item_[0] + for i, check_ in priority: + if check_(key_): + return i + # values have highest priorities + return 0 + + return item_priority + + +_sort_item = _compile_itemsort() + + +def _iterate_mapping_candidates(schema): + """Iterate over schema in a meaningful order.""" + # Without this, Extra might appear first in the iterator, and fail to + # validate a key even though it's a Required that has its own validation, + # generating a false positive. + return sorted(schema.items(), key=_sort_item) + + +def _iterate_object(obj): + """Return iterator over object attributes. Respect objects with + defined __slots__. + + """ + d = {} + try: + d = vars(obj) + except TypeError: + # maybe we have named tuple here? + if hasattr(obj, '_asdict'): + d = obj._asdict() + for item in d.items(): + yield item + try: + slots = obj.__slots__ + except AttributeError: + pass + else: + for key in slots: + if key != '__dict__': + yield (key, getattr(obj, key)) + + +class Msg(object): + """Report a user-friendly message if a schema fails to validate. + + >>> validate = Schema( + ... Msg(['one', 'two', int], + ... 'should be one of "one", "two" or an integer')) + >>> with raises(er.MultipleInvalid, 'should be one of "one", "two" or an integer'): + ... validate(['three']) + + Messages are only applied to invalid direct descendants of the schema: + + >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!')) + >>> with raises(er.MultipleInvalid, 'expected int @ data[0][0]'): + ... validate([['three']]) + + The type which is thrown can be overridden but needs to be a subclass of Invalid + + >>> with raises(er.SchemaError, 'Msg can only use subclases of Invalid as custom class'): + ... validate = Schema(Msg([int], 'should be int', cls=KeyError)) + + If you do use a subclass of Invalid, that error will be thrown (wrapped in a MultipleInvalid) + + >>> validate = Schema(Msg([['one', 'two', int]], 'not okay!', cls=er.RangeInvalid)) + >>> try: + ... validate(['three']) + ... except er.MultipleInvalid as e: + ... assert isinstance(e.errors[0], er.RangeInvalid) + """ + + def __init__( + self, + schema: Schemable, + msg: str, + cls: typing.Optional[typing.Type[Error]] = None, + ) -> None: + if cls and not issubclass(cls, er.Invalid): + raise er.SchemaError( + "Msg can only use subclases of Invalid as custom class" + ) + self._schema = schema + self.schema = Schema(schema) + self.msg = msg + self.cls = cls + + def __call__(self, v): + try: + return self.schema(v) + except er.Invalid as e: + if len(e.path) > 1: + raise e + else: + raise (self.cls or er.Invalid)(self.msg) + + def __repr__(self): + return 'Msg(%s, %s, cls=%s)' % (self._schema, self.msg, self.cls) + + +class Object(dict): + """Indicate that we should work with attributes, not keys.""" + + def __init__(self, schema: typing.Any, cls: object = UNDEFINED) -> None: + self.cls = cls + super(Object, self).__init__(schema) + + +class VirtualPathComponent(str): + def __str__(self): + return '<' + self + '>' + + def __repr__(self): + return self.__str__() + + +class Marker(object): + """Mark nodes for special treatment. + + `description` is an optional field, unused by Voluptuous itself, but can be + introspected by any external tool, for example to generate schema documentation. + """ + + __slots__ = ('schema', '_schema', 'msg', 'description', '__hash__') + + def __init__( + self, + schema_: Schemable, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + self.schema: typing.Any = schema_ + self._schema = Schema(schema_) + self.msg = msg + self.description = description + self.__hash__ = cache(lambda: hash(schema_)) # type: ignore[method-assign] + + def __call__(self, v): + try: + return self._schema(v) + except er.Invalid as e: + if not self.msg or len(e.path) > 1: + raise + raise er.Invalid(self.msg) + + def __str__(self): + return str(self.schema) + + def __repr__(self): + return repr(self.schema) + + def __lt__(self, other): + if isinstance(other, Marker): + return self.schema < other.schema + return self.schema < other + + def __eq__(self, other): + return self.schema == other + + def __ne__(self, other): + return not (self.schema == other) + + +class Optional(Marker): + """Mark a node in the schema as optional, and optionally provide a default + + >>> schema = Schema({Optional('key'): str}) + >>> schema({}) + {} + >>> schema = Schema({Optional('key', default='value'): str}) + >>> schema({}) + {'key': 'value'} + >>> schema = Schema({Optional('key', default=list): list}) + >>> schema({}) + {'key': []} + + If 'required' flag is set for an entire schema, optional keys aren't required + + >>> schema = Schema({ + ... Optional('key'): str, + ... 'key2': str + ... }, required=True) + >>> schema({'key2':'value'}) + {'key2': 'value'} + """ + + def __init__( + self, + schema: Schemable, + msg: typing.Optional[str] = None, + default: typing.Any = UNDEFINED, + description: typing.Any | None = None, + ) -> None: + super(Optional, self).__init__(schema, msg=msg, description=description) + self.default = default_factory(default) + + +class Exclusive(Optional): + """Mark a node in the schema as exclusive. + + Exclusive keys inherited from Optional: + + >>> schema = Schema({Exclusive('alpha', 'angles'): int, Exclusive('beta', 'angles'): int}) + >>> schema({'alpha': 30}) + {'alpha': 30} + + Keys inside a same group of exclusion cannot be together, it only makes sense for dictionaries: + + >>> with raises(er.MultipleInvalid, "two or more values in the same group of exclusion 'angles' @ data[]"): + ... schema({'alpha': 30, 'beta': 45}) + + For example, API can provides multiple types of authentication, but only one works in the same time: + + >>> msg = 'Please, use only one type of authentication at the same time.' + >>> schema = Schema({ + ... Exclusive('classic', 'auth', msg=msg):{ + ... Required('email'): str, + ... Required('password'): str + ... }, + ... Exclusive('internal', 'auth', msg=msg):{ + ... Required('secret_key'): str + ... }, + ... Exclusive('social', 'auth', msg=msg):{ + ... Required('social_network'): str, + ... Required('token'): str + ... } + ... }) + + >>> with raises(er.MultipleInvalid, "Please, use only one type of authentication at the same time. @ data[]"): + ... schema({'classic': {'email': 'foo@example.com', 'password': 'bar'}, + ... 'social': {'social_network': 'barfoo', 'token': 'tEMp'}}) + """ + + def __init__( + self, + schema: Schemable, + group_of_exclusion: str, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + super(Exclusive, self).__init__(schema, msg=msg, description=description) + self.group_of_exclusion = group_of_exclusion + + +class Inclusive(Optional): + """Mark a node in the schema as inclusive. + + Inclusive keys inherited from Optional: + + >>> schema = Schema({ + ... Inclusive('filename', 'file'): str, + ... Inclusive('mimetype', 'file'): str + ... }) + >>> data = {'filename': 'dog.jpg', 'mimetype': 'image/jpeg'} + >>> data == schema(data) + True + + Keys inside a same group of inclusive must exist together, it only makes sense for dictionaries: + + >>> with raises(er.MultipleInvalid, "some but not all values in the same group of inclusion 'file' @ data[]"): + ... schema({'filename': 'dog.jpg'}) + + If none of the keys in the group are present, it is accepted: + + >>> schema({}) + {} + + For example, API can return 'height' and 'width' together, but not separately. + + >>> msg = "Height and width must exist together" + >>> schema = Schema({ + ... Inclusive('height', 'size', msg=msg): int, + ... Inclusive('width', 'size', msg=msg): int + ... }) + + >>> with raises(er.MultipleInvalid, msg + " @ data[]"): + ... schema({'height': 100}) + + >>> with raises(er.MultipleInvalid, msg + " @ data[]"): + ... schema({'width': 100}) + + >>> data = {'height': 100, 'width': 100} + >>> data == schema(data) + True + """ + + def __init__( + self, + schema: Schemable, + group_of_inclusion: str, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + default: typing.Any = UNDEFINED, + ) -> None: + super(Inclusive, self).__init__( + schema, msg=msg, default=default, description=description + ) + self.group_of_inclusion = group_of_inclusion + + +class Required(Marker): + """Mark a node in the schema as being required, and optionally provide a default value. + + >>> schema = Schema({Required('key'): str}) + >>> with raises(er.MultipleInvalid, "required key not provided @ data['key']"): + ... schema({}) + + >>> schema = Schema({Required('key', default='value'): str}) + >>> schema({}) + {'key': 'value'} + >>> schema = Schema({Required('key', default=list): list}) + >>> schema({}) + {'key': []} + """ + + def __init__( + self, + schema: Schemable, + msg: typing.Optional[str] = None, + default: typing.Any = UNDEFINED, + description: typing.Any | None = None, + ) -> None: + super(Required, self).__init__(schema, msg=msg, description=description) + self.default = default_factory(default) + + +class Remove(Marker): + """Mark a node in the schema to be removed and excluded from the validated + output. Keys that fail validation will not raise ``Invalid``. Instead, these + keys will be treated as extras. + + >>> schema = Schema({str: int, Remove(int): str}) + >>> with raises(er.MultipleInvalid, "extra keys not allowed @ data[1]"): + ... schema({'keep': 1, 1: 1.0}) + >>> schema({1: 'red', 'red': 1, 2: 'green'}) + {'red': 1} + >>> schema = Schema([int, Remove(float), Extra]) + >>> schema([1, 2, 3, 4.0, 5, 6.0, '7']) + [1, 2, 3, 5, '7'] + """ + + def __init__( + self, + schema_: Schemable, + msg: typing.Optional[str] = None, + description: typing.Any | None = None, + ) -> None: + super().__init__(schema_, msg, description) + self.__hash__ = cache(lambda: object.__hash__(self)) # type: ignore[method-assign] + + def __call__(self, schema: Schemable): + super(Remove, self).__call__(schema) + return self.__class__ + + def __repr__(self): + return "Remove(%r)" % (self.schema,) + + +def message( + default: typing.Optional[str] = None, + cls: typing.Optional[typing.Type[Error]] = None, +) -> typing.Callable: + """Convenience decorator to allow functions to provide a message. + + Set a default message: + + >>> @message('not an integer') + ... def isint(v): + ... return int(v) + + >>> validate = Schema(isint()) + >>> with raises(er.MultipleInvalid, 'not an integer'): + ... validate('a') + + The message can be overridden on a per validator basis: + + >>> validate = Schema(isint('bad')) + >>> with raises(er.MultipleInvalid, 'bad'): + ... validate('a') + + The class thrown too: + + >>> class IntegerInvalid(er.Invalid): pass + >>> validate = Schema(isint('bad', clsoverride=IntegerInvalid)) + >>> try: + ... validate('a') + ... except er.MultipleInvalid as e: + ... assert isinstance(e.errors[0], IntegerInvalid) + """ + if cls and not issubclass(cls, er.Invalid): + raise er.SchemaError( + "message can only use subclases of Invalid as custom class" + ) + + def decorator(f): + @wraps(f) + def check(msg=None, clsoverride=None): + @wraps(f) + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValueError: + raise (clsoverride or cls or er.ValueInvalid)( + msg or default or 'invalid value' + ) + + return wrapper + + return check + + return decorator + + +def _args_to_dict(func, args): + """Returns argument names as values as key-value pairs.""" + if sys.version_info >= (3, 0): + arg_count = func.__code__.co_argcount + arg_names = func.__code__.co_varnames[:arg_count] + else: + arg_count = func.func_code.co_argcount + arg_names = func.func_code.co_varnames[:arg_count] + + arg_value_list = list(args) + arguments = dict( + (arg_name, arg_value_list[i]) + for i, arg_name in enumerate(arg_names) + if i < len(arg_value_list) + ) + return arguments + + +def _merge_args_with_kwargs(args_dict, kwargs_dict): + """Merge args with kwargs.""" + ret = args_dict.copy() + ret.update(kwargs_dict) + return ret + + +def validate(*a, **kw) -> typing.Callable: + """Decorator for validating arguments of a function against a given schema. + + Set restrictions for arguments: + + >>> @validate(arg1=int, arg2=int) + ... def foo(arg1, arg2): + ... return arg1 * arg2 + + Set restriction for returned value: + + >>> @validate(arg=int, __return__=int) + ... def bar(arg1): + ... return arg1 * 2 + + """ + RETURNS_KEY = '__return__' + + def validate_schema_decorator(func): + returns_defined = False + returns = None + + schema_args_dict = _args_to_dict(func, a) + schema_arguments = _merge_args_with_kwargs(schema_args_dict, kw) + + if RETURNS_KEY in schema_arguments: + returns_defined = True + returns = schema_arguments[RETURNS_KEY] + del schema_arguments[RETURNS_KEY] + + input_schema = ( + Schema(schema_arguments, extra=ALLOW_EXTRA) + if len(schema_arguments) != 0 + else lambda x: x + ) + output_schema = Schema(returns) if returns_defined else lambda x: x + + @wraps(func) + def func_wrapper(*args, **kwargs): + args_dict = _args_to_dict(func, args) + arguments = _merge_args_with_kwargs(args_dict, kwargs) + validated_arguments = input_schema(arguments) + output = func(**validated_arguments) + return output_schema(output) + + return func_wrapper + + return validate_schema_decorator diff --git a/server/libs/voluptuous/util.py b/server/libs/voluptuous/util.py new file mode 100644 index 0000000..0bf9302 --- /dev/null +++ b/server/libs/voluptuous/util.py @@ -0,0 +1,149 @@ +# F401: "imported but unused" +# fmt: off +import typing + +from voluptuous import validators # noqa: F401 +from voluptuous.error import Invalid, LiteralInvalid, TypeInvalid # noqa: F401 +from voluptuous.schema_builder import DefaultFactory # noqa: F401 +from voluptuous.schema_builder import Schema, default_factory, raises # noqa: F401 + +# fmt: on + +__author__ = 'tusharmakkar08' + + +def Lower(v: str) -> str: + """Transform a string to lower case. + + >>> s = Schema(Lower) + >>> s('HI') + 'hi' + """ + return str(v).lower() + + +def Upper(v: str) -> str: + """Transform a string to upper case. + + >>> s = Schema(Upper) + >>> s('hi') + 'HI' + """ + return str(v).upper() + + +def Capitalize(v: str) -> str: + """Capitalise a string. + + >>> s = Schema(Capitalize) + >>> s('hello world') + 'Hello world' + """ + return str(v).capitalize() + + +def Title(v: str) -> str: + """Title case a string. + + >>> s = Schema(Title) + >>> s('hello world') + 'Hello World' + """ + return str(v).title() + + +def Strip(v: str) -> str: + """Strip whitespace from a string. + + >>> s = Schema(Strip) + >>> s(' hello world ') + 'hello world' + """ + return str(v).strip() + + +class DefaultTo(object): + """Sets a value to default_value if none provided. + + >>> s = Schema(DefaultTo(42)) + >>> s(None) + 42 + >>> s = Schema(DefaultTo(list)) + >>> s(None) + [] + """ + + def __init__(self, default_value, msg: typing.Optional[str] = None) -> None: + self.default_value = default_factory(default_value) + self.msg = msg + + def __call__(self, v): + if v is None: + v = self.default_value() + return v + + def __repr__(self): + return 'DefaultTo(%s)' % (self.default_value(),) + + +class SetTo(object): + """Set a value, ignoring any previous value. + + >>> s = Schema(validators.Any(int, SetTo(42))) + >>> s(2) + 2 + >>> s("foo") + 42 + """ + + def __init__(self, value) -> None: + self.value = default_factory(value) + + def __call__(self, v): + return self.value() + + def __repr__(self): + return 'SetTo(%s)' % (self.value(),) + + +class Set(object): + """Convert a list into a set. + + >>> s = Schema(Set()) + >>> s([]) == set([]) + True + >>> s([1, 2]) == set([1, 2]) + True + >>> with raises(Invalid, regex="^cannot be presented as set: "): + ... s([set([1, 2]), set([3, 4])]) + """ + + def __init__(self, msg: typing.Optional[str] = None) -> None: + self.msg = msg + + def __call__(self, v): + try: + set_v = set(v) + except Exception as e: + raise TypeInvalid(self.msg or 'cannot be presented as set: {0}'.format(e)) + return set_v + + def __repr__(self): + return 'Set()' + + +class Literal(object): + def __init__(self, lit) -> None: + self.lit = lit + + def __call__(self, value, msg: typing.Optional[str] = None): + if self.lit != value: + raise LiteralInvalid(msg or '%s not match for %s' % (value, self.lit)) + else: + return self.lit + + def __str__(self): + return str(self.lit) + + def __repr__(self): + return repr(self.lit) diff --git a/server/libs/voluptuous/validators.py b/server/libs/voluptuous/validators.py new file mode 100644 index 0000000..d385260 --- /dev/null +++ b/server/libs/voluptuous/validators.py @@ -0,0 +1,1248 @@ +# fmt: off +from __future__ import annotations + +import datetime +import os +import re +import sys +import typing +from decimal import Decimal, InvalidOperation +from functools import wraps + +from voluptuous.error import ( + AllInvalid, AnyInvalid, BooleanInvalid, CoerceInvalid, ContainsInvalid, DateInvalid, + DatetimeInvalid, DirInvalid, EmailInvalid, ExactSequenceInvalid, FalseInvalid, + FileInvalid, InInvalid, Invalid, LengthInvalid, MatchInvalid, MultipleInvalid, + NotEnoughValid, NotInInvalid, PathInvalid, RangeInvalid, TooManyValid, TrueInvalid, + TypeInvalid, UrlInvalid, +) + +# F401: flake8 complains about 'raises' not being used, but it is used in doctests +from voluptuous.schema_builder import Schema, Schemable, message, raises # noqa: F401 + +if typing.TYPE_CHECKING: + from _typeshed import SupportsAllComparisons + +# fmt: on + + +Enum: typing.Union[type, None] +try: + from enum import Enum +except ImportError: + Enum = None + + +if sys.version_info >= (3,): + import urllib.parse as urlparse + + basestring = str +else: + import urlparse + +# Taken from https://github.com/kvesteri/validators/blob/master/validators/email.py +# fmt: off +USER_REGEX = re.compile( + # start anchor, because fullmatch is not available in python 2.7 + "(?:" + # dot-atom + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+" + r"(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*$" + # quoted-string + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|' + r"""\\[\001-\011\013\014\016-\177])*"$)""" + # end anchor, because fullmatch is not available in python 2.7 + r")\Z", + re.IGNORECASE, +) +DOMAIN_REGEX = re.compile( + # start anchor, because fullmatch is not available in python 2.7 + "(?:" + # domain + r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' + # tld + r'(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?$)' + # literal form, ipv4 address (SMTP 4.1.3) + r'|^\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)' + r'(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]$' + # end anchor, because fullmatch is not available in python 2.7 + r")\Z", + re.IGNORECASE, +) +# fmt: on + +__author__ = 'tusharmakkar08' + + +def truth(f: typing.Callable) -> typing.Callable: + """Convenience decorator to convert truth functions into validators. + + >>> @truth + ... def isdir(v): + ... return os.path.isdir(v) + >>> validate = Schema(isdir) + >>> validate('/') + '/' + >>> with raises(MultipleInvalid, 'not a valid value'): + ... validate('/notavaliddir') + """ + + @wraps(f) + def check(v): + t = f(v) + if not t: + raise ValueError + return v + + return check + + +class Coerce(object): + """Coerce a value to a type. + + If the type constructor throws a ValueError or TypeError, the value + will be marked as Invalid. + + Default behavior: + + >>> validate = Schema(Coerce(int)) + >>> with raises(MultipleInvalid, 'expected int'): + ... validate(None) + >>> with raises(MultipleInvalid, 'expected int'): + ... validate('foo') + + With custom message: + + >>> validate = Schema(Coerce(int, "moo")) + >>> with raises(MultipleInvalid, 'moo'): + ... validate('foo') + """ + + def __init__( + self, + type: typing.Union[type, typing.Callable], + msg: typing.Optional[str] = None, + ) -> None: + self.type = type + self.msg = msg + self.type_name = type.__name__ + + def __call__(self, v): + try: + return self.type(v) + except (ValueError, TypeError, InvalidOperation): + msg = self.msg or ('expected %s' % self.type_name) + if not self.msg and Enum and issubclass(self.type, Enum): + msg += " or one of %s" % str([e.value for e in self.type])[1:-1] + raise CoerceInvalid(msg) + + def __repr__(self): + return 'Coerce(%s, msg=%r)' % (self.type_name, self.msg) + + +@message('value was not true', cls=TrueInvalid) +@truth +def IsTrue(v): + """Assert that a value is true, in the Python sense. + + >>> validate = Schema(IsTrue()) + + "In the Python sense" means that implicitly false values, such as empty + lists, dictionaries, etc. are treated as "false": + + >>> with raises(MultipleInvalid, "value was not true"): + ... validate([]) + >>> validate([1]) + [1] + >>> with raises(MultipleInvalid, "value was not true"): + ... validate(False) + + ...and so on. + + >>> try: + ... validate([]) + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], TrueInvalid) + """ + return v + + +@message('value was not false', cls=FalseInvalid) +def IsFalse(v): + """Assert that a value is false, in the Python sense. + + (see :func:`IsTrue` for more detail) + + >>> validate = Schema(IsFalse()) + >>> validate([]) + [] + >>> with raises(MultipleInvalid, "value was not false"): + ... validate(True) + + >>> try: + ... validate(True) + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], FalseInvalid) + """ + if v: + raise ValueError + return v + + +@message('expected boolean', cls=BooleanInvalid) +def Boolean(v): + """Convert human-readable boolean values to a bool. + + Accepted values are 1, true, yes, on, enable, and their negatives. + Non-string values are cast to bool. + + >>> validate = Schema(Boolean()) + >>> validate(True) + True + >>> validate("1") + True + >>> validate("0") + False + >>> with raises(MultipleInvalid, "expected boolean"): + ... validate('moo') + >>> try: + ... validate('moo') + ... except MultipleInvalid as e: + ... assert isinstance(e.errors[0], BooleanInvalid) + """ + if isinstance(v, basestring): + v = v.lower() + if v in ('1', 'true', 'yes', 'on', 'enable'): + return True + if v in ('0', 'false', 'no', 'off', 'disable'): + return False + raise ValueError + return bool(v) + + +class _WithSubValidators(object): + """Base class for validators that use sub-validators. + + Special class to use as a parent class for validators using sub-validators. + This class provides the `__voluptuous_compile__` method so the + sub-validators are compiled by the parent `Schema`. + """ + + def __init__( + self, *validators, msg=None, required=False, discriminant=None, **kwargs + ) -> None: + self.validators = validators + self.msg = msg + self.required = required + self.discriminant = discriminant + + def __voluptuous_compile__(self, schema: Schema) -> typing.Callable: + self._compiled = [] + old_required = schema.required + self.schema = schema + for v in self.validators: + schema.required = self.required + self._compiled.append(schema._compile(v)) + schema.required = old_required + return self._run + + def _run(self, path: typing.List[typing.Hashable], value): + if self.discriminant is not None: + self._compiled = [ + self.schema._compile(v) + for v in self.discriminant(value, self.validators) + ] + + return self._exec(self._compiled, value, path) + + def __call__(self, v): + return self._exec((Schema(val) for val in self.validators), v) + + def __repr__(self): + return '%s(%s, msg=%r)' % ( + self.__class__.__name__, + ", ".join(repr(v) for v in self.validators), + self.msg, + ) + + def _exec( + self, + funcs: typing.Iterable, + v, + path: typing.Optional[typing.List[typing.Hashable]] = None, + ): + raise NotImplementedError() + + +class Any(_WithSubValidators): + """Use the first validated value. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + :returns: Return value of the first validator that passes. + + >>> validate = Schema(Any('true', 'false', + ... All(Any(int, bool), Coerce(bool)))) + >>> validate('true') + 'true' + >>> validate(1) + True + >>> with raises(MultipleInvalid, "not a valid value"): + ... validate('moo') + + msg argument is used + + >>> validate = Schema(Any(1, 2, 3, msg="Expected 1 2 or 3")) + >>> validate(1) + 1 + >>> with raises(MultipleInvalid, "Expected 1 2 or 3"): + ... validate(4) + """ + + def _exec(self, funcs, v, path=None): + error = None + for func in funcs: + try: + if path is None: + return func(v) + else: + return func(path, v) + except Invalid as e: + if error is None or len(e.path) > len(error.path): + error = e + else: + if error: + raise error if self.msg is None else AnyInvalid(self.msg, path=path) + raise AnyInvalid(self.msg or 'no valid value found', path=path) + + +# Convenience alias +Or = Any + + +class Union(_WithSubValidators): + """Use the first validated value among those selected by discriminant. + + :param msg: Message to deliver to user if validation fails. + :param discriminant(value, validators): Returns the filtered list of validators based on the value. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + :returns: Return value of the first validator that passes. + + >>> validate = Schema(Union({'type':'a', 'a_val':'1'},{'type':'b', 'b_val':'2'}, + ... discriminant=lambda val, alt: filter( + ... lambda v : v['type'] == val['type'] , alt))) + >>> validate({'type':'a', 'a_val':'1'}) == {'type':'a', 'a_val':'1'} + True + >>> with raises(MultipleInvalid, "not a valid value for dictionary value @ data['b_val']"): + ... validate({'type':'b', 'b_val':'5'}) + + ```discriminant({'type':'b', 'a_val':'5'}, [{'type':'a', 'a_val':'1'},{'type':'b', 'b_val':'2'}])``` is invoked + + Without the discriminant, the exception would be "extra keys not allowed @ data['b_val']" + """ + + def _exec(self, funcs, v, path=None): + error = None + for func in funcs: + try: + if path is None: + return func(v) + else: + return func(path, v) + except Invalid as e: + if error is None or len(e.path) > len(error.path): + error = e + else: + if error: + raise error if self.msg is None else AnyInvalid(self.msg, path=path) + raise AnyInvalid(self.msg or 'no valid value found', path=path) + + +# Convenience alias +Switch = Union + + +class All(_WithSubValidators): + """Value must pass all validators. + + The output of each validator is passed as input to the next. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + + >>> validate = Schema(All('10', Coerce(int))) + >>> validate('10') + 10 + """ + + def _exec(self, funcs, v, path=None): + try: + for func in funcs: + if path is None: + v = func(v) + else: + v = func(path, v) + except Invalid as e: + raise e if self.msg is None else AllInvalid(self.msg, path=path) + return v + + +# Convenience alias +And = All + + +class Match(object): + """Value must be a string that matches the regular expression. + + >>> validate = Schema(Match(r'^0x[A-F0-9]+$')) + >>> validate('0x123EF4') + '0x123EF4' + >>> with raises(MultipleInvalid, 'does not match regular expression ^0x[A-F0-9]+$'): + ... validate('123EF4') + + >>> with raises(MultipleInvalid, 'expected string or buffer'): + ... validate(123) + + Pattern may also be a compiled regular expression: + + >>> validate = Schema(Match(re.compile(r'0x[A-F0-9]+', re.I))) + >>> validate('0x123ef4') + '0x123ef4' + """ + + def __init__( + self, pattern: typing.Union[re.Pattern, str], msg: typing.Optional[str] = None + ) -> None: + if isinstance(pattern, basestring): + pattern = re.compile(pattern) + self.pattern = pattern + self.msg = msg + + def __call__(self, v): + try: + match = self.pattern.match(v) + except TypeError: + raise MatchInvalid("expected string or buffer") + if not match: + raise MatchInvalid( + self.msg + or 'does not match regular expression {}'.format(self.pattern.pattern) + ) + return v + + def __repr__(self): + return 'Match(%r, msg=%r)' % (self.pattern.pattern, self.msg) + + +class Replace(object): + """Regex substitution. + + >>> validate = Schema(All(Replace('you', 'I'), + ... Replace('hello', 'goodbye'))) + >>> validate('you say hello') + 'I say goodbye' + """ + + def __init__( + self, + pattern: typing.Union[re.Pattern, str], + substitution: str, + msg: typing.Optional[str] = None, + ) -> None: + if isinstance(pattern, basestring): + pattern = re.compile(pattern) + self.pattern = pattern + self.substitution = substitution + self.msg = msg + + def __call__(self, v): + return self.pattern.sub(self.substitution, v) + + def __repr__(self): + return 'Replace(%r, %r, msg=%r)' % ( + self.pattern.pattern, + self.substitution, + self.msg, + ) + + +def _url_validation(v: str) -> urlparse.ParseResult: + parsed = urlparse.urlparse(v) + if not parsed.scheme or not parsed.netloc: + raise UrlInvalid("must have a URL scheme and host") + return parsed + + +@message('expected an email address', cls=EmailInvalid) +def Email(v): + """Verify that the value is an email address or not. + + >>> s = Schema(Email()) + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a.com") + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a@.com") + >>> with raises(MultipleInvalid, 'expected an email address'): + ... s("a@.com") + >>> s('t@x.com') + 't@x.com' + """ + try: + if not v or "@" not in v: + raise EmailInvalid("Invalid email address") + user_part, domain_part = v.rsplit('@', 1) + + if not (USER_REGEX.match(user_part) and DOMAIN_REGEX.match(domain_part)): + raise EmailInvalid("Invalid email address") + return v + except: # noqa: E722 + raise ValueError + + +@message('expected a fully qualified domain name URL', cls=UrlInvalid) +def FqdnUrl(v): + """Verify that the value is a fully qualified domain name URL. + + >>> s = Schema(FqdnUrl()) + >>> with raises(MultipleInvalid, 'expected a fully qualified domain name URL'): + ... s("http://localhost/") + >>> s('http://w3.org') + 'http://w3.org' + """ + try: + parsed_url = _url_validation(v) + if "." not in parsed_url.netloc: + raise UrlInvalid("must have a domain name in URL") + return v + except: # noqa: E722 + raise ValueError + + +@message('expected a URL', cls=UrlInvalid) +def Url(v): + """Verify that the value is a URL. + + >>> s = Schema(Url()) + >>> with raises(MultipleInvalid, 'expected a URL'): + ... s(1) + >>> s('http://w3.org') + 'http://w3.org' + """ + try: + _url_validation(v) + return v + except: # noqa: E722 + raise ValueError + + +@message('Not a file', cls=FileInvalid) +@truth +def IsFile(v): + """Verify the file exists. + + >>> os.path.basename(IsFile()(__file__)).startswith('validators.py') + True + >>> with raises(FileInvalid, 'Not a file'): + ... IsFile()("random_filename_goes_here.py") + >>> with raises(FileInvalid, 'Not a file'): + ... IsFile()(None) + """ + try: + if v: + v = str(v) + return os.path.isfile(v) + else: + raise FileInvalid('Not a file') + except TypeError: + raise FileInvalid('Not a file') + + +@message('Not a directory', cls=DirInvalid) +@truth +def IsDir(v): + """Verify the directory exists. + + >>> IsDir()('/') + '/' + >>> with raises(DirInvalid, 'Not a directory'): + ... IsDir()(None) + """ + try: + if v: + v = str(v) + return os.path.isdir(v) + else: + raise DirInvalid("Not a directory") + except TypeError: + raise DirInvalid("Not a directory") + + +@message('path does not exist', cls=PathInvalid) +@truth +def PathExists(v): + """Verify the path exists, regardless of its type. + + >>> os.path.basename(PathExists()(__file__)).startswith('validators.py') + True + >>> with raises(Invalid, 'path does not exist'): + ... PathExists()("random_filename_goes_here.py") + >>> with raises(PathInvalid, 'Not a Path'): + ... PathExists()(None) + """ + try: + if v: + v = str(v) + return os.path.exists(v) + else: + raise PathInvalid("Not a Path") + except TypeError: + raise PathInvalid("Not a Path") + + +def Maybe(validator: Schemable, msg: typing.Optional[str] = None): + """Validate that the object matches given validator or is None. + + :raises Invalid: If the value does not match the given validator and is not + None. + + >>> s = Schema(Maybe(int)) + >>> s(10) + 10 + >>> with raises(Invalid): + ... s("string") + + """ + return Any(None, validator, msg=msg) + + +class Range(object): + """Limit a value to a range. + + Either min or max may be omitted. + Either min or max can be excluded from the range of accepted values. + + :raises Invalid: If the value is outside the range. + + >>> s = Schema(Range(min=1, max=10, min_included=False)) + >>> s(5) + 5 + >>> s(10) + 10 + >>> with raises(MultipleInvalid, 'value must be at most 10'): + ... s(20) + >>> with raises(MultipleInvalid, 'value must be higher than 1'): + ... s(1) + >>> with raises(MultipleInvalid, 'value must be lower than 10'): + ... Schema(Range(max=10, max_included=False))(20) + """ + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + min_included: bool = True, + max_included: bool = True, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.min_included = min_included + self.max_included = max_included + self.msg = msg + + def __call__(self, v): + try: + if self.min_included: + if self.min is not None and not v >= self.min: + raise RangeInvalid( + self.msg or 'value must be at least %s' % self.min + ) + else: + if self.min is not None and not v > self.min: + raise RangeInvalid( + self.msg or 'value must be higher than %s' % self.min + ) + if self.max_included: + if self.max is not None and not v <= self.max: + raise RangeInvalid( + self.msg or 'value must be at most %s' % self.max + ) + else: + if self.max is not None and not v < self.max: + raise RangeInvalid( + self.msg or 'value must be lower than %s' % self.max + ) + + return v + + # Objects that lack a partial ordering, e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid( + self.msg or 'invalid value or type (must have a partial ordering)' + ) + + def __repr__(self): + return 'Range(min=%r, max=%r, min_included=%r, max_included=%r, msg=%r)' % ( + self.min, + self.max, + self.min_included, + self.max_included, + self.msg, + ) + + +class Clamp(object): + """Clamp a value to a range. + + Either min or max may be omitted. + + >>> s = Schema(Clamp(min=0, max=1)) + >>> s(0.5) + 0.5 + >>> s(5) + 1 + >>> s(-1) + 0 + """ + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.msg = msg + + def __call__(self, v): + try: + if self.min is not None and v < self.min: + v = self.min + if self.max is not None and v > self.max: + v = self.max + return v + + # Objects that lack a partial ordering, e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid( + self.msg or 'invalid value or type (must have a partial ordering)' + ) + + def __repr__(self): + return 'Clamp(min=%s, max=%s)' % (self.min, self.max) + + +class Length(object): + """The length of a value must be in a certain range.""" + + def __init__( + self, + min: SupportsAllComparisons | None = None, + max: SupportsAllComparisons | None = None, + msg: typing.Optional[str] = None, + ) -> None: + self.min = min + self.max = max + self.msg = msg + + def __call__(self, v): + try: + if self.min is not None and len(v) < self.min: + raise LengthInvalid( + self.msg or 'length of value must be at least %s' % self.min + ) + if self.max is not None and len(v) > self.max: + raise LengthInvalid( + self.msg or 'length of value must be at most %s' % self.max + ) + return v + + # Objects that have no length e.g. None or strings will raise TypeError + except TypeError: + raise RangeInvalid(self.msg or 'invalid value or type') + + def __repr__(self): + return 'Length(min=%s, max=%s)' % (self.min, self.max) + + +class Datetime(object): + """Validate that the value matches the datetime format.""" + + DEFAULT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' + + def __init__( + self, format: typing.Optional[str] = None, msg: typing.Optional[str] = None + ) -> None: + self.format = format or self.DEFAULT_FORMAT + self.msg = msg + + def __call__(self, v): + try: + datetime.datetime.strptime(v, self.format) + except (TypeError, ValueError): + raise DatetimeInvalid( + self.msg or 'value does not match expected format %s' % self.format + ) + return v + + def __repr__(self): + return 'Datetime(format=%s)' % self.format + + +class Date(Datetime): + """Validate that the value matches the date format.""" + + DEFAULT_FORMAT = '%Y-%m-%d' + + def __call__(self, v): + try: + datetime.datetime.strptime(v, self.format) + except (TypeError, ValueError): + raise DateInvalid( + self.msg or 'value does not match expected format %s' % self.format + ) + return v + + def __repr__(self): + return 'Date(format=%s)' % self.format + + +class In(object): + """Validate that a value is in a collection.""" + + def __init__( + self, container: typing.Container, msg: typing.Optional[str] = None + ) -> None: + self.container = container + self.msg = msg + + def __call__(self, v): + try: + check = v not in self.container + except TypeError: + check = True + if check: + try: + raise InInvalid( + self.msg or f'value must be one of {sorted(self.container)}' + ) + except TypeError: + raise InInvalid( + self.msg + or f'value must be one of {sorted(self.container, key=str)}' + ) + return v + + def __repr__(self): + return 'In(%s)' % (self.container,) + + +class NotIn(object): + """Validate that a value is not in a collection.""" + + def __init__( + self, container: typing.Iterable, msg: typing.Optional[str] = None + ) -> None: + self.container = container + self.msg = msg + + def __call__(self, v): + try: + check = v in self.container + except TypeError: + check = True + if check: + try: + raise NotInInvalid( + self.msg or f'value must not be one of {sorted(self.container)}' + ) + except TypeError: + raise NotInInvalid( + self.msg + or f'value must not be one of {sorted(self.container, key=str)}' + ) + return v + + def __repr__(self): + return 'NotIn(%s)' % (self.container,) + + +class Contains(object): + """Validate that the given schema element is in the sequence being validated. + + >>> s = Contains(1) + >>> s([3, 2, 1]) + [3, 2, 1] + >>> with raises(ContainsInvalid, 'value is not allowed'): + ... s([3, 2]) + """ + + def __init__(self, item, msg: typing.Optional[str] = None) -> None: + self.item = item + self.msg = msg + + def __call__(self, v): + try: + check = self.item not in v + except TypeError: + check = True + if check: + raise ContainsInvalid(self.msg or 'value is not allowed') + return v + + def __repr__(self): + return 'Contains(%s)' % (self.item,) + + +class ExactSequence(object): + """Matches each element in a sequence against the corresponding element in + the validators. + + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema + constructors. + + >>> from voluptuous import Schema, ExactSequence + >>> validate = Schema(ExactSequence([str, int, list, list])) + >>> validate(['hourly_report', 10, [], []]) + ['hourly_report', 10, [], []] + >>> validate(('hourly_report', 10, [], [])) + ('hourly_report', 10, [], []) + """ + + def __init__( + self, + validators: typing.Iterable[Schemable], + msg: typing.Optional[str] = None, + **kwargs, + ) -> None: + self.validators = validators + self.msg = msg + self._schemas = [Schema(val, **kwargs) for val in validators] + + def __call__(self, v): + if not isinstance(v, (list, tuple)) or len(v) != len(self._schemas): + raise ExactSequenceInvalid(self.msg) + try: + v = type(v)(schema(x) for x, schema in zip(v, self._schemas)) + except Invalid as e: + raise e if self.msg is None else ExactSequenceInvalid(self.msg) + return v + + def __repr__(self): + return 'ExactSequence([%s])' % ", ".join(repr(v) for v in self.validators) + + +class Unique(object): + """Ensure an iterable does not contain duplicate items. + + Only iterables convertible to a set are supported (native types and + objects with correct __eq__). + + JSON does not support set, so they need to be presented as arrays. + Unique allows ensuring that such array does not contain dupes. + + >>> s = Schema(Unique()) + >>> s([]) + [] + >>> s([1, 2]) + [1, 2] + >>> with raises(Invalid, 'contains duplicate items: [1]'): + ... s([1, 1, 2]) + >>> with raises(Invalid, "contains duplicate items: ['one']"): + ... s(['one', 'two', 'one']) + >>> with raises(Invalid, regex="^contains unhashable elements: "): + ... s([set([1, 2]), set([3, 4])]) + >>> s('abc') + 'abc' + >>> with raises(Invalid, regex="^contains duplicate items: "): + ... s('aabbc') + """ + + def __init__(self, msg: typing.Optional[str] = None) -> None: + self.msg = msg + + def __call__(self, v): + try: + set_v = set(v) + except TypeError as e: + raise TypeInvalid(self.msg or 'contains unhashable elements: {0}'.format(e)) + if len(set_v) != len(v): + seen = set() + dupes = list(set(x for x in v if x in seen or seen.add(x))) + raise Invalid(self.msg or 'contains duplicate items: {0}'.format(dupes)) + return v + + def __repr__(self): + return 'Unique()' + + +class Equal(object): + """Ensure that value matches target. + + >>> s = Schema(Equal(1)) + >>> s(1) + 1 + >>> with raises(Invalid): + ... s(2) + + Validators are not supported, match must be exact: + + >>> s = Schema(Equal(str)) + >>> with raises(Invalid): + ... s('foo') + """ + + def __init__(self, target, msg: typing.Optional[str] = None) -> None: + self.target = target + self.msg = msg + + def __call__(self, v): + if v != self.target: + raise Invalid( + self.msg + or 'Values are not equal: value:{} != target:{}'.format(v, self.target) + ) + return v + + def __repr__(self): + return 'Equal({})'.format(self.target) + + +class Unordered(object): + """Ensures sequence contains values in unspecified order. + + >>> s = Schema(Unordered([2, 1])) + >>> s([2, 1]) + [2, 1] + >>> s([1, 2]) + [1, 2] + >>> s = Schema(Unordered([str, int])) + >>> s(['foo', 1]) + ['foo', 1] + >>> s([1, 'foo']) + [1, 'foo'] + """ + + def __init__( + self, + validators: typing.Iterable[Schemable], + msg: typing.Optional[str] = None, + **kwargs, + ) -> None: + self.validators = validators + self.msg = msg + self._schemas = [Schema(val, **kwargs) for val in validators] + + def __call__(self, v): + if not isinstance(v, (list, tuple)): + raise Invalid(self.msg or 'Value {} is not sequence!'.format(v)) + + if len(v) != len(self._schemas): + raise Invalid( + self.msg + or 'List lengths differ, value:{} != target:{}'.format( + len(v), len(self._schemas) + ) + ) + + consumed = set() + missing = [] + for index, value in enumerate(v): + found = False + for i, s in enumerate(self._schemas): + if i in consumed: + continue + try: + s(value) + except Invalid: + pass + else: + found = True + consumed.add(i) + break + if not found: + missing.append((index, value)) + + if len(missing) == 1: + el = missing[0] + raise Invalid( + self.msg + or 'Element #{} ({}) is not valid against any validator'.format( + el[0], el[1] + ) + ) + elif missing: + raise MultipleInvalid( + [ + Invalid( + self.msg + or 'Element #{} ({}) is not valid against any validator'.format( + el[0], el[1] + ) + ) + for el in missing + ] + ) + return v + + def __repr__(self): + return 'Unordered([{}])'.format(", ".join(repr(v) for v in self.validators)) + + +class Number(object): + """ + Verify the number of digits that are present in the number(Precision), + and the decimal places(Scale). + + :raises Invalid: If the value does not match the provided Precision and Scale. + + >>> schema = Schema(Number(precision=6, scale=2)) + >>> schema('1234.01') + '1234.01' + >>> schema = Schema(Number(precision=6, scale=2, yield_decimal=True)) + >>> schema('1234.01') + Decimal('1234.01') + """ + + def __init__( + self, + precision: typing.Optional[int] = None, + scale: typing.Optional[int] = None, + msg: typing.Optional[str] = None, + yield_decimal: bool = False, + ) -> None: + self.precision = precision + self.scale = scale + self.msg = msg + self.yield_decimal = yield_decimal + + def __call__(self, v): + """ + :param v: is a number enclosed with string + :return: Decimal number + """ + precision, scale, decimal_num = self._get_precision_scale(v) + + if ( + self.precision is not None + and self.scale is not None + and precision != self.precision + and scale != self.scale + ): + raise Invalid( + self.msg + or "Precision must be equal to %s, and Scale must be equal to %s" + % (self.precision, self.scale) + ) + else: + if self.precision is not None and precision != self.precision: + raise Invalid( + self.msg or "Precision must be equal to %s" % self.precision + ) + + if self.scale is not None and scale != self.scale: + raise Invalid(self.msg or "Scale must be equal to %s" % self.scale) + + if self.yield_decimal: + return decimal_num + else: + return v + + def __repr__(self): + return 'Number(precision=%s, scale=%s, msg=%s)' % ( + self.precision, + self.scale, + self.msg, + ) + + def _get_precision_scale(self, number) -> typing.Tuple[int, int, Decimal]: + """ + :param number: + :return: tuple(precision, scale, decimal_number) + """ + try: + decimal_num = Decimal(number) + except InvalidOperation: + raise Invalid(self.msg or 'Value must be a number enclosed with string') + + exp = decimal_num.as_tuple().exponent + if isinstance(exp, int): + return (len(decimal_num.as_tuple().digits), -exp, decimal_num) + else: + # TODO: handle infinity and NaN + # raise Invalid(self.msg or 'Value has no precision') + raise TypeError("infinity and NaN have no precision") + + +class SomeOf(_WithSubValidators): + """Value must pass at least some validations, determined by the given parameter. + Optionally, number of passed validations can be capped. + + The output of each validator is passed as input to the next. + + :param min_valid: Minimum number of valid schemas. + :param validators: List of schemas or validators to match input against. + :param max_valid: Maximum number of valid schemas. + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-schema constructors. + + :raises NotEnoughValid: If the minimum number of validations isn't met. + :raises TooManyValid: If the maximum number of validations is exceeded. + + >>> validate = Schema(SomeOf(min_valid=2, validators=[Range(1, 5), Any(float, int), 6.6])) + >>> validate(6.6) + 6.6 + >>> validate(3) + 3 + >>> with raises(MultipleInvalid, 'value must be at most 5, not a valid value'): + ... validate(6.2) + """ + + def __init__( + self, + validators: typing.List[Schemable], + min_valid: typing.Optional[int] = None, + max_valid: typing.Optional[int] = None, + **kwargs, + ) -> None: + assert min_valid is not None or max_valid is not None, ( + 'when using "%s" you should specify at least one of min_valid and max_valid' + % (type(self).__name__,) + ) + self.min_valid = min_valid or 0 + self.max_valid = max_valid or len(validators) + super(SomeOf, self).__init__(*validators, **kwargs) + + def _exec(self, funcs, v, path=None): + errors = [] + funcs = list(funcs) + for func in funcs: + try: + if path is None: + v = func(v) + else: + v = func(path, v) + except Invalid as e: + errors.append(e) + + passed_count = len(funcs) - len(errors) + if self.min_valid <= passed_count <= self.max_valid: + return v + + msg = self.msg + if not msg: + msg = ', '.join(map(str, errors)) + + if passed_count > self.max_valid: + raise TooManyValid(msg) + raise NotEnoughValid(msg) + + def __repr__(self): + return 'SomeOf(min_valid=%s, validators=[%s], max_valid=%s, msg=%r)' % ( + self.min_valid, + ", ".join(repr(v) for v in self.validators), + self.max_valid, + self.msg, + ) diff --git a/server/requirements.in b/server/requirements.in index 46312f1..beeb719 100644 --- a/server/requirements.in +++ b/server/requirements.in @@ -14,4 +14,5 @@ packaging # TODO: Add your tool here ply -lark \ No newline at end of file +lark +voluptuous \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index 94f34c4..c585a64 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -40,3 +40,7 @@ typing-extensions==4.14.1 \ --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 # via cattrs +voluptuous==0.15.2 \ + --hash=sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566 \ + --hash=sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa + # via -r ./requirements.in diff --git a/server/src/tools/__init__.py b/server/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/tools/checks.py b/server/src/tools/checks.py index 08be911..cc36ae4 100644 --- a/server/src/tools/checks.py +++ b/server/src/tools/checks.py @@ -1,240 +1,227 @@ -"""Helpers for checking command arguments.""" +import re -from collections.abc import Callable -from typing import List, Optional, Union +from src.tools.commands import get_commands +from src.tools.violations import Rule, Violation -from syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node +from src.tools.syntax_tree import ( + Visitor, + BracedExpression, + Expression, + BracedWord, + QuotedWord, + CommandSub, +) -class CommandArgError(Exception): - pass +class LineLengthChecker: + """Ensures lines aren't too long. + Reports 'line-length' violations. + """ -def arg_count(args, parser): - # TODO: graceful handling of argsub going into things with recursive parsing. - # if the argsub happens to be "concrete", we can technically do the right - # thing (although this should probably be flagged as a readability issue...) - # otherwise, we should flag that the non-concrete argsub is not okay for - # these cases. however, I think its not okay-ness doesn't need to be absolute, e.g. - # I think we could allow: - # - # catch {puts "my script"} {*}$catchopts - # + # ref: https://github.com/eslint/eslint/blob/b29a16b22f234f6134475efb6c7be5ac946556ee/lib/rules/max-len.js#L101 # noqa: E501 + # ^ ironic lint waiver... + URL_RE = re.compile(r"[^:/?#]:\/\/[^?#]") - arg_count = 0 - has_arg_expansion = False - for arg in args: - if isinstance(arg, ArgExpansion): - if arg.contents is None: - has_arg_expansion = True + def check(self, input, _, config): + violations = [] + for i, line in enumerate(input.split("\n")): + if self.URL_RE.search(line) is not None: + # ignore URLs continue - arg_count += len(parser.parse_list(arg.contents)) - else: - arg_count += 1 - return arg_count, has_arg_expansion - - -def check_count(command, min=None, max=None, args_name="args"): - def check(args, parser): - if min is None and max is None: - return None - - count, has_arg_expansion = arg_count(args, parser) - - if not has_arg_expansion and min == max and count != min: - raise CommandArgError( - f"wrong # of {args_name} for {command}: got {count}, expected {min}" - ) - - if not has_arg_expansion and min is not None and count < min: - raise CommandArgError( - f"not enough {args_name} for {command}: got {count}, expected at least" - f" {min}" - ) - - if max is not None and count > max: - raise CommandArgError( - f"too many {args_name} for {command}: got {count}, expected no more" - f" than {max}" - ) - - return None - - return check - - -def eval(args, parser, command): - if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args): - # Slightly odd restriction, but our syntax tree doesn't have a great way - # to handle this case. We require each command argument to correspond to - # one child node, but multiple quoted or braced word arguments can be - # combined into a single subcommand when interpreted eval-style. This - # requirement exists to facilitate style checking, if we had a separate - # CST for style checks and AST for logical checks we may be able to - # handle it. - - raise CommandArgError( - f"unable to parse multiple {command} arguments when one includes a braced" - " or quoted word" - ) - - # Construct the body of the eval taking whitespace into account to ensure we get - # style checking. - - eval_script = "" - prev_arg_end_pos = None - for arg in args: - contents = arg.contents - if contents is None: - # TODO: flag sort of eval-specific violation? Common patterns will - # often trigger this, and it seems useful to be able to turn it off - raise CommandArgError( - f"{command} received an argument with a substitution, unable to parse" - " its arguments" - ) - - if prev_arg_end_pos is not None: - if prev_arg_end_pos[0] != arg.line: - # If we have multiple args on the same line, we know there must be a - # backslash newline. Add it so the parsing works. - eval_script += "\\\n" * (arg.line - prev_arg_end_pos[0]) - eval_script += " " * (arg.col - 1) - else: - eval_script += " " * (arg.col - prev_arg_end_pos[1]) - eval_script += contents - - prev_arg_end_pos = arg.end_pos - - script = parser.parse(eval_script, pos=(args[0].pos)) - script.end_pos = args[-1].end_pos - - return [script] - - -def check_command( - command: str, args: List[Node], parser, command_spec: Union[Callable, dict, None] -) -> Optional[List[Node]]: - if command_spec is None: - return None - - if isinstance(command_spec, dict): - return check_arg_spec(command, args, parser, command_spec) - - return command_spec(args, parser) - - -def check_arg_spec( - command: str, args: List[Node], parser, arg_spec: dict -) -> Optional[List[Node]]: - if "subcommands" in arg_spec: - subcommands = arg_spec["subcommands"] - try: - subcommand = args[0].contents - except IndexError: - subcommand = None - - if subcommand in subcommands: - new_args = check_command( - f"{command} {subcommand}", args[1:], parser, subcommands[subcommand] - ) - if new_args is None: - return new_args - return args[0:1] + new_args - - if "" in subcommands: - return check_command(command, args, parser, subcommands[""]) - - if subcommand is not None: - msg = f"invalid subcommand for {command}: got {subcommand}" - else: - msg = f"no subcommand provided for {command}" - - raise CommandArgError(f"{msg}, expected one of {', '.join(subcommands.keys())}") - - switches = arg_spec["switches"] - args_allowed = set(switches) - args_required = {switch for switch in switches if switches[switch]["required"]} - positional_args = [] - - args = list(args) - while len(args) > 0: - arg = args.pop(0) - - # To facilitate better error messages, we expect that switches are always - # specified as BareWords that start with "-" or ">". This lets us throw an - # error when a switch-like thing doesn't match any supported arguments, - # rather than counting it towards the positional arguments (which usually - # ends up in a vague "too many arguments" error). To make tclint interpret a - # switch-like word as a positional argument, users should wrap it in "", and - # any switches should be BareWords. - contents = arg.contents - if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}): - positional_args.append(arg) - continue - - # TODO check required arguments - if contents in args_allowed: - if switches[contents]["value"]: - try: - args.pop(0) - except IndexError: - raise CommandArgError( - f"invalid arguments for {command}: expected value after" - f" {contents}" + lineno = i + 1 + if len(line) > config.style_line_length: + start = (lineno, 1) + end = (lineno, len(line) + 1) + violations.append( + Violation( + Rule.LINE_LENGTH, + f"line length is {len(line)}, maximum allowed is" + f" {config.style_line_length}", + start, + end, ) - if not switches[contents]["repeated"]: - args_allowed.remove(contents) - if contents in args_required: - args_required.remove(contents) - elif contents in arg_spec: - raise CommandArgError(f"duplicate argument for {command}: {contents}") - else: - prefix_matches = [] - for switch in switches: - if switch.startswith(contents): - prefix_matches.append(switch) - - if len(prefix_matches) == 1: - raise CommandArgError( - f"shortened argument for {command}: expand {contents} to" - f" {prefix_matches[0]}" ) - if len(prefix_matches) > 1: - raise CommandArgError( - f"ambiguous argument for {command}: {contents} could be any of" - f" {', '.join(prefix_matches)}" + return violations + + +class TrailingWhitespaceChecker: + """Ensures lines don't include trailing whitespace. + + Reports 'trailing-whitespace' violations. + """ + + def check(self, input, _, config): + violations = [] + for i, line in enumerate(input.split("\n")): + lineno = i + 1 + + WHITESPACE = (" ", "\t") + if line.endswith(WHITESPACE): + start_col = len(line.rstrip("".join(WHITESPACE))) + start = (lineno, start_col + 1) + end = (lineno, len(line) + 1) + violations.append( + Violation( + Rule.TRAILING_WHITESPACE, + "line has trailing whitespace", + start, + end, + ) ) - raise CommandArgError(f"unrecognized argument for {command}: {contents}") + return violations - if len(args_required) > 1: - raise CommandArgError( - f"missing required arguments for {command}: {', '.join(args_required)}" - ) - elif len(args_required) == 1: - raise CommandArgError( - f"missing required argument for {command}: {args_required.pop()}" + +class RedefinedBuiltinChecker(Visitor): + """Ensures names of built-in commands aren't reused by proc definitions. + + Reports 'redefined-builtin' violations. + """ + + def check(self, _, tree, config): + self._violations = [] + + plugins = [config.commands] if config.commands is not None else [] + commands = get_commands(plugins) + self._commands = commands.keys() + + tree.accept(self, recurse=True) + + return self._violations + + def visit_command(self, command): + if command.routine.contents != "proc": + return + + if len(command.args) == 0: + # This is a syntax error, but should already be caught as a command-args + # error by the parser's `proc` command handling. + return + + name = command.args[0].contents + + if name in self._commands: + self._violations.append( + Violation( + Rule.REDEFINED_BUILTIN, + f"redefinition of built-in command '{name}'", + command.pos, + command.args[1].end_pos, + ) + ) + + +class UnbracedExprChecker(Visitor): + def check(self, _, tree, __): + self._violations = [] + tree.accept(self, recurse=True) + return self._violations + + def visit_command(self, command): + if command.routine.contents != "expr": + return + + if len(command.args) == 0: + # This is a syntax error, but should already be caught as a command-args + # error by the parser's `expr` command handling. + return + + if len(command.args) == 1 and isinstance( + command.args[0], (BracedExpression, Expression) + ): + return + + # If we got here, tclint had trouble parsing the expression due to one of the + # two following cases. + + for child in command.args: + if child.contents is None: + self._violations.append( + Violation( + Rule.UNBRACED_EXPR, + "expression with substitutions should be enclosed by braces", + command.args[0].pos, + command.args[-1].end_pos, + ) + ) + return + + for child in command.args: + if isinstance(child, (BracedWord, QuotedWord)): + self._violations.append( + Violation( + Rule.UNBRACED_EXPR, + "expression containing braced or quoted words should be" + " enclosed by braces", + command.args[0].pos, + command.args[-1].end_pos, + ) + ) + return + + # If we reach here, there's probably a bug in expr parsing logic. + assert False, ( + "Children of expr node were different than expected, please file a bug" + " report" ) - min_positionals = 0 - max_positionals: Optional[int] = 0 - for positional in arg_spec["positionals"]: - if positional["value"]["type"] == "variadic": - max_positionals = None - if positional["required"]: - min_positionals += 1 - if max_positionals is not None: - max_positionals += 1 +class RedundantExprChecker(Visitor): + def check(self, _, tree, __): + self._violations = [] + tree.accept(self, recurse=True) + return self._violations - check = check_count( - command, - min=min_positionals, - max=max_positionals, - args_name="positional args", + def _check_operand(self, operand): + if not isinstance(operand, CommandSub) or len(operand.children) != 1: + return + + command = operand.children[0] + if command.routine.contents == "expr": + self._violations.append( + Violation( + Rule.REDUNDANT_EXPR, + "unnecessary command substitution within expression", + operand.pos, + operand.end_pos, + ) + ) + + def visit_braced_expression(self, expression): + if len(expression.children) == 1: + self._check_operand(expression.children[0]) + + def visit_expression(self, expression): + if len(expression.children) == 1: + self._check_operand(expression.children[0]) + + def visit_unary_op(self, expr): + self._check_operand(expr.children[1]) + + def visit_binary_op(self, expr): + self._check_operand(expr.children[0]) + self._check_operand(expr.children[2]) + + def visit_ternary_op(self, expr): + self._check_operand(expr.children[0]) + self._check_operand(expr.children[2]) + self._check_operand(expr.children[4]) + + def visit_function(self, function): + for arg in function.children[1:]: + self._check_operand(arg) + + +def get_checkers(): + checkers = ( + RedefinedBuiltinChecker(), + UnbracedExprChecker(), + RedundantExprChecker(), + LineLengthChecker(), + TrailingWhitespaceChecker(), ) - check(positional_args, None) - return None + return checkers diff --git a/server/src/tools/commands/__init__.py b/server/src/tools/commands/__init__.py index 1202114..f69a211 100644 --- a/server/src/tools/commands/__init__.py +++ b/server/src/tools/commands/__init__.py @@ -1,11 +1,11 @@ import pathlib from typing import List, Dict, Union -from tools.commands import builtin as _builtin -from tools.commands.plugins import PluginManager +from src.tools.commands import builtin as _builtin +from src.tools.commands.plugins import PluginManager # import to expose in package -from tools.commands.checks import CommandArgError +from src.tools.commands.checks import CommandArgError __all__ = ["CommandArgError", "validate_command_plugins", "get_commands"] diff --git a/server/src/tools/commands/builtin.py b/server/src/tools/commands/builtin.py new file mode 100644 index 0000000..b0aec14 --- /dev/null +++ b/server/src/tools/commands/builtin.py @@ -0,0 +1,1082 @@ +"""Parse-time handling of Tcl's builtin commands. + +Based on Tcl 8.6, https://www.tcl-lang.org/man/tcl8.6/TclCmd/contents.htm. + +Note that the following commands are not currently supported. If support for any of +these would be helpful for your use case, please file an issue. + +- Anything related to TclOO: + - https://www.tcl.tk/man/tcl/TclCmd/my.html + - https://www.tcl.tk/man/tcl/TclCmd/next.html + - https://www.tcl.tk/man/tcl/TclCmd/class.html + - https://www.tcl.tk/man/tcl/TclCmd/copy.html + - https://www.tcl.tk/man/tcl/TclCmd/define.html + - https://www.tcl.tk/man/tcl/TclCmd/object.html + - https://www.tcl.tk/man/tcl/TclCmd/self.html + +- Things that are imported via `package require` + - https://www.tcl.tk/man/tcl/TclCmd/dde.html + - https://www.tcl.tk/man/tcl/TclCmd/http.html + - https://www.tcl.tk/man/tcl/TclCmd/msgcat.html + - https://www.tcl.tk/man/tcl/TclCmd/platform.html + - https://www.tcl.tk/man/tcl/TclCmd/platform_shell.html + - https://www.tcl.tk/man/tcl/TclCmd/transchan.html + - https://www.tcl.tk/man/tcl/TclCmd/tcltest.html + +- Tcl library commands: https://www.tcl.tk/man/tcl/TclCmd/library.html + +- The "unknown" command: https://www.tcl.tk/man/tcl/TclCmd/unknown.html + +- Math ops: + - https://www.tcl.tk/man/tcl/TclCmd/mathfunc.html + - https://www.tcl.tk/man/tcl/TclCmd/mathop.html +""" + +from src.tools.commands.checks import ( + CommandArgError, + check_count, + eval, +) +from src.tools.commands.schema import commands_schema +from src.tools.syntax_tree import BareWord + + +def _check_code(arg): + """Check 'code' argument used by return and try.""" + + val = arg.contents + if val is None: + return + + try: + int(val) + except ValueError: + pass + else: + return + + if val in {"ok", "error", "return", "break", "continue"}: + return + + raise CommandArgError( + f"got {val}, expected one of ok, error, return, break, continue, or an integer" + ) + + +def _after(args, parser): + """after ms [script...]""" + # ref: https://www.tcl.tk/man/tcl/TclCmd/after.html + + script_arg = [] + if len(args) > 1: + script_arg = eval(args[1:], parser, "after") + + return args[0:1] + script_arg + + +def _after_cancel(args, parser): + """after id|(script...)""" + # ref: https://www.tcl.tk/man/tcl/TclCmd/after.html + check_count("after cancel", 1, None) + + # TODO: raise warning about not checking code + + return None + + +def _after_idle(args, parser): + """after idle [script...]""" + # ref: https://www.tcl.tk/man/tcl/TclCmd/after.html + return eval(args, parser, "after idle") + + +def _apply(args, parser): + """apply func [arg...]""" + # ref: https://www.tcl.tk/man/tcl/TclCmd/apply.html + if len(args) < 1: + raise CommandArgError( + f"not enough args to apply: got {len(args)}, expected at least 1" + ) + + func_list = parser.parse_list(args[0]) + list_len = len(func_list.children) + if list_len < 2 or list_len > 3: + raise CommandArgError( + f"Invalid first argument to apply: got list of {list_len} elements," + " expected 2 or 3" + ) + + body = parser.parse_script(func_list.children[1]) + func_list.children[1] = body + + return [func_list] + args[1:] + + +_array = { + "subcommands": { + "anymore": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "searchId", "value": {"type": "any"}, "required": True}, + ] + }, + "donesearch": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "searchId", "value": {"type": "any"}, "required": True}, + ] + }, + "exists": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + ] + }, + "get": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "pattern", "value": {"type": "any"}, "required": False}, + ] + }, + "names": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "mode", "value": {"type": "any"}, "required": False}, + {"name": "pattern", "value": {"type": "any"}, "required": False}, + ] + }, + "nextelement": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "searchId", "value": {"type": "any"}, "required": True}, + ] + }, + "set": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "list", "value": {"type": "any"}, "required": True}, + ] + }, + "size": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + ] + }, + "startsearch": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + ] + }, + "statistics": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + ] + }, + "unset": { + "positionals": [ + {"name": "arrayName", "value": {"type": "any"}, "required": True}, + {"name": "pattern", "value": {"type": "any"}, "required": False}, + ] + }, + }, +} + + +def _catch(args, parser): + """catch script [resultVarName] [optionsVarName]""" + if len(args) < 1: + raise CommandArgError( + f"not enough args to catch: got {len(args)}, expected at least 1" + ) + if len(args) > 3: + raise CommandArgError( + f"too many args to catch: got {len(args)}, expected no more than 3" + ) + + return [parser.parse_script(args[0])] + args[1:] + + +_chan = { + "subcommands": { + "blocked": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "close": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "direction", "value": {"type": "any"}, "required": False}, + ] + }, + "configure": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "options", "value": {"type": "variadic"}, "required": False}, + ], + }, + "copy": { + "positionals": [ + {"name": "inputChan", "value": {"type": "any"}, "required": True}, + {"name": "outputChan", "value": {"type": "any"}, "required": True}, + {"name": "options", "value": {"type": "variadic"}, "required": False}, + ], + }, + "create": { + "positionals": [ + {"name": "mode", "value": {"type": "any"}, "required": True}, + {"name": "cmdPrefix", "value": {"type": "any"}, "required": True}, + ] + }, + "eof": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "event": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "event", "value": {"type": "any"}, "required": True}, + # TODO: parse this as script + {"name": "script", "value": {"type": "any"}, "required": False}, + ] + }, + "flush": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "gets": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "varName", "value": {"type": "any"}, "required": False}, + ] + }, + "names": { + "positionals": [ + {"name": "pattern", "value": {"type": "any"}, "required": False}, + ] + }, + "pending": { + "positionals": [ + {"name": "mode", "value": {"type": "any"}, "required": True}, + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "pipe": {}, + "pop": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "postevent": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "eventSpec", "value": {"type": "any"}, "required": True}, + ] + }, + "push": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "cmdPrefix", "value": {"type": "any"}, "required": True}, + ] + }, + "puts": { + "positionals": [ + {"name": "-nonewline", "value": {"type": "any"}, "required": False}, + {"name": "channelId", "value": {"type": "any"}, "required": False}, + {"name": "string", "value": {"type": "any"}, "required": True}, + ], + }, + "read": { + "positionals": [ + {"name": "-nonewline", "value": {"type": "any"}, "required": False}, + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "numChars", "value": {"type": "any"}, "required": False}, + ], + }, + "seek": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "offset", "value": {"type": "any"}, "required": True}, + {"name": "origin", "value": {"type": "any"}, "required": False}, + ] + }, + "tell": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + ] + }, + "truncate": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "length", "value": {"type": "any"}, "required": False}, + ] + }, + }, +} + + +def _dict_filter(args, parser): + """dict filter [arg...] + dict filter key [globPattern...] + dict filter value [globPattern...] + """ + + # ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M8 + + if len(args) < 2: + raise CommandArgError( + f"not enough args to 'dict filter': got {len(args)}, expected at least 2" + ) + + if args[1].contents not in {"key", "script", "value"}: + raise CommandArgError( + "invalid argument to 'dict filter': expected filter type to be one of key," + " script, or value" + ) + + if args[1].contents == "script": + kv_pair = parser.parse_list(args[2]) + list_len = len(kv_pair.children) + if len(kv_pair.children) != 2: + raise CommandArgError( + "invalid argument to 'dict filter': expected list of 2 elements in" + f" second-to-last argument, got {list_len}" + ) + return args[0:2] + [kv_pair, parser.parse_script(args[3])] + + return None + + +def _dict_map_for(cmd): + def check(args, parser): + if len(args) != 3: + raise CommandArgError( + f"wrong # of args to '{cmd}': got {len(args)}, expected 3" + ) + + # TODO: might be worth checking that arg[0] is a pair? + + return args[0:2] + [parser.parse_script(args[2])] + + return check + + +def _dict_update(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M25 + + if len(args) < 4: + raise CommandArgError( + f"not enough args to 'dict update': got {len(args)}, expected at least 4" + ) + + if len(args) % 2 != 0: + raise CommandArgError( + "invalid # of args to 'dict update': expected an even number" + ) + + return args[0:-1] + [parser.parse_script(args[-1])] + + +def _dict_with(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M27 + + if len(args) < 2: + raise CommandArgError( + f"not enough args to 'dict with': got {len(args)}, expected at least 2" + ) + + return args[0:-1] + [parser.parse_script(args[-1])] + + +def _eval(args, parser): + return eval(args, parser, "eval") + + +def _expr(args, parser): + if len(args) == 0: + raise CommandArgError("not enough args to 'expr': got 0, expected at least 1") + + # Handle single argument consisting of BareWord, BracedWord, or concrete QuotedWord. + if len(args) == 1 and args[0].contents is not None: + # this method will handle the `node.contents is None` case fine, but + # will throw an error. We'll instead pass thru silently, since that error + # will be caught by a separate lint check. + return [parser.parse_expression(args[0])] + + # Handle multiple BareWord arguments. Non-BareWords are hard to handle in this case, + # since we need to pop the contents out of quoted or braced words, but then we have + # no way of storing the original info about these words in the syntax tree. + contents = "" + last_pos = args[0].pos + for arg in args: + if not isinstance(arg, BareWord): + return None + + if arg.pos[0] != last_pos[0]: + contents += "\n" * (arg.pos[0] - last_pos[0]) + contents += " " * (arg.pos[1] - 1) + else: + contents += " " * (arg.pos[1] - last_pos[1]) + contents += arg.contents + last_pos = arg.end_pos + + node = BareWord(contents, pos=args[0].pos, end_pos=args[-1].end_pos) + return [parser.parse_expression(node)] + + +def _fileevent(args, parser): + # ref: https://www.tcl.tk/man/tcl8.4/TclCmd/fileevent.html + # TODO: implement + raise CommandArgError( + "argument parsing for 'fileevent' not implemented, script argument will not be" + " checked for violations" + ) + + +def _for(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/for.html + if len(args) != 4: + raise CommandArgError(f"wrong # of args to for: got {len(args)}, expected 4") + + return [ + parser.parse_script(args[0]), + parser.parse_expression(args[1]), + parser.parse_script(args[2]), + parser.parse_script(args[3]), + ] + + +def _foreach(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/foreach.html + if len(args) < 3: + raise CommandArgError( + f"insufficient args to foreach: got {len(args)}, expected at least 3" + ) + + # last argument is script body + return args[0:-1] + [parser.parse_script(args[-1])] + + +def _if(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/if.html + # TODO: make arg checking strict + + new_args = [] + + new_args.append(parser.parse_expression(args[0])) + + while len(new_args) < len(args): + arg = args[len(new_args)] + + if arg.contents == "then" or arg.contents == "else": + new_args.append(arg) + continue + if arg.contents == "elseif": + new_args.append(arg) + new_args.append(parser.parse_expression(args[len(new_args)])) + continue + + arg = parser.parse_script(arg) + new_args.append(arg) + + return new_args + + +def _interp_eval(args, parser): + if len(args) < 2: + raise CommandArgError( + f"not enough args to 'interp eval': got {len(args)}, expected at least 2" + ) + return args[0:1] + eval(args[1:], parser, "interp eval") + + +def _lmap(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/lmap.html + if len(args) < 3: + raise CommandArgError( + f"not enough args to lmap: got {len(args)}, expected at least 3" + ) + + return args[:-1] + [parser.parse_script(args[-1])] + + +def _namespace_code(args, parser): + # ref: https://www.tcl.tk/man/tcl8.4/TclCmd/namespace.html#M6 + # TODO: seems like a possible pattern is to execute things in these scripts + # with additional args provided, so command-args checks within this might + # actually be false positive. will keep as-is for now though. + return [parser.parse_script(args[0])] + + +def _namespace_eval(args, parser): + if len(args) < 2: + raise CommandArgError( + f"not enough args to 'namespace eval': got {len(args)}, expected at least 2" + ) + return args[0:1] + eval(args[1:], parser, "namespace eval") + + +def _namespace_inscope(args, parser): + # ref: https://www.tcl.tk/man/tcl8.4/TclCmd/namespace.html#M14 + raise CommandArgError( + "'namespace inscope' is not meant to be called directly, consider using" + " 'namespace code' or 'namespace eval' instead" + ) + + +def _package_ifneeded(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/package.html + + # TODO: implement + + # one issue with this one - it seems like calls to package ifneeded are + # often generated by pkg_MkIndex and these calls won't lint clean. Probably + # need a special case to ensure that these don't generate violations + + raise CommandArgError( + "argument parsing for 'package ifneeded' not implemented, any script argument" + " will not be checked for violations" + ) + + +def _proc(args, parser): + if len(args) != 3: + raise CommandArgError(f"wrong # of args to proc: got {len(args)}, expected 3") + + # Parse args as list, then iterate over each item to parse arg specifier lists and + # do some validation. We don't store non-defaulted arguments as Lists so that they + # don't get formatted inside braces. + arg_list = parser.parse_list(args[1]) + for i, arg in enumerate(arg_list.children): + if isinstance(arg, BareWord): + continue + + arg_specifier = parser.parse_list(arg) + arg_specifier_len = len(arg_specifier.children) + + if arg_specifier_len == 2: + arg_list.children[i] = arg_specifier + elif arg_specifier_len != 1: + raise CommandArgError( + f"too many fields in argument specifier: got {arg_specifier_len}," + " expected no more than 2" + ) + + return args[0:1] + [arg_list, parser.parse_script(args[2])] + + +def _return(args, parser): + args = list(args) + while len(args) > 0: + option = args.pop(0).contents + + try: + if option == "-code": + arg = args.pop(0) + try: + _check_code(arg) + except CommandArgError as e: + raise CommandArgError(f"invalid value for return -code: {e}") + elif option == "-level": + val = args.pop(0).contents + + if val is None: + continue + + try: + if int(val) >= 0: + continue + except ValueError: + pass + + raise CommandArgError( + f"invalid value for return -level: got {val}, expected a" + " non-negative integer" + ) + elif option in {"-errorcode", "-errorinfo", "-errorstack", "-options"}: + args.pop(0) + else: + break + except IndexError: + raise CommandArgError( + f"insufficient args to return: expected value after {option}" + ) + + if len(args) > 0: + raise CommandArgError( + "too many arguments to return: expected no more than 1 argument after" + " explicit options. Provide -options argument if you intend to specify" + " additional return options." + ) + + return None + + +def _switch(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/switch.html + # This one's complicated... + + # TODO: better checking of malformed switch command + + arg_contents = [arg.contents for arg in args] + arg_i = 0 + + try: + arg_i = arg_contents.index("--") + 1 + except ValueError: + while True: + contents = args[arg_i].contents + if contents in {"-exact", "-glob", "-regexp", "-nocase"}: + arg_i += 1 + elif contents in {"-matchvar", "-indexvar"}: + arg_i += 2 + else: + break + + # accounts for string to be matched + arg_i += 1 + + new_args = args[0:arg_i] + + # one argument left => form where patterns and bodies are in list + last_arg_is_list = arg_i == len(args) - 1 + + if last_arg_is_list: + pattern_and_commands_list = parser.parse_list(args[arg_i]) + new_args.append(pattern_and_commands_list) + pattern_and_commands = pattern_and_commands_list.children + else: + pattern_and_commands = args[arg_i:] + + if len(pattern_and_commands) % 2 != 0: + raise CommandArgError("Expected even number of patterns and commands") + + parsed_patterns_and_commands = [] + for i, node in enumerate(pattern_and_commands): + if i % 2 == 0: + parsed_patterns_and_commands.append(node) + else: + parsed_patterns_and_commands.append(parser.parse_script(node)) + + if last_arg_is_list: + pattern_and_commands_list.children = parsed_patterns_and_commands + else: + new_args.extend(parsed_patterns_and_commands) + + return new_args + + +def _time(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/time.html + if len(args) < 1: + raise CommandArgError( + f"not enough args to time: got {len(args)}, expected at least 1" + ) + + if len(args) > 2: + raise CommandArgError( + f"too many args to time: got {len(args)}, expected no more than 2" + ) + + if len(args) == 2: + time = args[1].contents + if time is not None: + try: + int(time) + except ValueError: + raise CommandArgError( + "invalid argument to time: expected integer for last argument" + ) + + return [parser.parse_script(args[0])] + args[1:] + + +def _timerate(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/timerate.html + # timerate doesn't seem to be implemented in tclsh 8.6 for me - why? + + args = list(args) + new_args = [] + + while True: + try: + arg = args.pop(0) + except IndexError: + raise CommandArgError("invalid arguments to timerate: expected script body") + + if arg.contents in {"-direct", "-calibrate"}: + new_args.append(arg) + elif arg.contents in {"-overhead"}: + new_args.append(arg) + try: + val = args.pop(0) + if val.contents is not None: + float(val.contents) + except (ValueError, IndexError, TypeError): + raise CommandArgError( + "invalid argument to timerate: -overhead must be followed by a" + " double" + ) + new_args.append(val) + else: + break + + new_args.append(parser.parse_script(arg)) + + if len(args) > 2: + raise CommandArgError( + "too many arguments to timerate: expected no more than 2 arguments" + " following script body" + ) + + try: + [int(arg.contents) for arg in args] + except ValueError: + raise CommandArgError( + "invalid argument to timerate: expected one or two integers following" + " script body" + ) + + return new_args + args + + +def _try(args, parser): + # ref: https://www.tcl.tk/man/tcl/TclCmd/try.html + args = list(args) + new_args = [] + + while True: + try: + arg = args.pop(0) + except IndexError: + raise CommandArgError("invalid arguments to try: missing script body") + new_args.append(parser.parse_script(arg)) + + try: + arg = args.pop(0) + except IndexError: + break + + new_args.append(arg) + + if arg.contents == "on": + try: + code = args.pop(0) + try: + _check_code(code) + except CommandArgError as e: + raise CommandArgError( + f"invalid code argument to 'on' handler in try: {e}" + ) + new_args.append(code) + new_args.append(args.pop(0)) + except IndexError: + raise CommandArgError( + "invalid arguments to try: expected 3 arguments after 'on' handler" + ) + elif arg.contents == "trap": + try: + new_args.append(args.pop(0)) + new_args.append(args.pop(0)) + except IndexError: + raise CommandArgError( + "invalid arguments to try: expected 3 arguments after 'trap'" + " handler" + ) + elif arg.contents == "finally": + continue + else: + raise CommandArgError( + "invalid handler argument to try: expected one of 'on', 'trap', or" + " 'finally'" + ) + + return new_args + + +def _while(args, parser): + if len(args) != 2: + raise CommandArgError(f"wrong # of args to while: got {len(args)}, expected 2") + + return [ + parser.parse_expression(args[0]), + parser.parse_script(args[1]), + ] + + +commands = commands_schema( + { + "after": { + "subcommands": { + "cancel": _after_cancel, + "idle": _after_idle, + "info": { + "positionals": [ + {"name": "id", "value": {"type": "any"}, "required": False} + ] + }, + "": _after, + }, + }, + "append": { + "positionals": [ + {"name": "varname", "value": {"type": "any"}, "required": True}, + {"name": "value", "value": {"type": "variadic"}, "required": False}, + ] + }, + "apply": _apply, + "array": _array, + "binary": { + "subcommands": { + "decode": check_count("binary decode", 2, None), + "encode": check_count("binary encode", 2, None), + "format": check_count("binary format", 1, None), + "scan": check_count("binary scan", 2, None), + }, + }, + "break": check_count("break", 0, 0), + "catch": _catch, + "cd": { + "positionals": [ + {"name": "dirName", "value": {"type": "any"}, "required": False} + ], + }, + "chan": _chan, + # TODO: check subcommands + "clock": check_count("clock"), + "close": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "read|write", "value": {"type": "any"}, "required": False}, + ], + }, + "concat": { + "positionals": [ + {"name": "arg", "value": {"type": "variadic"}, "required": True}, + ] + }, + "continue": {}, + "coroutine": { + "positionals": [ + {"name": "name", "value": {"type": "any"}, "required": True}, + {"name": "command", "value": {"type": "any"}, "required": True}, + {"name": "arg", "value": {"type": "variadic"}, "required": False}, + ] + }, + "dict": { + "subcommands": { + "append": check_count("dict append", 2, None), + "create": check_count("dict create"), + "exists": check_count("dict exists", 2, None), + "filter": _dict_filter, + "for": _dict_map_for("dict for"), + "get": check_count("dict get", 1, None), + "incr": check_count("dict incr", 2, 3), + "info": check_count("dict info", 1, 1), + "keys": check_count("dict keys", 1, 2), + "lappend": check_count("dict lappend", 2, None), + "map": _dict_map_for("dict map"), + "merge": check_count("dict merge"), + "remove": check_count("dict remove", 1, None), + "replace": check_count("dict replace", 1, None), + "set": check_count("dict set", 3, None), + "size": check_count("dict size", 1, 1), + "unset": check_count("dict unset", 2, None), + "update": _dict_update, + "values": check_count("dict values", 1, 2), + "with": _dict_with, + }, + }, + "encoding": { + "subcommands": { + "convertfrom": check_count("encoding convertfrom", 1, 2), + "convertto": check_count("encoding convertto", 1, 2), + "dirs": check_count("encoding dirs", 0, 1), + "names": check_count("encoding names", 0, 0), + "system": check_count("encoding system", 0, 1), + }, + }, + "eof": check_count("eof", 1, 1), + "error": check_count("error", 1, 3), + "eval": _eval, + "exec": check_count("exec", 1, None), + "exit": check_count("exit", 0, 1), + "expr": _expr, + "fblocked": check_count("fblocked", 1, 1), + "fconfigure": check_count("fconfigure", 1, None), + "fcopy": check_count("fcopy", 2, 6), + # TODO: check subcommands + "file": check_count("file", 1, None), + "fileevent": _fileevent, + "flush": check_count("flush", 1, 1), + "for": _for, + "foreach": _foreach, + "format": check_count("format", 1, None), + "gets": check_count("gets", 1, 2), + "glob": check_count("glob"), + "global": check_count("global"), + "history": check_count("history"), + "if": _if, + "incr": check_count("incr", 1, 2), + # TODO: check subcommands + "info": check_count("info", 1, None), + # TODO: check other subcommands + "interp": { + "subcommands": { + "eval": _interp_eval, + "": check_count("interp", 1, None), + }, + }, + "join": check_count("join", 1, 2), + "lappend": check_count("lappend", 1, None), + "lassign": check_count("lassign", 1, None), + "lindex": check_count("lindex", 1, None), + "linsert": check_count("linsert", 2, None), + "list": check_count("list", 0, None), + "llength": check_count("llength", 1, 1), + "lrepeat": check_count("lrepeat", 1, None), + "lreplace": check_count("lreplace", 3, None), + "lreverse": check_count("lreverse", 1, 1), + "lset": check_count("lset", 2, None), + "lsort": check_count("lsort", 1, None), + "lmap": _lmap, + "load": check_count("load", 1, 6), + "lrange": check_count("lrange", 3, 3), + "lsearch": check_count("lsearch", 2, None), + "memory": { + "subcommands": { + "active": check_count("memory active", 1, 1), + "break_on_malloc": check_count("memory break_on_malloc", 1, 1), + "info": check_count("memory info", 0, 0), + # just on or off + "init": check_count("memory init", 1, 1), + "objs": check_count("memory objs", 1, 1), + "onexit": check_count("memory onexit", 1, 1), + "tag": check_count("memory tag", 1, 1), + # just on or off + "trace": check_count("memory trace", 1, 1), + "trace_on_at_malloc": check_count("memory trace_on_at_malloc", 1, 1), + # just on or off + "validate": check_count("memory validate", 1, 1), + }, + }, + "namespace": { + "subcommands": { + "children": check_count("namespace children", 0, 2), + "code": _namespace_code, + "current": check_count("namespace current", 0, 0), + "delete": None, + "eval": _namespace_eval, + "exists": check_count("namespace exists", 1, 1), + "export": None, + "forget": None, + "import": None, + "inscope": _namespace_inscope, + "origin": check_count("namespace origin", 1, 1), + "parent": check_count("namespace parent", 0, 1), + "qualifiers": check_count("namespace qualifiers", 1, 1), + "tail": check_count("namespace tail", 1, 1), + "which": check_count("namespace which", 1, 2), + "ensemble": { + "subcommands": { + "create": None, + "configure": check_count( + "namespace ensemble configure", 1, None + ), + "exists": check_count("namespace ensemble exists", 1, 1), + }, + }, + }, + }, + "open": check_count("open", 1, 3), + "package": { + "subcommands": { + "forget": None, + "ifneeded": _package_ifneeded, + "names": check_count("package names", 0, 0), + "present": check_count("package present", 0, None), + "provide": check_count("package provide", 1, 2), + "require": check_count("package require", 1, None), + "unknown": check_count("package unknown", 1, None), + "vcompare": check_count("package vcompare", 2, 2), + "versions": check_count("package versions", 1, 1), + "vsatisfies": check_count("package vsatisfies", 2, None), + "prefer": check_count("package prefer", 1, 1), + }, + }, + "pid": check_count("pid", 0, 1), + "pkg::create": check_count("pkg::create", 2, None), + "pkg_mkIndex": check_count("pkg_mkIndex", 1, None), + "proc": _proc, + "puts": { + "positionals": [ + {"name": "-nonewline", "value": {"type": "any"}, "required": False}, + {"name": "channelId", "value": {"type": "any"}, "required": False}, + {"name": "string", "value": {"type": "any"}, "required": True}, + ], + }, + "pwd": check_count("pwd", 0, 0), + "read": check_count("read", 1, 2), + "regexp": check_count("regexp", 2, None), + "regsub": check_count("regsub", 3, None), + "rename": check_count("rename", 2, 2), + "return": _return, + # TODO: check subcommands + "safe": check_count("safe", 1, None), + "scan": check_count("scan", 2, None), + "seek": check_count("seek", 2, 3), + "set": check_count("set", 1, 2), + "socket": check_count("socket", 2, None), + "source": check_count("source", 1, 3), + "split": check_count("split", 1, 2), + # TODO: check subcommands + "string": check_count("string", 2, None), + "subst": check_count("subst", 1, 4), + "switch": _switch, + "tailcall": check_count("tailcall", 1, None), + "tcl::prefix": { + "subcommands": { + "all": check_count("tcl::prefix all", 2, 2), + "longest": check_count("tcl::prefix longest", 2, 2), + "match": check_count("tcl::prefix match", 2, None), + }, + }, + "tell": check_count("tell", 1, 1), + "throw": check_count("throw", 2, 2), + "time": _time, + "timerate": _timerate, + "tcl::tm::path": { + "subcommands": { + "add": check_count("tcl::tm::path add"), + "remove": check_count("tcl::tm::path remove"), + "list": check_count("tcl::tm::path list", 0, 0), + }, + }, + "tcl::tm::roots": check_count("tcl::tm::roots"), + # TODO: check subcommands + "trace": check_count("trace", 2, None), + "try": _try, + "unload": check_count("unload", 1, 6), + "unset": check_count("unset"), + "update": check_count("update", 0, 1), + "uplevel": check_count("uplevel", 1, None), + "upvar": check_count("upvar", 2, None), + "variable": check_count("variable", 1, None), + "vwait": check_count("vwait", 1, 1), + "while": _while, + "yield": { + "positionals": [ + {"name": "value", "value": {"type": "any"}, "required": False}, + ] + }, + "yieldto": { + "positionals": [ + {"name": "command", "value": {"type": "any"}, "required": True}, + {"name": "arg", "value": {"type": "variadic"}, "required": False}, + ] + }, + # TODO: check subcommands + "zlib": check_count("zlib", 3, None), + } +) diff --git a/server/src/tools/commands/checks.py b/server/src/tools/commands/checks.py new file mode 100644 index 0000000..a325f59 --- /dev/null +++ b/server/src/tools/commands/checks.py @@ -0,0 +1,240 @@ +"""Helpers for checking command arguments.""" + +from collections.abc import Callable +from typing import List, Optional, Union + +from src.tools.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node + + +class CommandArgError(Exception): + pass + + +def arg_count(args, parser): + # TODO: graceful handling of argsub going into things with recursive parsing. + # if the argsub happens to be "concrete", we can technically do the right + # thing (although this should probably be flagged as a readability issue...) + # otherwise, we should flag that the non-concrete argsub is not okay for + # these cases. however, I think its not okay-ness doesn't need to be absolute, e.g. + # I think we could allow: + # + # catch {puts "my script"} {*}$catchopts + # + + arg_count = 0 + has_arg_expansion = False + for arg in args: + if isinstance(arg, ArgExpansion): + if arg.contents is None: + has_arg_expansion = True + continue + arg_count += len(parser.parse_list(arg.contents)) + else: + arg_count += 1 + + return arg_count, has_arg_expansion + + +def check_count(command, min=None, max=None, args_name="args"): + def check(args, parser): + if min is None and max is None: + return None + + count, has_arg_expansion = arg_count(args, parser) + + if not has_arg_expansion and min == max and count != min: + raise CommandArgError( + f"wrong # of {args_name} for {command}: got {count}, expected {min}" + ) + + if not has_arg_expansion and min is not None and count < min: + raise CommandArgError( + f"not enough {args_name} for {command}: got {count}, expected at least" + f" {min}" + ) + + if max is not None and count > max: + raise CommandArgError( + f"too many {args_name} for {command}: got {count}, expected no more" + f" than {max}" + ) + + return None + + return check + + +def eval(args, parser, command): + if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args): + # Slightly odd restriction, but our syntax tree doesn't have a great way + # to handle this case. We require each command argument to correspond to + # one child node, but multiple quoted or braced word arguments can be + # combined into a single subcommand when interpreted eval-style. This + # requirement exists to facilitate style checking, if we had a separate + # CST for style checks and AST for logical checks we may be able to + # handle it. + + raise CommandArgError( + f"unable to parse multiple {command} arguments when one includes a braced" + " or quoted word" + ) + + # Construct the body of the eval taking whitespace into account to ensure we get + # style checking. + + eval_script = "" + prev_arg_end_pos = None + for arg in args: + contents = arg.contents + if contents is None: + # TODO: flag sort of eval-specific violation? Common patterns will + # often trigger this, and it seems useful to be able to turn it off + raise CommandArgError( + f"{command} received an argument with a substitution, unable to parse" + " its arguments" + ) + + if prev_arg_end_pos is not None: + if prev_arg_end_pos[0] != arg.line: + # If we have multiple args on the same line, we know there must be a + # backslash newline. Add it so the parsing works. + eval_script += "\\\n" * (arg.line - prev_arg_end_pos[0]) + eval_script += " " * (arg.col - 1) + else: + eval_script += " " * (arg.col - prev_arg_end_pos[1]) + eval_script += contents + + prev_arg_end_pos = arg.end_pos + + script = parser.parse(eval_script, pos=(args[0].pos)) + script.end_pos = args[-1].end_pos + + return [script] + + +def check_command( + command: str, args: List[Node], parser, command_spec: Union[Callable, dict, None] +) -> Optional[List[Node]]: + if command_spec is None: + return None + + if isinstance(command_spec, dict): + return check_arg_spec(command, args, parser, command_spec) + + return command_spec(args, parser) + + +def check_arg_spec( + command: str, args: List[Node], parser, arg_spec: dict +) -> Optional[List[Node]]: + if "subcommands" in arg_spec: + subcommands = arg_spec["subcommands"] + try: + subcommand = args[0].contents + except IndexError: + subcommand = None + + if subcommand in subcommands: + new_args = check_command( + f"{command} {subcommand}", args[1:], parser, subcommands[subcommand] + ) + if new_args is None: + return new_args + return args[0:1] + new_args + + if "" in subcommands: + return check_command(command, args, parser, subcommands[""]) + + if subcommand is not None: + msg = f"invalid subcommand for {command}: got {subcommand}" + else: + msg = f"no subcommand provided for {command}" + + raise CommandArgError(f"{msg}, expected one of {', '.join(subcommands.keys())}") + + switches = arg_spec["switches"] + args_allowed = set(switches) + args_required = {switch for switch in switches if switches[switch]["required"]} + positional_args = [] + + args = list(args) + while len(args) > 0: + arg = args.pop(0) + + # To facilitate better error messages, we expect that switches are always + # specified as BareWords that start with "-" or ">". This lets us throw an + # error when a switch-like thing doesn't match any supported arguments, + # rather than counting it towards the positional arguments (which usually + # ends up in a vague "too many arguments" error). To make tclint interpret a + # switch-like word as a positional argument, users should wrap it in "", and + # any switches should be BareWords. + contents = arg.contents + if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}): + positional_args.append(arg) + continue + + # TODO check required arguments + if contents in args_allowed: + if switches[contents]["value"]: + try: + args.pop(0) + except IndexError: + raise CommandArgError( + f"invalid arguments for {command}: expected value after" + f" {contents}" + ) + if not switches[contents]["repeated"]: + args_allowed.remove(contents) + if contents in args_required: + args_required.remove(contents) + elif contents in arg_spec: + raise CommandArgError(f"duplicate argument for {command}: {contents}") + else: + prefix_matches = [] + for switch in switches: + if switch.startswith(contents): + prefix_matches.append(switch) + + if len(prefix_matches) == 1: + raise CommandArgError( + f"shortened argument for {command}: expand {contents} to" + f" {prefix_matches[0]}" + ) + + if len(prefix_matches) > 1: + raise CommandArgError( + f"ambiguous argument for {command}: {contents} could be any of" + f" {', '.join(prefix_matches)}" + ) + + raise CommandArgError(f"unrecognized argument for {command}: {contents}") + + if len(args_required) > 1: + raise CommandArgError( + f"missing required arguments for {command}: {', '.join(args_required)}" + ) + elif len(args_required) == 1: + raise CommandArgError( + f"missing required argument for {command}: {args_required.pop()}" + ) + + min_positionals = 0 + max_positionals: Optional[int] = 0 + for positional in arg_spec["positionals"]: + if positional["value"]["type"] == "variadic": + max_positionals = None + + if positional["required"]: + min_positionals += 1 + if max_positionals is not None: + max_positionals += 1 + + check = check_count( + command, + min=min_positionals, + max=max_positionals, + args_name="positional args", + ) + check(positional_args, None) + + return None diff --git a/server/src/tools/commands/plugins.py b/server/src/tools/commands/plugins.py new file mode 100644 index 0000000..9c41c5b --- /dev/null +++ b/server/src/tools/commands/plugins.py @@ -0,0 +1,86 @@ +from importlib.metadata import entry_points +import json +import pathlib +from typing import Dict, Optional +from types import ModuleType + +import voluptuous + +from src.tools.commands.schema import schema as command_schema + + +class _PluginManager: + def __init__(self): + self._loaded = {} + self._installed = {} + self._loaded_specs = {} + for plugin in entry_points(group="tclint.plugins"): + if plugin.name in self._installed: + print(f"Warning: found duplicate definitions for plugin {plugin.name}") + self._installed[plugin.name] = plugin + + def load(self, name: str) -> Optional[Dict]: + if name in self._loaded: + return self._loaded[name] + + mod = self._load(name) + self._loaded[name] = mod + return mod + + def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + if path in self._loaded_specs: + return self._loaded_specs[path] + + spec = self._load_from_spec(path) + self._loaded_specs[path] = spec + return spec + + def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + try: + with open(path.expanduser(), "r") as f: + spec = json.load(f) + except (FileNotFoundError, RuntimeError): + print(f"Warning: command spec {path} not found, skipping...") + return None + + try: + # Apply defaults and validate the spec. + spec = command_schema(spec) + except voluptuous.Invalid as e: + print(f"Warning: invalid command spec {path}: {e}") + return None + + return spec["commands"] + + def get_mod(self, name: str) -> Optional[ModuleType]: + if name not in self._installed: + print(f"Warning: plugin {name} is not installed") + return None + + plugin = self._installed[name] + + try: + module = plugin.load() + except Exception as e: + print(f"Warning: error loading plugin {name}: {e}") + return None + + return module + + def _load(self, name: str): + module = self.get_mod(name) + if module is None: + print(f"Skipping requested plugin {name}") + return None + + if not hasattr(module, "commands"): + print(f"Warning: skipping plugin {name} since it does not define commands") + return None + + return getattr(module, "commands") + + +# TODO: we'll probably want to construct this in the tclint entry point and pass +# it around rather than using a singleton instance, but this made for an easier +# refactor. +PluginManager = _PluginManager() diff --git a/server/src/tools/commands/schema.py b/server/src/tools/commands/schema.py new file mode 100644 index 0000000..7662a10 --- /dev/null +++ b/server/src/tools/commands/schema.py @@ -0,0 +1,35 @@ +from collections.abc import Callable +from voluptuous import Schema, Optional, Or, Self + +# Need to define this as a Schema with required=True to ensure that this requirement +# persists through the Or in the main schema definition. +_command_args = Schema( + { + Optional("positionals", default=[]): [ + { + "name": str, + "required": bool, + "value": Or({"type": "any"}, {"type": "variadic"}), + } + ], + Optional("switches", default={}): { + Optional(str): { + "required": bool, + "repeated": bool, + "value": Or({"type": "any"}, None), + Optional("metavar"): str, + } + }, + }, + required=True, +) + +commands_schema = Schema( + {Optional(str): Or(_command_args, None, {"subcommands": Self}, Callable)}, + required=True, +) + +schema = Schema( + {"name": str, "commands": commands_schema}, + required=True, +) diff --git a/server/src/tools/comments.py b/server/src/tools/comments.py new file mode 100644 index 0000000..293a997 --- /dev/null +++ b/server/src/tools/comments.py @@ -0,0 +1,91 @@ +from collections import defaultdict + +from src.tools.syntax_tree import Visitor +from src.tools.violations import ALL_RULES, Rule + + +class CommentVisitor(Visitor): + """Scans the tree for lint waiver comments.""" + + def __init__(self): + # line -> [rule] + self.ignore_lines = defaultdict(set) + + self._disable_regions = { + # rule -> line + } + + def run(self, tree, path): + self._path = path + tree.accept(self, recurse=True) + + # resolve remaining disabled regions + last_line = tree.end_pos[0] + for rule, start_line in self._disable_regions.items(): + for line in range(start_line, last_line + 1): + self.ignore_lines[line].add(rule) + + return self.ignore_lines + + def visit_comment(self, comment): + contents = comment.value.strip() + + if not contents.startswith("tclint-"): + return + + split = contents.split(" ", 1) + + command = split[0] + + rule_strs = [] + if len(split) > 1: + rest = split[-1] + rule_strs = rest.split("--", 1)[0] + rule_strs = rule_strs.replace(" ", "") + rule_strs = rule_strs.split(",") + + rules = [] + if not rule_strs: + # default if no rules specified is all violation types + rules = ALL_RULES + else: + for rule in rule_strs: + try: + rules.append(Rule(rule)) + except ValueError: + self._warning( + f"unknown rule '{rule}' provided to '{command}'", comment.pos + ) + + if command == "tclint-disable": + for rule in rules: + # if in dictionary, already disabled - this has no effect + if rule not in self._disable_regions: + self._disable_regions[rule] = comment.line + elif command == "tclint-disable-line": + line = comment.line + self.ignore_lines[line].update(rules) + elif command == "tclint-disable-next-line": + line = comment.line + 1 + self.ignore_lines[line].update(rules) + elif command == "tclint-enable": + for rule in rules: + if rule in self._disable_regions: + disable_start_line = self._disable_regions[rule] + disable_end_line = comment.line + + for line in range(disable_start_line, disable_end_line + 1): + self.ignore_lines[line].add(rule) + + del self._disable_regions[rule] + else: + self._warning( + f"comment starts with '{command}', which looks like a tclint keyword." + " Is this a typo?", + comment.pos, + ) + + def _warning(self, message, pos): + # TODO: formal warning mechanism + prefix = self._path if self._path is not None else "(stdin)" + print(f"Warning: {prefix}:{pos[0]}:{pos[1]}: {message}") diff --git a/server/src/tools/config.py b/server/src/tools/config.py new file mode 100644 index 0000000..87cf234 --- /dev/null +++ b/server/src/tools/config.py @@ -0,0 +1,429 @@ +import argparse +import pathlib +from typing import Union, List +from typing import Optional as OptionalType +import dataclasses +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from voluptuous import Schema, Optional, And, Coerce, Invalid, Range + +from src.tools.violations import Rule + + +@dataclasses.dataclass +class Config: + """This dataclass defines the supported Config fields and their default + values. It provides an external interface for accessing config values. + + The type annotations defined here are fairly loose - more specific type + validation (and normalization) is defined by `validators` below. + """ + + exclude: List[str] = dataclasses.field(default_factory=list) + ignore: List[Rule] = dataclasses.field(default_factory=list) + commands: OptionalType[pathlib.Path] = dataclasses.field(default=None) + extensions: List[str] = dataclasses.field( + default_factory=lambda: ["tcl", "sdc", "xdc", "upf"] + ) + style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None) + style_line_length: int = dataclasses.field(default=100) + style_max_blank_lines: int = dataclasses.field(default=2) + style_indent_namespace_eval: bool = dataclasses.field(default=True) + style_spaces_in_braces: bool = dataclasses.field(default=False) + + def apply_cli_args(self, args): + args_dict = vars(args) + for field in dataclasses.fields(self): + if field.name in args_dict and args_dict[field.name] is not None: + setattr(self, field.name, args_dict[field.name]) + + # Special arguments that aren't handled automatically + if "extend_exclude" in args_dict and args_dict["extend_exclude"] is not None: + self.exclude.extend(args_dict["extend_exclude"]) + + if "extend_ignore" in args_dict and args_dict["extend_ignore"] is not None: + self.ignore.extend(args_dict["extend_ignore"]) + + def get_indent(self) -> str: + """Get indent setting as string. + + This helper does two things. One, it's a helpful utility to factor out the logic + required for calculating the indent. Two, it lets us ergonomically store if the + indentation is not set in style_indent, which the LSP relies on. + """ + if self.style_indent is None: + # Default indent + return " " * 4 + elif self.style_indent == "tab": + return "\t" + elif isinstance(self.style_indent, int): + return " " * self.style_indent + + # Should be unreachable, validated on ingestion of config + raise ValueError( + f"unexpected value for config.style_indent: {self.style_indent}" + ) + + +# Validators using `voluptuous` library that check and normalize config inputs. +# Used for checking both config files as well as config-related CLI args. + +# Using these for CLI args adds a constraint that all non-boolean validators +# need to be able to normalize a value from a string. This means one could put +# e.g. a string representation of a list into a .toml config file, but we shouldn't +# document this, since it won't be considered stable behavior. + + +def _str2list(s): + """Handles string-to-list normalization.""" + if isinstance(s, str): + if s == "": + return [] + return [v.strip() for v in s.split(",")] + return s + + +_VALIDATORS = { + # note: it's ok if paths don't exist - allows for generic + # configurations with directories like .git/ excluded + "exclude": _str2list, + "ignore": And( + _str2list, + [ + Coerce(Rule, msg="invalid rule ID"), + ], + ), + "commands": Coerce(pathlib.Path), + "extensions": _str2list, + "style_indent": Coerce( + lambda v: v if v == "tab" else int(v), msg="expected integer or 'tab'" + ), + "style_line_length": Coerce(int), + "style_max_blank_lines": And( + Coerce(int), + # we could technically support i >= 0, but I think 0 would be a weird + # setting and this lets us ignore pluralizing the violation message :) + Range(min=1), + ), + "style_indent_namespace_eval": bool, + "style_spaces_in_braces": bool, +} + + +def _validate_config(config): + """Validates dictionary read from TOML config file. Individual value validators + are implemented in the global dict, this defines the actual structure of the + schema.""" + + base_config = { + Optional("ignore"): _VALIDATORS["ignore"], + Optional("commands"): _VALIDATORS["commands"], + Optional("style"): { + Optional("indent"): _VALIDATORS["style_indent"], + Optional("line-length"): _VALIDATORS["style_line_length"], + Optional("max-blank-lines"): _VALIDATORS["style_max_blank_lines"], + Optional("indent-namespace-eval"): _VALIDATORS[ + "style_indent_namespace_eval" + ], + Optional("spaces-in-braces"): _VALIDATORS["style_spaces_in_braces"], + }, + } + + schema = Schema( + { + # exclude and extensions can only be used in global context + Optional("exclude"): _VALIDATORS["exclude"], + Optional("extensions"): _VALIDATORS["extensions"], + **base_config, + Optional("fileset"): Schema( + [{"paths": [Coerce(pathlib.Path)], **base_config}], required=True + ), + } + ) + + try: + return schema(config) + except Invalid as e: + if not e.path: + raise ConfigError(e.error_message) + + # Stringify error path to my own taste. + path = [] + for item in e.path: + if isinstance(item, int): + # Brackets around indices + if len(path) > 0: + path[-1] += f"[{item}]" + else: + path.append(f"[{item}]") + else: + path.append(str(item)) + + raise ConfigError(f"{e.error_message} ({'.'.join(path)})") + + +def _validator(key): + def func(s): + try: + return Schema(_VALIDATORS[key])(s) + except Invalid as e: + raise argparse.ArgumentTypeError(str(e)) + + return func + + +def _add_bool(group, parser, dest, yes_flag, no_flag): + mutex_group = group.add_mutually_exclusive_group(required=False) + mutex_group.add_argument(yes_flag, dest=dest, action="store_true") + mutex_group.add_argument(no_flag, dest=dest, action="store_false") + parser.set_defaults(**{dest: None}) + + +def setup_common_config_cli_args(config_group): + config_group.add_argument( + "--exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' + ) + config_group.add_argument( + "--extend-exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' + ) + config_group.add_argument( + "--extensions", type=_validator("extensions"), metavar='"tcl, xdc, ..."' + ) + config_group.add_argument( + "--commands", type=_validator("commands"), metavar="" + ) + + +def setup_config_cli_args(parser): + """This method defines config-related CLI arguments. + + The destvars of these switches should match the fields of Config. + """ + config_group = parser.add_argument_group("configuration arguments") + + config_group.add_argument( + "--ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' + ) + config_group.add_argument( + "--extend-ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' + ) + setup_common_config_cli_args(config_group) + config_group.add_argument( + "--style-line-length", + type=_validator("style_line_length"), + metavar="", + ) + + +def setup_tclfmt_config_cli_args(parser): + """This method defines the subset of config-related CLI arguments used by tclfmt. + + The destvars of these switches should match the fields of Config. + """ + config_group = parser.add_argument_group("configuration arguments") + + setup_common_config_cli_args(config_group) + + config_group.add_argument( + "--indent", + type=_validator("style_indent"), + metavar="", + dest="style_indent", + ) + config_group.add_argument( + "--max-blank-lines", + type=_validator("style_max_blank_lines"), + metavar="", + dest="style_max_blank_lines", + ) + _add_bool( + config_group, + parser, + "style_indent_namespace_eval", + "--indent-namespace-eval", + "--no-indent-namespace-eval", + ) + _add_bool( + config_group, + parser, + "style_spaces_in_braces", + "--spaces-in-braces", + "--no-spaces-in-braces", + ) + + +def _flatten(d, prefix=None): + """Flattens TOML config dictionary structure to match the flat set of fields + expected by Config dataclass.""" + if prefix is None: + prefix = [] + + flat = {} + for k, v in d.items(): + if isinstance(v, dict): + flat.update(_flatten(v, prefix=prefix + [k])) + else: + flat["_".join(prefix + [k]).replace("-", "_")] = v + + return flat + + +class RunConfig: + """Class that holds information about both global and fileset configs. User + code can get a Config object that applies to a particular file by calling + get_from_path() and supplying that file's path.""" + + def __init__(self, global_config=None, fileset_configs=None): + if global_config is not None: + self._global_config = global_config + else: + self._global_config = Config() + + self._fileset_configs = [ + # ([pathlib.Path...], Config]) + ] + if fileset_configs is not None: + self._fileset_configs = fileset_configs + + @property + def exclude(self): + return self._global_config.exclude + + @property + def extensions(self): + return self._global_config.extensions + + @classmethod + def from_dict(cls, config_dict: dict, root: pathlib.Path): + config_dict = _validate_config(config_dict) + try: + fileset_config_dicts = config_dict.pop("fileset") + except KeyError: + fileset_config_dicts = [] + + config_dict = _flatten(config_dict) + global_config = Config(**config_dict) + + fileset_configs = [] + for fileset_config in fileset_config_dicts: + paths = [] + for path in fileset_config.pop("paths"): + if not path.is_absolute(): + path = root / path + paths.append(path.resolve()) + + fileset_config = _flatten(fileset_config) + + # pull in default values from global config + full_fileset_config = config_dict.copy() + full_fileset_config.update(fileset_config) + + fileset_configs.append((paths, Config(**full_fileset_config))) + + return cls(global_config, fileset_configs) + + @classmethod + def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path): + path = pathlib.Path(path) + + if not path.exists(): + raise FileNotFoundError + + with open(path, "rb") as f: + try: + data = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"{path}: {e}") + + try: + return cls.from_dict(data, root) + except ConfigError as e: + raise ConfigError(f"{path}: {e}") + + @classmethod + def from_pyproject(cls, directory=None): + if directory is None: + directory = pathlib.Path(".") + else: + directory = pathlib.Path(directory) + + path = directory / "pyproject.toml" + + if not path.exists(): + raise FileNotFoundError + + with open(path, "rb") as f: + data = tomllib.load(f) + + tclint_config = data.get("tool", {})["tclint"] + + try: + return cls.from_dict(tclint_config, directory) + except ConfigError as e: + raise ConfigError(f"pyproject.toml: {e}") + + def get_for_path(self, path) -> Config: + if path is None: + return self._global_config + + path = path.resolve() + for fileset_paths, config in self._fileset_configs: + for fileset_path in fileset_paths: + if path.is_relative_to(fileset_path): + return config + + return self._global_config + + def apply_cli_args(self, args): + self._global_config.apply_cli_args(args) + for _, fileset_config in self._fileset_configs: + fileset_config.apply_cli_args(args) + + +class ConfigError(Exception): + pass + + +DEFAULT_CONFIGS = ("tclint.toml", ".tclint") + + +def get_config( + config_path: OptionalType[pathlib.Path], root: pathlib.Path +) -> OptionalType[RunConfig]: + """Loads a config file. + + If `config_path` is supplied, attempts to read config file from this path. If the + path can't be found, raises a ConfigError. + + Otherwise, attempts to read config from `root`/{tclint.toml, .tclint, + pyproject.toml} (in that order). If none of these files can be found, returns None. + + `root` is also used to resolve some relative paths in the config file. + """ + # user-supplied + if config_path is not None: + try: + return RunConfig.from_path(config_path, root) + except FileNotFoundError: + raise ConfigError(f"path {config_path} doesn't exist") + + for path in DEFAULT_CONFIGS: + try: + return RunConfig.from_path(root / path, root) + except FileNotFoundError: + pass + + try: + return RunConfig.from_pyproject(directory=root) + except ConfigError as e: + raise e + except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError): + # just skip if file doesn't exist, contains TOML errors, or tclint key not found + pass + + return None diff --git a/server/src/tools/format.py b/server/src/tools/format.py new file mode 100644 index 0000000..66441e5 --- /dev/null +++ b/server/src/tools/format.py @@ -0,0 +1,480 @@ +import dataclasses +import itertools +import textwrap +from typing import List, Tuple, Union +import sys + +from src.tools.syntax_tree import ( + Node, + Script, + Command, + Comment, + CommandSub, + BareWord, + QuotedWord, + BracedWord, + CompoundBareWord, + VarSub, + ArgExpansion, + Expression, + BracedExpression, + ParenExpression, + UnaryOp, + BinaryOp, + TernaryOp, + Function, +) +from src.tools.parser import Parser +from src.tools.syntax_tree import List as ListNode + + +@dataclasses.dataclass +class LiteralBlock: + block: List[str] + pos: Tuple[int, int] + end_pos: Tuple[int, int] + + +@dataclasses.dataclass +class FormatterOpts: + indent: str + spaces_in_braces: bool + max_blank_lines: int + indent_namespace_eval: bool + + +class Formatter: + def __init__(self, opts: FormatterOpts): + self.opts = opts + + def _indent(self, lines: List[str], indent: str) -> List[str]: + indented = [] + for line in lines: + if line == "": + indented.append("") + else: + indented.append(indent + line) + + return indented + + def _brace(self, lines: List[str]) -> List[str]: + spaces_in_braces = " " if self.opts.spaces_in_braces else "" + if lines == [""]: + return ["{" + spaces_in_braces + "}"] + + braced_lines = lines[:] + braced_lines[0] = "{" + spaces_in_braces + lines[0] + braced_lines[-1] += spaces_in_braces + "}" + return braced_lines + + def format(self, *nodes: Union[Node, LiteralBlock]) -> List[str]: + formatted = [] + for node in nodes: + if isinstance(node, Script): + formatted += self.format_script(node) + elif isinstance(node, Command): + formatted += self.format_command(node) + elif isinstance(node, Comment): + formatted += self.format_comment(node) + elif isinstance(node, CommandSub): + formatted += self.format_command_sub(node) + elif isinstance(node, BareWord): + formatted += self.format_bare_word(node) + elif isinstance(node, QuotedWord): + formatted += self.format_quoted_word(node) + elif isinstance(node, BracedWord): + formatted += self.format_braced_word(node) + elif isinstance(node, CompoundBareWord): + formatted += self.format_compound_bare_word(node) + elif isinstance(node, VarSub): + formatted += self.format_var_sub(node) + elif isinstance(node, ArgExpansion): + formatted += self.format_arg_expansion(node) + elif isinstance(node, ListNode): + formatted += self.format_list(node) + elif isinstance(node, Expression): + formatted += self.format_expression(node) + elif isinstance(node, BracedExpression): + formatted += self.format_braced_expression(node) + elif isinstance(node, ParenExpression): + formatted += self.format_paren_expression(node) + elif isinstance(node, UnaryOp): + formatted += self.format_unary_op(node) + elif isinstance(node, BinaryOp): + formatted += self.format_binary_op(node) + elif isinstance(node, TernaryOp): + formatted += self.format_ternary_op(node) + elif isinstance(node, Function): + formatted += self.format_function(node) + elif isinstance(node, LiteralBlock): + formatted += node.block + else: + assert False, f"unrecognized node: {type(node)}" + + return formatted + + def format_top(self, script: str, parser: Parser) -> str: + tree = parser.parse(script) + self.script = script.split("\n") + return "\n".join(self.format_script_contents(tree)) + "\n" + + def format_partial(self, script: str, parser: Parser) -> str: + """Formats a partial Tcl script. + + This function formats a partial script according to the gofmt partial formatting + rules, "[preserving] leading indentation as well as leading and trailing spaces" + (ref: https://pkg.go.dev/cmd/gofmt#pkg-overview). Unlike Go, we have no way of + detecting if a given script is a program fragment, hence the distinct method + from `format_top` . + """ + leading = "".join(itertools.takewhile(str.isspace, script)) + try: + leading, indent = leading.rsplit("\n", 1) + leading += "\n" + except ValueError: + leading, indent = "", leading + trailing = "".join(itertools.takewhile(str.isspace, reversed(script)))[::-1] + + script = script.strip() + tree = parser.parse(script) + self.script = script.split("\n") + + formatted = "\n".join(self.format_script_contents(tree)) + + return leading + textwrap.indent(formatted, indent) + trailing + + def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]: + to_format = [] + skip_formatting_start = None + for child in script.children: + if skip_formatting_start is None: + to_format.append(child) + + if isinstance(child, Comment): + if child.value.strip() == "tclfmt-disable": + if skip_formatting_start is not None: + print( + "Warning: encountered 'tclint-disable' while formatting is" + " already disabled, ignoring...", + file=sys.stderr, + ) + else: + skip_formatting_start = child.pos[0] + elif child.value.strip() == "tclfmt-enable": + if skip_formatting_start is None: + print( + "Warning: encountered 'tclint-enable' while formatting is" + " already disabled, ignoring...", + file=sys.stderr, + ) + else: + skip_formatting_end = child.pos[0] + block = self.script[skip_formatting_start:skip_formatting_end] + to_format.append( + LiteralBlock( + block, + pos=(skip_formatting_start + 1, 1), + end_pos=(skip_formatting_end, 1), + ) + ) + skip_formatting_start = None + + if skip_formatting_start is not None: + print("Warning: missing 'tclint-enable'", file=sys.stderr) + to_format.append( + LiteralBlock( + self.script[skip_formatting_start:], + pos=(skip_formatting_start + 1, 1), + end_pos=script.end_pos, + ) + ) + + formatted = [""] + last_line = None + for child in to_format: + if last_line is not None: + if last_line == child.pos[0]: + if isinstance(child, Comment): + formatted[-1] += " ;" + else: + formatted[-1] += "; " + else: + newlines = child.pos[0] - last_line + newlines = min(newlines, self.opts.max_blank_lines + 1) + formatted.extend([""] * newlines) + last_line = child.end_pos[0] + + lines = self.format(child) + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + return formatted + + def format_script(self, script: Script, should_indent=True) -> List[str]: + lines = self.format_script_contents(script) + if script.pos[0] == script.end_pos[0]: + return self._brace(lines) + + # Usually, we enforce that multi-line scripts start on a new line after the open + # brace. However, if a comment was originally on the same line as the open brace + # we preserve it, since it's probably meant to be associated with this line + # (e.g. a tclint-disable-line). + open_brace = "{" + if ( + len(script.children) > 0 + and isinstance(script.children[0], Comment) + and script.pos[0] == script.children[0].pos[0] + ): + open_brace += " " + lines[0] + lines = lines[1:] + + if should_indent: + return [open_brace] + self._indent(lines, self.opts.indent) + ["}"] + else: + return [open_brace] + lines + ["}"] + + def format_command(self, command: Command) -> List[str]: + is_namespace_eval = ( + command.routine.contents == "namespace" + and len(command.args) > 0 + and command.args[0].contents == "eval" + ) + should_indent = not is_namespace_eval or self.opts.indent_namespace_eval + + hanging_indent = False + formatted = self.format(command.routine) + last_line = command.routine.end_pos[0] + for child in command.args: + if isinstance(child, Script): + child_lines = self.format_script(child, should_indent=should_indent) + else: + child_lines = self.format(child) + + if last_line == child.pos[0]: + formatted[-1] += " " + formatted[-1] += child_lines[0] + else: + formatted[-1] += " \\" + formatted.append(self.opts.indent + child_lines[0]) + hanging_indent = True + + if hanging_indent: + formatted.extend(self._indent(child_lines[1:], self.opts.indent)) + else: + formatted.extend(child_lines[1:]) + + last_line = child.end_pos[0] + + return formatted + + def format_comment(self, comment: Comment) -> List[str]: + return [f"#{comment.value}"] + + def format_command_sub(self, command_sub): + if len(command_sub.children) == 0: + return ["[]"] + + formatted = [] + contents = self.format_script_contents(command_sub) + if len(command_sub.children) > 1 and len(contents) > 1: + formatted.append("[") + formatted.extend(self._indent(contents, self.opts.indent)) + formatted.append("]") + else: + formatted.append("[" + contents[0]) + formatted.extend(contents[1:]) + formatted[-1] += "]" + + return formatted + + def format_bare_word(self, word) -> List[str]: + # Property enforced by parser + assert word.contents is not None + return [word.contents] + + def format_quoted_word(self, word) -> List[str]: + if word.contents is not None: + return [f'"{word.contents}"'] + + formatted = "" + for child in word.children: + formatted += "\n".join(self.format(child)) + + return [f'"{formatted}"'] + + def format_braced_word(self, word) -> List[str]: + assert word.contents is not None + return [f"{{{word.contents}}}"] + + def format_compound_bare_word(self, word) -> List[str]: + formatted = [""] + for child in word.children: + child_lines = self.format(child) + formatted[-1] += child_lines[0] + formatted.extend(child_lines[1:]) + + return formatted + + def format_var_sub(self, varsub) -> List[str]: + # We might be able to make the formatter infer whether braces are required, and + # remove them from the syntax tree. For now it's easier to just mimic the + # original format. + if varsub.braced: + formatted = [f"${{{varsub.value}}}"] + else: + formatted = [f"${varsub.value}"] + + if varsub.children: + # We just concatenate everything as is, since changes in whitespace are + # semantically meaningful in this context. Any newlines are captured by + # BareWords. + formatted[-1] += "(" + for child in varsub.children: + child_lines = self.format(child) + formatted[-1] += child_lines[0] + formatted.extend(child_lines[1:]) + formatted[-1] += ")" + + return formatted + + def format_arg_expansion(self, arg_expansion) -> List[str]: + lines = self.format(arg_expansion.list) + lines[0] = "{*}" + lines[0] + + return lines + + def format_list(self, list_node) -> List[str]: + # Similar to Script, but the contents are a bit more straightforward. + contents = [""] + last_line = None + for child in list_node.children: + if last_line is not None: + if last_line == child.pos[0]: + contents[-1] += " " + else: + newlines = child.pos[0] - last_line + newlines = min(newlines, 3) + contents.extend([""] * newlines) + + lines = self.format(child) + contents[-1] += lines[0] + contents.extend(lines[1:]) + + last_line = child.end_pos[0] + + if list_node.pos[0] == list_node.end_pos[0]: + return self._brace(contents) + + return ["{"] + self._indent(contents, self.opts.indent) + ["}"] + + def format_expression(self, expr) -> List[str]: + formatted = [""] + for child in expr.children: + lines = self.format(child) + formatted[-1] += lines[0] + for line in lines[1:]: + formatted[-1] += " \\" + formatted += self._indent([line], self.opts.indent) + + # Trick: we know there are quotes around the expression if the start of the + # expression is a different column than its first child. + quoted = expr.pos[1] != expr.children[0].pos[1] + if quoted: + formatted[0] = '"' + formatted[0] + formatted[-1] += '"' + + return formatted + + def format_braced_expression(self, expr) -> List[str]: + formatted = [""] + for child in expr.children: + lines = self.format(child) + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + if expr.pos[0] == expr.end_pos[0]: + return self._brace(formatted) + + return ["{"] + self._indent(formatted, self.opts.indent) + ["}"] + + def format_paren_expression(self, expr) -> List[str]: + body = expr.body + + formatted = ["("] + lines = self.format(body) + if expr.pos[0] != body.pos[0]: + formatted.extend(lines) + else: + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) + + if expr.end_pos[0] != body.end_pos[0]: + formatted.append(")") + else: + formatted[-1] += ")" + + return formatted + + def format_unary_op(self, expr): + op = self.format(expr.operator) + assert len(op) == 1 + + lines = self.format(expr.operand) + lines[0] = op[0] + lines[0] + return lines + + def _format_op(self, expr) -> List[str]: + nodes = expr.children + formatted = self.format(nodes[0]) + + last = nodes[0] + for next in nodes[1:]: + lines = self.format(next) + if last.end_pos[0] != next.pos[0]: + formatted.extend(lines) + else: + formatted[-1] += " " + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + last = next + + return formatted + + def format_binary_op(self, expr) -> List[str]: + return self._format_op(expr) + + def format_ternary_op(self, expr) -> List[str]: + return self._format_op(expr) + + def format_function(self, function): + name = self.format(function.name) + assert len(name) == 1 + name = name[0] + + formatted = [f"{name}("] + + last = function.name + for i, child in enumerate(function.args): + if i > 0: + formatted[-1] += "," + lines = self.format(child) + if last.end_pos[0] != child.pos[0]: + formatted.extend(lines) + else: + if i > 0: + formatted[-1] += " " + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + last = child + + # indent any continuation lines, but we leave the closing paren dedented + formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) + + if last.end_pos[0] != function.end_pos[0]: + formatted.append(")") + else: + formatted[-1] += ")" + + return formatted diff --git a/server/src/tools/lexer.py b/server/src/tools/lexer.py index 1375c4b..dc18312 100644 --- a/server/src/tools/lexer.py +++ b/server/src/tools/lexer.py @@ -1,34 +1,30 @@ -from enum import Enum import ply.lex as lex from typing import Tuple - -class Tok(str, Enum): - TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" - TOK_BACKSLASH_SUB = "BACKSLASH_SUB" - TOK_NEWLINE = "NEWLINE" - TOK_SEMI = "SEMI" - TOK_WS = "WS" - TOK_QUOTE = "QUOTE" - TOK_ARG_EXPANSION = "ARG_EXPANSION" - TOK_LBRACE = "LBRACE" - TOK_RBRACE = "RBRACE" - TOK_STAR = "STAR" - TOK_LBRACKET = "LBRACKET" - TOK_RBRACKET = "RBRACKET" - TOK_DOLLAR = "DOLLAR" - TOK_LPAREN = "LPAREN" - TOK_RPAREN = "RPAREN" - TOK_HASH = "HASH" - TOK_ALPHA_CHARS = "ALPHA_CHARS" - TOK_NUM_CHARS = "NUM_CHARS" - TOK_NAMESPACE_SEP = "NAMESPACE_SEP" - TOK_CHAR = "CHAR" - TOK_CONTENTS = "CONTENTS" - +TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" +TOK_BACKSLASH_SUB = "BACKSLASH_SUB" +TOK_NEWLINE = "NEWLINE" +TOK_SEMI = "SEMI" +TOK_WS = "WS" +TOK_QUOTE = "QUOTE" +TOK_ARG_EXPANSION = "ARG_EXPANSION" +TOK_LBRACE = "LBRACE" +TOK_RBRACE = "RBRACE" +TOK_STAR = "STAR" +TOK_LBRACKET = "LBRACKET" +TOK_RBRACKET = "RBRACKET" +TOK_DOLLAR = "DOLLAR" +TOK_LPAREN = "LPAREN" +TOK_RPAREN = "RPAREN" +TOK_HASH = "HASH" +TOK_ALPHA_CHARS = "ALPHA_CHARS" +TOK_NUM_CHARS = "NUM_CHARS" +TOK_NAMESPACE_SEP = "NAMESPACE_SEP" +TOK_CHAR = "CHAR" +TOK_CONTENTS = "CONTENTS" +TOK_EOF = None STATE_BRACEDWORD = "bracedword" -TOK_EOF = None class TclSyntaxError(Exception): @@ -39,27 +35,38 @@ class TclSyntaxError(Exception): class _LexTable: - tokens = tuple(t.value for t in Tok) + tokens = ( + TOK_BACKSLASH_NEWLINE, + TOK_BACKSLASH_SUB, + TOK_NEWLINE, + TOK_SEMI, + TOK_WS, + TOK_QUOTE, + TOK_ARG_EXPANSION, + TOK_LBRACE, + TOK_RBRACE, + TOK_STAR, + TOK_LBRACKET, + TOK_RBRACKET, + TOK_DOLLAR, + TOK_LPAREN, + TOK_RPAREN, + TOK_HASH, + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_NAMESPACE_SEP, + TOK_CHAR, + TOK_CONTENTS, + ) + # This defines a conditional lexing state for parsing braced words. This is a + # performance optimization; since there are few special characters in this context, + # we can use a smaller set of tokens to parse them faster. This has a large impact + # since most Tcl programs have a large number of braced words. Any token with + # `bracedword` in its name is included in this state. Tokens that are included in + # this state and the default state also include `INITIAL` in their name. states = ((STATE_BRACEDWORD, "exclusive"),) - def __init__(self): - self.lexer = lex.lex(object=self) - self.lexer.lineno = 1 - self.lexer.colno = 1 - - def new_lexer(self, pos=None): - lexer = self.lexer.clone() - lexer.lineno = 1 - lexer.colno = 1 - - if pos is not None: - line, col = pos - lexer.lineno = line - lexer.colno = col - - return lexer - def _tok(self, t): pos = (t.lexer.lineno, t.lexer.colno) t.lexer.lineno += t.value.count("\n") @@ -146,6 +153,9 @@ class _LexTable: r"[A-Za-z_]+" return self._tok(t) + # Valid numeric chars in variable names + # This is split up from the above to facilitate expression parsing, since + # e.g. 1eq1 can't be a single token. def t_NUM_CHARS(self, t): r"[0-9]+" return self._tok(t) @@ -170,6 +180,23 @@ class _LexTable: print("Illegal character '%s'" % t.value[0]) t.lexer.skip(1) + def __init__(self): + self.lexer = lex.lex(object=self) + self.lexer.lineno = 1 + self.lexer.colno = 1 + + def new_lexer(self, pos=None): + lexer = self.lexer.clone() + lexer.lineno = 1 + lexer.colno = 1 + + if pos is not None: + line, col = pos + lexer.lineno = line + lexer.colno = col + + return lexer + # Calling `lex.lex()` performs an expensive reflection process to generate the lexer. # This singleton class holds a preinitialized lexer that can then be cloned to create @@ -214,21 +241,3 @@ class Lexer: def assert_(self, *tokens): assert self.current.type in tokens self.next() - - -def dump_tokens(code): - lx = Lexer() - lx.input(code) - out = [] - while lx.type() is not TOK_EOF: - out.append((lx.type(), lx.value(), lx.pos())) - lx.next() - return out - - -if __name__ == "__main__": - code = ( - "set a 1\nputs $a\nnamespace eval test {}\n proc myProc {arg1 {optArg 10}} {}" - ) - for ttype, val, (ln, col) in dump_tokens(code): - print(f"{ttype:<18} {val!r:<10} @ ({ln},{col})") diff --git a/server/src/tools/parser.py b/server/src/tools/parser.py index abf6553..5819a75 100644 --- a/server/src/tools/parser.py +++ b/server/src/tools/parser.py @@ -1,19 +1,64 @@ -from tools.lexer import Lexer, TclSyntaxError, Tok, TOK_EOF -from tools import syntax_tree as st -from tools.commands import CommandArgError, get_commands -from tools.checks import check_command +import string +import re + +from src.tools.lexer import ( + Lexer, + TclSyntaxError, + STATE_BRACEDWORD, + TOK_BACKSLASH_NEWLINE, + TOK_NEWLINE, + TOK_SEMI, + TOK_WS, + TOK_QUOTE, + TOK_ARG_EXPANSION, + TOK_LBRACE, + TOK_RBRACE, + TOK_LBRACKET, + TOK_RBRACKET, + TOK_DOLLAR, + TOK_LPAREN, + TOK_RPAREN, + TOK_HASH, + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_NAMESPACE_SEP, + TOK_EOF, +) +from src.tools.syntax_tree import ( + Script, + Comment, + Command, + CommandSub, + ArgExpansion, + VarSub, + BareWord, + BracedWord, + QuotedWord, + CompoundBareWord, + List, + Expression, + BracedExpression, + ParenExpression, + UnaryOp, + BinaryOp, + TernaryOp, + Function, +) +from src.tools.commands import CommandArgError, get_commands +from src.tools.commands.checks import check_command +from src.tools.violations import Rule, Violation def _strip_ws(parse_func): """Decorator used by expression parser for stripping whitespace around a node.""" def func(parser, ts): - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: ts.next() node = parse_func(parser, ts) - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: ts.next() return node @@ -22,6 +67,8 @@ def _strip_ws(parse_func): class _Word: + """Helper class for constructing Word nodes out of multiple segments.""" + def __init__(self): self.segments = [] self.current_segment = "" @@ -30,13 +77,12 @@ class _Word: def add_tok(self, tok): if self.current_start is None: self.current_start = tok.value[1] + self.current_segment += tok.value[0] def add_node(self, node): if self.current_segment != "": self.segments.append( - st.BareWord( - self.current_segment, pos=self.current_start, end_pos=node.pos - ) + BareWord(self.current_segment, pos=self.current_start, end_pos=node.pos) ) self.current_segment = "" self.current_start = None @@ -45,9 +91,7 @@ class _Word: def resolve(self, end_pos): if self.current_segment: self.segments.append( - st.BareWord( - self.current_segment, pos=self.current_start, end_pos=end_pos - ) + BareWord(self.current_segment, pos=self.current_start, end_pos=end_pos) ) return self.segments @@ -57,7 +101,9 @@ class Parser: def __init__(self, debug=False, command_plugins=None): self._debug = debug self._debug_indent = 0 + # TODO: better way to handle this? self.violations = [] + if command_plugins is None: command_plugins = [] self._commands = get_commands(command_plugins) @@ -726,7 +772,7 @@ class Parser: pos=name.pos, ) - delims = {Tok.TOK_RPAREN, TOK_EOF} + delims = {TOK_RPAREN, TOK_EOF} arguments = [] if ts.type() not in delims: @@ -745,11 +791,11 @@ class Parser: arguments.append(self._parse_expression(ts)) ts.expect( - Tok.TOK_RPAREN, + TOK_RPAREN, message="expected close paren after function arguments", pos=name.pos, ) - return st.Function(name, *arguments, pos=name.pos, end_pos=ts.pos()) + return Function(name, *arguments, pos=name.pos, end_pos=ts.pos()) def _all(_list, non_empty=False): diff --git a/server/src/tools/syntax_tree.py b/server/src/tools/syntax_tree.py index 97f646c..d11f6df 100644 --- a/server/src/tools/syntax_tree.py +++ b/server/src/tools/syntax_tree.py @@ -1,4 +1,4 @@ -"""Classes for representing and interacting with Tcl syntax trees.""" +"""Classes for representing and interacting with Tcl syntax trees. """ class Visitor: @@ -188,12 +188,12 @@ class Node: return lines if len(self.children) != len(other.children): - my_children = ",".join( - [child.__class__.__name__ for child in self.children] - ) - other_children = ",".join( - [child.__class__.__name__ for child in other.children] - ) + my_children = ",".join([ + child.__class__.__name__ for child in self.children + ]) + other_children = ",".join([ + child.__class__.__name__ for child in other.children + ]) lines += [f"{indent}-{my_cls}({my_children})"] lines += [f"{indent}+{other_cls}({other_children})"] diff --git a/server/src/tools/violations.py b/server/src/tools/violations.py new file mode 100644 index 0000000..8b08329 --- /dev/null +++ b/server/src/tools/violations.py @@ -0,0 +1,50 @@ +from enum import Enum +from typing import Tuple + + +class Rule(Enum): + """This enum serves a few purposes: + + 1) define symbols for rule IDs to be used in code + 2) map these symbols to names in the UI + 3) collect all rule IDs/provide validation for IDs + """ + + LINE_LENGTH = "line-length" + TRAILING_WHITESPACE = "trailing-whitespace" + COMMAND_ARGS = "command-args" + REDEFINED_BUILTIN = "redefined-builtin" + UNBRACED_EXPR = "unbraced-expr" + REDUNDANT_EXPR = "redundant-expr" + + def __str__(self): + return self.value + + +ALL_RULES = [rule for rule in Rule] + + +class Violation: + def __init__( + self, id: Rule, message: str, start: Tuple[int, int], end: Tuple[int, int] + ): + self.id = id + self.message = message + self.start = start + self.end = end + + def __lt__(self, other): + return self.start < other.start + + def __str__(self): + line, col = self.start + rule = str(self.id) + + return f"{line}:{col}: {self.message} [{rule}]" + + @classmethod + def create(cls, id): + def func(message: str, start: Tuple[int, int], end: Tuple[int, int]): + return cls(id, message, start, end) + + return func diff --git a/test/test.tcl b/test/test.tcl index 0163f2d..bbac630 100644 --- a/test/test.tcl +++ b/test/test.tcl @@ -1,3 +1,5 @@ proc myProc {arg {opt 1}} { + +} -} \ No newline at end of file +MOM_abort_program "Test" \ No newline at end of file -- 2.54.0 From d391b5dd792975c03c415cc4dff6213ccde53151 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Jul 2025 11:40:09 +0200 Subject: [PATCH 05/10] add more items --- server/src/common/completion_list.json | 336 ++++++++++++++++++++++++- 1 file changed, 335 insertions(+), 1 deletion(-) diff --git a/server/src/common/completion_list.json b/server/src/common/completion_list.json index 44b6666..d0ae302 100644 --- a/server/src/common/completion_list.json +++ b/server/src/common/completion_list.json @@ -784,7 +784,7 @@ "parameters": [ { "name": "ON|OFF", - "desc": "urns rotary axis output limit ON or OFF." + "desc": "Turns rotary axis output limit ON or OFF." }, { "name": "4|5", @@ -804,6 +804,340 @@ "MOM_limit_output_angle ON 5 360.0 0.0" ] }, + { + "label": "MOM_list_user_defined_events", + "kind": "function", + "description": "This procedure allows you to output or list User Defined Events (UDE) defined in the operation.", + "format": "MOM_list_user_defined_events []", + "parameters": [ + { + "name": "Start|End", + "desc": "Indicates the UDE list at the start or end of the operation." + }, + { + "name": "operation_name", + "desc": "Operation name to output list of UDEs." + }, + { + "name": "definition", + "desc": "Name of the definition file (.def)." + }, + { + "name": "event_handler", + "desc": "Name of event handler file (.tcl). This file should contain a procedure to output the UDE list, such as MOM_info_user_defined_event." + }, + { + "name": "output", + "desc": "Optional. The file where the UDE lists are output. If NOT specified, the UDE lists are output in mom_output_file (as defined in the Manufacturing part report dialog)." + } + ], + "example": [ + "# Example 1", + "MOM_list_user_defined_events \"Start\" `$mom_operation_name` \"$mom_source_directory\\test_info.def\" \"$mom_source_directory\\\\test_info.tcl\" \"c:\\\\Temp\\\\XYZ.txt\" " + ] + }, + { + "label": "MOM_load_definition_file", + "kind": "function", + "description": "This procedure loads the definition file given by filename. If the data in filename (or any of its included files) matches data already loaded by previous definition files, the duplicate data is overridden by the new version. Like any TCL extension, this command may be called from any place in the TCL script.", + "format": "MOM_load_definition_file ", + "parameters": [ + { + "name": "filename", + "desc": "Name of definition file." + } + ], + "example": [ + "# Example 1", + "MOM_load_definition_file lathe" + ] + }, + { + "label": "MOM_load_lathe_thread_cycle_params", + "kind": "function", + "description": "This command loads the parameters of a lathe threading cycle of the current operation. Note: This command should be called after the desired CSYS is set.", + "format": "MOM_load_lathe_thread_cycle_params", + "parameters": [], + "returns": [ + "1 - Successfully loaded parameters.", + "0 - Failed to load." + ], + "example": [ + "# Example 1", + "if {[MOM_load_lathe_thread_cycle_params]} {# Do your thing...}" + ] + }, + { + "label": "MOM_log_message", + "kind": "function", + "description": "This function causes MOM to write a message to the syslog.", + "format": "MOM_log_message ", + "parameters": [ + { + "name": "message", + "desc": "Text string defining the message." + } + ], + "example": [ + "# Example 1", + "MOM_log_message \"Postprocessing Terminated\"" + ] + }, + { + "label": "MOM_on_event_error", + "kind": "function", + "description": "If the TCL interpreter reports an error, this procedure is invoked by NX prior to raising a system error.", + "format": "MOM_on_event_error [_debug]", + "parameters": [ + { + "name": "_debug", + "desc": "Optional. Name of procedure that is invoked only if DEBUG mode is true." + } + ], + "returns": [ + "1 - True (error).", + "0 - False" + ], + "example": [] + }, + { + "label": "MOM_on_parse_error", + "kind": "function", + "description": "If a syntax error is found in the Definition File, this procedure is invoked by NX prior to raising a system error.", + "format": "MOM_on_parse_error [_debug]", + "parameters": [ + { + "name": "_debug", + "desc": "Optional. Name of procedure that is invoked only if DEBUG mode is true." + } + ], + "returns": [ + "1 - True (error).", + "0 - False" + ], + "example": [] + }, + { + "label": "MOM_open_output_file", + "kind": "function", + "description": "This procedure allows you to redirect output to other output files. If the specified file does not exist, a new one is created with that file name.", + "format": "MOM_open_output_file ", + "parameters": [ + { + "name": "filename", + "desc": "Name of output file." + } + ], + "example": [ + "# Example 1", + "MOM_open_output_file $mom_output_doc_file2" + ] + }, + { + "label": "MOM_output_literal", + "kind": "function", + "description": "This procedure outputs a list of literals and variables as a single line.", + "format": "MOM_output_literal [BUFFER]", + "parameters": [ + { + "name": "string", + "desc": "Text string and variables to be output." + }, + { + "name": "BUFFER", + "desc": "Optional. Places the string only in the output buffer. Does not send it to the output file yet." + } + ], + "example": [ + "# Example 1", + "MOM_output_literal \"Start of path $mom_path_name\"" + ] + }, + { + "label": "MOM_output_text", + "kind": "function", + "description": "This procedure outputs a list of literals and variables as a single line. The resulting output is just like MOM_output_literal except that no sequence number is output.", + "format": "MOM_output_text [BUFFER]", + "parameters": [ + { + "name": "string", + "desc": "Text string and variables to be output." + }, + { + "name": "BUFFER", + "desc": "Optional. Places the string only in the output buffer. Does not send it to the output file yet." + } + ], + "example": [ + "# Example 1", + "MOM_output_text \"Start of path $mom_path_name\"" + ] + }, + { + "label": "MOM_output_to_listing_device", + "kind": "function", + "description": "If running in an interactive NX session, this extension outputs string to the NX listing window, otherwise do nothing.", + "format": "MOM_output_to_listing_device ", + "parameters": [ + { + "name": "string", + "desc": "Text string to be output." + } + ], + "example": [ + "# Example 1", + "MOM_output_to_listing_device \"Error occurred while postprocessing.\"" + ] + }, + { + "label": "MOM_polar_convert_point", + "kind": "function", + "description": "Converts a point from a Cartesian coordinate system to a polar coordinate system.", + "format": "MOM_polar_convert_point ", + "parameters": [ + { + "name": "init_vector", + "desc": "Initial vector of the rotary axis at the zero position (i, j, k)." + }, + { + "name": "point", + "desc": "Point to be converted (X, Y, Z, 4th_angle, 5th_angle)." + } + ], + "returns": [ + "The result is saved in the array variable mom_polar_pos." + ], + "example": [ + "# Example 1", + "MOM_polar_convert_point polar_vector_ref mom_pos" + ] + }, + { + "label": "MOM_polar_motion", + "kind": "function", + "description": "Outputs polar motion directly to the NC code output file. If the move is linearized, all of linearized data is output as polar motion.", + "format": "MOM_polar_motion ", + "parameters": [ + { + "name": "polar_block_template", + "desc": "Block template name used to output polar coordinates." + }, + { + "name": "init_vector", + "desc": "Initial vector of the rotary axis at the zero position (i, j, k)." + }, + { + "name": "target_pos", + "desc": "Target position of the polar motion (X, Y, Z, 4th_angle, 5th_angle)." + } + ], + "example": [ + "# Example 1", + "MOM_polar_motion linear_move polar_vector_ref mom_pos" + ] + }, + { + "label": "MOM_post_oper_path", + "kind": "function", + "description": "Postprocesses the named operation. During execution, the variable mom_post_oper_path exists and has a value of 1. The called process uses the same units specifed in the Postprocess dialog box.", + "format": "MOM_post_oper_path [] [] [ ]", + "parameters": [ + { + "name": "operation_name", + "desc": "Description." + }, + { + "name": "output_file", + "desc": "Optional. Name of the output file. If the output_file argument is specified without a post, the default post is used. When the file name is given without a preceding path, the primary output directory is used. When a post is specified, the output_file argument SAME outputs to the active output file." + }, + { + "name": "postprocessor", + "desc": "Optional. Name of the postprocessor. If a post is named, the review file option and warning output setting are governed by the named post." + }, + { + "name": "event_handler_file, definition_file", + "desc": "Optional. The postprocessor TCL and DEF files." + } + ], + "returns": [ + "Returns 1 (True) when the execution is successful, 0 (False) if it is not successful, and -1 if the post is calling itself." + ], + "example": [] + }, + { + "label": "MOM_reload_iks_parameters", + "kind": "function", + "description": "Retrieves kinematic parameters during postprocessing for the current axis chain that is defined in the machine tool loaded in the current CAM setup.", + "format": "MOM_reload_iks_parameters ", + "parameters": [ + { + "name": "axis_chain", + "desc": "Name of the axis chain.." + } + ], + "example": [] + }, + { + "label": "MOM_reload_kinematics", + "kind": "function", + "description": "Refresh the event generator with the current values of all the kinematics variables.", + "format": "MOM_reload_kinematics", + "parameters": [], + "example": [] + }, + { + "label": "MOM_reload_kinematics_variable", + "kind": "function", + "description": "Refresh the event generator with the current values of only the specified kinematics variables. Both scalar and array variables can be reloaded in one call of the command.", + "format": "MOM_reload_kinematics_variable var1 var2 … varN", + "parameters": [ + { + "name": "var1 var2 … varN", + "desc": "Name of kinematic variable(s)." + } + ], + "returns": [ + "The total number of scalar variables and array elements are output to the variable mom_result. If a specified variable does not exist, a warning is written to the syslog." + ], + "example": [] + }, + { + "label": "MOM_reload_variable", + "kind": "function", + "description": "This procedure updates the event generator with the current value of variable_name in the event handler.", + "format": "MOM_reload_variable [-a] ", + "parameters": [ + { + "name": "-a", + "desc": "Optional. Specifies that variable is an array." + }, + { + "name": "variable_name", + "desc": "Name of variable" + } + ], + "example": [ + "# Example 1", + "# The following command will load the current five values of the mom_pos array variable into the event generator.", + "MOM_reload_variable -a mom_pos" + ] + }, + { + "label": "MOM_remove_file", + "kind": "function", + "description": "This command deletes a file with the given name. The permissions on the file that is being asked to be removed should be compatible for removal.", + "format": "MOM_remove_file ", + "parameters": [ + { + "name": "filename", + "desc": "Name of file to be deleted." + } + ], + "example": [ + "# Example 1", + "MOM_remove_file temp_file" + ] + }, { "label": "MOM_source", "kind": "function", -- 2.54.0 From 94181497bd353720d8c6e09388cbe7336bcab37f Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Jul 2025 12:19:38 +0200 Subject: [PATCH 06/10] fix item list --- server/src/common/completion_list.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/common/completion_list.json b/server/src/common/completion_list.json index d0ae302..aec2eb7 100644 --- a/server/src/common/completion_list.json +++ b/server/src/common/completion_list.json @@ -1033,7 +1033,7 @@ ], "example": [ "# Example 1", - "MOM_polar_motion linear_move polar_vector_ref mom_pos" + "MOM_polar_motion \"linear_move\" \"polar_vector_ref\" \"mom_pos\"" ] }, { @@ -1150,7 +1150,7 @@ } ], "example": [ - "MOM_source my_header.tcl" + "MOM_source \"my_header.tcl\"" ] }, { @@ -1169,7 +1169,7 @@ } ], "example": [ - "MOM_suppress_address Always N X Y" + "MOM_suppress \"Always\" \"N\" \"X\" \"Y\"" ] }, { -- 2.54.0 From c34dac847a9a452be5514c5def1bfa26d1376472 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Jul 2025 16:02:39 +0200 Subject: [PATCH 07/10] update parser --- server/src/test_tcl.py | 10 + server/src/tools/__init__.py | 0 server/src/tools/checks.py | 227 ------------ server/src/tools/commands/__init__.py | 28 +- server/src/tools/commands/builtin.py | 6 +- server/src/tools/commands/checks.py | 2 +- server/src/tools/commands/plugins.py | 86 ----- server/src/tools/comments.py | 91 ----- server/src/tools/config.py | 429 ----------------------- server/src/tools/format.py | 480 -------------------------- server/src/tools/lexer.py | 92 ++--- server/src/tools/parser.py | 200 ++++++----- server/src/tools/plugins.py | 81 ----- server/src/tools/schema.py | 35 -- server/src/tools/syntax_tree.py | 18 +- server/src/tools/violations.py | 50 --- 16 files changed, 168 insertions(+), 1667 deletions(-) create mode 100644 server/src/test_tcl.py delete mode 100644 server/src/tools/__init__.py delete mode 100644 server/src/tools/checks.py delete mode 100644 server/src/tools/commands/plugins.py delete mode 100644 server/src/tools/comments.py delete mode 100644 server/src/tools/config.py delete mode 100644 server/src/tools/format.py delete mode 100644 server/src/tools/plugins.py delete mode 100644 server/src/tools/schema.py delete mode 100644 server/src/tools/violations.py diff --git a/server/src/test_tcl.py b/server/src/test_tcl.py new file mode 100644 index 0000000..89cb451 --- /dev/null +++ b/server/src/test_tcl.py @@ -0,0 +1,10 @@ +from tools.parser import Parser + + +def main(): + parser = Parser(True) + parser.parse("puts hello") + + +if __name__ == "__main__": + main() diff --git a/server/src/tools/__init__.py b/server/src/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/src/tools/checks.py b/server/src/tools/checks.py deleted file mode 100644 index cc36ae4..0000000 --- a/server/src/tools/checks.py +++ /dev/null @@ -1,227 +0,0 @@ -import re - -from src.tools.commands import get_commands -from src.tools.violations import Rule, Violation - -from src.tools.syntax_tree import ( - Visitor, - BracedExpression, - Expression, - BracedWord, - QuotedWord, - CommandSub, -) - - -class LineLengthChecker: - """Ensures lines aren't too long. - - Reports 'line-length' violations. - """ - - # ref: https://github.com/eslint/eslint/blob/b29a16b22f234f6134475efb6c7be5ac946556ee/lib/rules/max-len.js#L101 # noqa: E501 - # ^ ironic lint waiver... - URL_RE = re.compile(r"[^:/?#]:\/\/[^?#]") - - def check(self, input, _, config): - violations = [] - for i, line in enumerate(input.split("\n")): - if self.URL_RE.search(line) is not None: - # ignore URLs - continue - - lineno = i + 1 - if len(line) > config.style_line_length: - start = (lineno, 1) - end = (lineno, len(line) + 1) - violations.append( - Violation( - Rule.LINE_LENGTH, - f"line length is {len(line)}, maximum allowed is" - f" {config.style_line_length}", - start, - end, - ) - ) - - return violations - - -class TrailingWhitespaceChecker: - """Ensures lines don't include trailing whitespace. - - Reports 'trailing-whitespace' violations. - """ - - def check(self, input, _, config): - violations = [] - for i, line in enumerate(input.split("\n")): - lineno = i + 1 - - WHITESPACE = (" ", "\t") - if line.endswith(WHITESPACE): - start_col = len(line.rstrip("".join(WHITESPACE))) - start = (lineno, start_col + 1) - end = (lineno, len(line) + 1) - violations.append( - Violation( - Rule.TRAILING_WHITESPACE, - "line has trailing whitespace", - start, - end, - ) - ) - - return violations - - -class RedefinedBuiltinChecker(Visitor): - """Ensures names of built-in commands aren't reused by proc definitions. - - Reports 'redefined-builtin' violations. - """ - - def check(self, _, tree, config): - self._violations = [] - - plugins = [config.commands] if config.commands is not None else [] - commands = get_commands(plugins) - self._commands = commands.keys() - - tree.accept(self, recurse=True) - - return self._violations - - def visit_command(self, command): - if command.routine.contents != "proc": - return - - if len(command.args) == 0: - # This is a syntax error, but should already be caught as a command-args - # error by the parser's `proc` command handling. - return - - name = command.args[0].contents - - if name in self._commands: - self._violations.append( - Violation( - Rule.REDEFINED_BUILTIN, - f"redefinition of built-in command '{name}'", - command.pos, - command.args[1].end_pos, - ) - ) - - -class UnbracedExprChecker(Visitor): - def check(self, _, tree, __): - self._violations = [] - tree.accept(self, recurse=True) - return self._violations - - def visit_command(self, command): - if command.routine.contents != "expr": - return - - if len(command.args) == 0: - # This is a syntax error, but should already be caught as a command-args - # error by the parser's `expr` command handling. - return - - if len(command.args) == 1 and isinstance( - command.args[0], (BracedExpression, Expression) - ): - return - - # If we got here, tclint had trouble parsing the expression due to one of the - # two following cases. - - for child in command.args: - if child.contents is None: - self._violations.append( - Violation( - Rule.UNBRACED_EXPR, - "expression with substitutions should be enclosed by braces", - command.args[0].pos, - command.args[-1].end_pos, - ) - ) - return - - for child in command.args: - if isinstance(child, (BracedWord, QuotedWord)): - self._violations.append( - Violation( - Rule.UNBRACED_EXPR, - "expression containing braced or quoted words should be" - " enclosed by braces", - command.args[0].pos, - command.args[-1].end_pos, - ) - ) - return - - # If we reach here, there's probably a bug in expr parsing logic. - assert False, ( - "Children of expr node were different than expected, please file a bug" - " report" - ) - - -class RedundantExprChecker(Visitor): - def check(self, _, tree, __): - self._violations = [] - tree.accept(self, recurse=True) - return self._violations - - def _check_operand(self, operand): - if not isinstance(operand, CommandSub) or len(operand.children) != 1: - return - - command = operand.children[0] - if command.routine.contents == "expr": - self._violations.append( - Violation( - Rule.REDUNDANT_EXPR, - "unnecessary command substitution within expression", - operand.pos, - operand.end_pos, - ) - ) - - def visit_braced_expression(self, expression): - if len(expression.children) == 1: - self._check_operand(expression.children[0]) - - def visit_expression(self, expression): - if len(expression.children) == 1: - self._check_operand(expression.children[0]) - - def visit_unary_op(self, expr): - self._check_operand(expr.children[1]) - - def visit_binary_op(self, expr): - self._check_operand(expr.children[0]) - self._check_operand(expr.children[2]) - - def visit_ternary_op(self, expr): - self._check_operand(expr.children[0]) - self._check_operand(expr.children[2]) - self._check_operand(expr.children[4]) - - def visit_function(self, function): - for arg in function.children[1:]: - self._check_operand(arg) - - -def get_checkers(): - checkers = ( - RedefinedBuiltinChecker(), - UnbracedExprChecker(), - RedundantExprChecker(), - LineLengthChecker(), - TrailingWhitespaceChecker(), - ) - - return checkers diff --git a/server/src/tools/commands/__init__.py b/server/src/tools/commands/__init__.py index f69a211..f498d2c 100644 --- a/server/src/tools/commands/__init__.py +++ b/server/src/tools/commands/__init__.py @@ -1,37 +1,17 @@ import pathlib from typing import List, Dict, Union -from src.tools.commands import builtin as _builtin -from src.tools.commands.plugins import PluginManager +from tools.commands import builtin as _builtin + # import to expose in package -from src.tools.commands.checks import CommandArgError +from tools.commands.checks import CommandArgError -__all__ = ["CommandArgError", "validate_command_plugins", "get_commands"] - - -def validate_command_plugins(plugins: List[str]) -> List[str]: - valid_plugins = [] - for plugin in set(plugins): - if PluginManager.load(plugin) is not None: - valid_plugins.append(plugin) - - return valid_plugins +__all__ = ["CommandArgError", "get_commands"] def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict: commands = {} commands.update(_builtin.commands) - for plugin in plugins: - if isinstance(plugin, str): - plugin_commands = PluginManager.load(plugin) - elif isinstance(plugin, pathlib.Path): - plugin_commands = PluginManager.load_from_spec(plugin) - else: - raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}") - - if plugin_commands is not None: - commands.update(plugin_commands) - return commands diff --git a/server/src/tools/commands/builtin.py b/server/src/tools/commands/builtin.py index b0aec14..ce12405 100644 --- a/server/src/tools/commands/builtin.py +++ b/server/src/tools/commands/builtin.py @@ -32,13 +32,13 @@ these would be helpful for your use case, please file an issue. - https://www.tcl.tk/man/tcl/TclCmd/mathop.html """ -from src.tools.commands.checks import ( +from tools.commands.checks import ( CommandArgError, check_count, eval, ) -from src.tools.commands.schema import commands_schema -from src.tools.syntax_tree import BareWord +from tools.commands.schema import commands_schema +from tools.syntax_tree import BareWord def _check_code(arg): diff --git a/server/src/tools/commands/checks.py b/server/src/tools/commands/checks.py index a325f59..558bb3d 100644 --- a/server/src/tools/commands/checks.py +++ b/server/src/tools/commands/checks.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import List, Optional, Union -from src.tools.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node +from tools.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node class CommandArgError(Exception): diff --git a/server/src/tools/commands/plugins.py b/server/src/tools/commands/plugins.py deleted file mode 100644 index 9c41c5b..0000000 --- a/server/src/tools/commands/plugins.py +++ /dev/null @@ -1,86 +0,0 @@ -from importlib.metadata import entry_points -import json -import pathlib -from typing import Dict, Optional -from types import ModuleType - -import voluptuous - -from src.tools.commands.schema import schema as command_schema - - -class _PluginManager: - def __init__(self): - self._loaded = {} - self._installed = {} - self._loaded_specs = {} - for plugin in entry_points(group="tclint.plugins"): - if plugin.name in self._installed: - print(f"Warning: found duplicate definitions for plugin {plugin.name}") - self._installed[plugin.name] = plugin - - def load(self, name: str) -> Optional[Dict]: - if name in self._loaded: - return self._loaded[name] - - mod = self._load(name) - self._loaded[name] = mod - return mod - - def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: - if path in self._loaded_specs: - return self._loaded_specs[path] - - spec = self._load_from_spec(path) - self._loaded_specs[path] = spec - return spec - - def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: - try: - with open(path.expanduser(), "r") as f: - spec = json.load(f) - except (FileNotFoundError, RuntimeError): - print(f"Warning: command spec {path} not found, skipping...") - return None - - try: - # Apply defaults and validate the spec. - spec = command_schema(spec) - except voluptuous.Invalid as e: - print(f"Warning: invalid command spec {path}: {e}") - return None - - return spec["commands"] - - def get_mod(self, name: str) -> Optional[ModuleType]: - if name not in self._installed: - print(f"Warning: plugin {name} is not installed") - return None - - plugin = self._installed[name] - - try: - module = plugin.load() - except Exception as e: - print(f"Warning: error loading plugin {name}: {e}") - return None - - return module - - def _load(self, name: str): - module = self.get_mod(name) - if module is None: - print(f"Skipping requested plugin {name}") - return None - - if not hasattr(module, "commands"): - print(f"Warning: skipping plugin {name} since it does not define commands") - return None - - return getattr(module, "commands") - - -# TODO: we'll probably want to construct this in the tclint entry point and pass -# it around rather than using a singleton instance, but this made for an easier -# refactor. -PluginManager = _PluginManager() diff --git a/server/src/tools/comments.py b/server/src/tools/comments.py deleted file mode 100644 index 293a997..0000000 --- a/server/src/tools/comments.py +++ /dev/null @@ -1,91 +0,0 @@ -from collections import defaultdict - -from src.tools.syntax_tree import Visitor -from src.tools.violations import ALL_RULES, Rule - - -class CommentVisitor(Visitor): - """Scans the tree for lint waiver comments.""" - - def __init__(self): - # line -> [rule] - self.ignore_lines = defaultdict(set) - - self._disable_regions = { - # rule -> line - } - - def run(self, tree, path): - self._path = path - tree.accept(self, recurse=True) - - # resolve remaining disabled regions - last_line = tree.end_pos[0] - for rule, start_line in self._disable_regions.items(): - for line in range(start_line, last_line + 1): - self.ignore_lines[line].add(rule) - - return self.ignore_lines - - def visit_comment(self, comment): - contents = comment.value.strip() - - if not contents.startswith("tclint-"): - return - - split = contents.split(" ", 1) - - command = split[0] - - rule_strs = [] - if len(split) > 1: - rest = split[-1] - rule_strs = rest.split("--", 1)[0] - rule_strs = rule_strs.replace(" ", "") - rule_strs = rule_strs.split(",") - - rules = [] - if not rule_strs: - # default if no rules specified is all violation types - rules = ALL_RULES - else: - for rule in rule_strs: - try: - rules.append(Rule(rule)) - except ValueError: - self._warning( - f"unknown rule '{rule}' provided to '{command}'", comment.pos - ) - - if command == "tclint-disable": - for rule in rules: - # if in dictionary, already disabled - this has no effect - if rule not in self._disable_regions: - self._disable_regions[rule] = comment.line - elif command == "tclint-disable-line": - line = comment.line - self.ignore_lines[line].update(rules) - elif command == "tclint-disable-next-line": - line = comment.line + 1 - self.ignore_lines[line].update(rules) - elif command == "tclint-enable": - for rule in rules: - if rule in self._disable_regions: - disable_start_line = self._disable_regions[rule] - disable_end_line = comment.line - - for line in range(disable_start_line, disable_end_line + 1): - self.ignore_lines[line].add(rule) - - del self._disable_regions[rule] - else: - self._warning( - f"comment starts with '{command}', which looks like a tclint keyword." - " Is this a typo?", - comment.pos, - ) - - def _warning(self, message, pos): - # TODO: formal warning mechanism - prefix = self._path if self._path is not None else "(stdin)" - print(f"Warning: {prefix}:{pos[0]}:{pos[1]}: {message}") diff --git a/server/src/tools/config.py b/server/src/tools/config.py deleted file mode 100644 index 87cf234..0000000 --- a/server/src/tools/config.py +++ /dev/null @@ -1,429 +0,0 @@ -import argparse -import pathlib -from typing import Union, List -from typing import Optional as OptionalType -import dataclasses -import sys - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - -from voluptuous import Schema, Optional, And, Coerce, Invalid, Range - -from src.tools.violations import Rule - - -@dataclasses.dataclass -class Config: - """This dataclass defines the supported Config fields and their default - values. It provides an external interface for accessing config values. - - The type annotations defined here are fairly loose - more specific type - validation (and normalization) is defined by `validators` below. - """ - - exclude: List[str] = dataclasses.field(default_factory=list) - ignore: List[Rule] = dataclasses.field(default_factory=list) - commands: OptionalType[pathlib.Path] = dataclasses.field(default=None) - extensions: List[str] = dataclasses.field( - default_factory=lambda: ["tcl", "sdc", "xdc", "upf"] - ) - style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None) - style_line_length: int = dataclasses.field(default=100) - style_max_blank_lines: int = dataclasses.field(default=2) - style_indent_namespace_eval: bool = dataclasses.field(default=True) - style_spaces_in_braces: bool = dataclasses.field(default=False) - - def apply_cli_args(self, args): - args_dict = vars(args) - for field in dataclasses.fields(self): - if field.name in args_dict and args_dict[field.name] is not None: - setattr(self, field.name, args_dict[field.name]) - - # Special arguments that aren't handled automatically - if "extend_exclude" in args_dict and args_dict["extend_exclude"] is not None: - self.exclude.extend(args_dict["extend_exclude"]) - - if "extend_ignore" in args_dict and args_dict["extend_ignore"] is not None: - self.ignore.extend(args_dict["extend_ignore"]) - - def get_indent(self) -> str: - """Get indent setting as string. - - This helper does two things. One, it's a helpful utility to factor out the logic - required for calculating the indent. Two, it lets us ergonomically store if the - indentation is not set in style_indent, which the LSP relies on. - """ - if self.style_indent is None: - # Default indent - return " " * 4 - elif self.style_indent == "tab": - return "\t" - elif isinstance(self.style_indent, int): - return " " * self.style_indent - - # Should be unreachable, validated on ingestion of config - raise ValueError( - f"unexpected value for config.style_indent: {self.style_indent}" - ) - - -# Validators using `voluptuous` library that check and normalize config inputs. -# Used for checking both config files as well as config-related CLI args. - -# Using these for CLI args adds a constraint that all non-boolean validators -# need to be able to normalize a value from a string. This means one could put -# e.g. a string representation of a list into a .toml config file, but we shouldn't -# document this, since it won't be considered stable behavior. - - -def _str2list(s): - """Handles string-to-list normalization.""" - if isinstance(s, str): - if s == "": - return [] - return [v.strip() for v in s.split(",")] - return s - - -_VALIDATORS = { - # note: it's ok if paths don't exist - allows for generic - # configurations with directories like .git/ excluded - "exclude": _str2list, - "ignore": And( - _str2list, - [ - Coerce(Rule, msg="invalid rule ID"), - ], - ), - "commands": Coerce(pathlib.Path), - "extensions": _str2list, - "style_indent": Coerce( - lambda v: v if v == "tab" else int(v), msg="expected integer or 'tab'" - ), - "style_line_length": Coerce(int), - "style_max_blank_lines": And( - Coerce(int), - # we could technically support i >= 0, but I think 0 would be a weird - # setting and this lets us ignore pluralizing the violation message :) - Range(min=1), - ), - "style_indent_namespace_eval": bool, - "style_spaces_in_braces": bool, -} - - -def _validate_config(config): - """Validates dictionary read from TOML config file. Individual value validators - are implemented in the global dict, this defines the actual structure of the - schema.""" - - base_config = { - Optional("ignore"): _VALIDATORS["ignore"], - Optional("commands"): _VALIDATORS["commands"], - Optional("style"): { - Optional("indent"): _VALIDATORS["style_indent"], - Optional("line-length"): _VALIDATORS["style_line_length"], - Optional("max-blank-lines"): _VALIDATORS["style_max_blank_lines"], - Optional("indent-namespace-eval"): _VALIDATORS[ - "style_indent_namespace_eval" - ], - Optional("spaces-in-braces"): _VALIDATORS["style_spaces_in_braces"], - }, - } - - schema = Schema( - { - # exclude and extensions can only be used in global context - Optional("exclude"): _VALIDATORS["exclude"], - Optional("extensions"): _VALIDATORS["extensions"], - **base_config, - Optional("fileset"): Schema( - [{"paths": [Coerce(pathlib.Path)], **base_config}], required=True - ), - } - ) - - try: - return schema(config) - except Invalid as e: - if not e.path: - raise ConfigError(e.error_message) - - # Stringify error path to my own taste. - path = [] - for item in e.path: - if isinstance(item, int): - # Brackets around indices - if len(path) > 0: - path[-1] += f"[{item}]" - else: - path.append(f"[{item}]") - else: - path.append(str(item)) - - raise ConfigError(f"{e.error_message} ({'.'.join(path)})") - - -def _validator(key): - def func(s): - try: - return Schema(_VALIDATORS[key])(s) - except Invalid as e: - raise argparse.ArgumentTypeError(str(e)) - - return func - - -def _add_bool(group, parser, dest, yes_flag, no_flag): - mutex_group = group.add_mutually_exclusive_group(required=False) - mutex_group.add_argument(yes_flag, dest=dest, action="store_true") - mutex_group.add_argument(no_flag, dest=dest, action="store_false") - parser.set_defaults(**{dest: None}) - - -def setup_common_config_cli_args(config_group): - config_group.add_argument( - "--exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' - ) - config_group.add_argument( - "--extend-exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' - ) - config_group.add_argument( - "--extensions", type=_validator("extensions"), metavar='"tcl, xdc, ..."' - ) - config_group.add_argument( - "--commands", type=_validator("commands"), metavar="" - ) - - -def setup_config_cli_args(parser): - """This method defines config-related CLI arguments. - - The destvars of these switches should match the fields of Config. - """ - config_group = parser.add_argument_group("configuration arguments") - - config_group.add_argument( - "--ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' - ) - config_group.add_argument( - "--extend-ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' - ) - setup_common_config_cli_args(config_group) - config_group.add_argument( - "--style-line-length", - type=_validator("style_line_length"), - metavar="", - ) - - -def setup_tclfmt_config_cli_args(parser): - """This method defines the subset of config-related CLI arguments used by tclfmt. - - The destvars of these switches should match the fields of Config. - """ - config_group = parser.add_argument_group("configuration arguments") - - setup_common_config_cli_args(config_group) - - config_group.add_argument( - "--indent", - type=_validator("style_indent"), - metavar="", - dest="style_indent", - ) - config_group.add_argument( - "--max-blank-lines", - type=_validator("style_max_blank_lines"), - metavar="", - dest="style_max_blank_lines", - ) - _add_bool( - config_group, - parser, - "style_indent_namespace_eval", - "--indent-namespace-eval", - "--no-indent-namespace-eval", - ) - _add_bool( - config_group, - parser, - "style_spaces_in_braces", - "--spaces-in-braces", - "--no-spaces-in-braces", - ) - - -def _flatten(d, prefix=None): - """Flattens TOML config dictionary structure to match the flat set of fields - expected by Config dataclass.""" - if prefix is None: - prefix = [] - - flat = {} - for k, v in d.items(): - if isinstance(v, dict): - flat.update(_flatten(v, prefix=prefix + [k])) - else: - flat["_".join(prefix + [k]).replace("-", "_")] = v - - return flat - - -class RunConfig: - """Class that holds information about both global and fileset configs. User - code can get a Config object that applies to a particular file by calling - get_from_path() and supplying that file's path.""" - - def __init__(self, global_config=None, fileset_configs=None): - if global_config is not None: - self._global_config = global_config - else: - self._global_config = Config() - - self._fileset_configs = [ - # ([pathlib.Path...], Config]) - ] - if fileset_configs is not None: - self._fileset_configs = fileset_configs - - @property - def exclude(self): - return self._global_config.exclude - - @property - def extensions(self): - return self._global_config.extensions - - @classmethod - def from_dict(cls, config_dict: dict, root: pathlib.Path): - config_dict = _validate_config(config_dict) - try: - fileset_config_dicts = config_dict.pop("fileset") - except KeyError: - fileset_config_dicts = [] - - config_dict = _flatten(config_dict) - global_config = Config(**config_dict) - - fileset_configs = [] - for fileset_config in fileset_config_dicts: - paths = [] - for path in fileset_config.pop("paths"): - if not path.is_absolute(): - path = root / path - paths.append(path.resolve()) - - fileset_config = _flatten(fileset_config) - - # pull in default values from global config - full_fileset_config = config_dict.copy() - full_fileset_config.update(fileset_config) - - fileset_configs.append((paths, Config(**full_fileset_config))) - - return cls(global_config, fileset_configs) - - @classmethod - def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path): - path = pathlib.Path(path) - - if not path.exists(): - raise FileNotFoundError - - with open(path, "rb") as f: - try: - data = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - raise ConfigError(f"{path}: {e}") - - try: - return cls.from_dict(data, root) - except ConfigError as e: - raise ConfigError(f"{path}: {e}") - - @classmethod - def from_pyproject(cls, directory=None): - if directory is None: - directory = pathlib.Path(".") - else: - directory = pathlib.Path(directory) - - path = directory / "pyproject.toml" - - if not path.exists(): - raise FileNotFoundError - - with open(path, "rb") as f: - data = tomllib.load(f) - - tclint_config = data.get("tool", {})["tclint"] - - try: - return cls.from_dict(tclint_config, directory) - except ConfigError as e: - raise ConfigError(f"pyproject.toml: {e}") - - def get_for_path(self, path) -> Config: - if path is None: - return self._global_config - - path = path.resolve() - for fileset_paths, config in self._fileset_configs: - for fileset_path in fileset_paths: - if path.is_relative_to(fileset_path): - return config - - return self._global_config - - def apply_cli_args(self, args): - self._global_config.apply_cli_args(args) - for _, fileset_config in self._fileset_configs: - fileset_config.apply_cli_args(args) - - -class ConfigError(Exception): - pass - - -DEFAULT_CONFIGS = ("tclint.toml", ".tclint") - - -def get_config( - config_path: OptionalType[pathlib.Path], root: pathlib.Path -) -> OptionalType[RunConfig]: - """Loads a config file. - - If `config_path` is supplied, attempts to read config file from this path. If the - path can't be found, raises a ConfigError. - - Otherwise, attempts to read config from `root`/{tclint.toml, .tclint, - pyproject.toml} (in that order). If none of these files can be found, returns None. - - `root` is also used to resolve some relative paths in the config file. - """ - # user-supplied - if config_path is not None: - try: - return RunConfig.from_path(config_path, root) - except FileNotFoundError: - raise ConfigError(f"path {config_path} doesn't exist") - - for path in DEFAULT_CONFIGS: - try: - return RunConfig.from_path(root / path, root) - except FileNotFoundError: - pass - - try: - return RunConfig.from_pyproject(directory=root) - except ConfigError as e: - raise e - except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError): - # just skip if file doesn't exist, contains TOML errors, or tclint key not found - pass - - return None diff --git a/server/src/tools/format.py b/server/src/tools/format.py deleted file mode 100644 index 66441e5..0000000 --- a/server/src/tools/format.py +++ /dev/null @@ -1,480 +0,0 @@ -import dataclasses -import itertools -import textwrap -from typing import List, Tuple, Union -import sys - -from src.tools.syntax_tree import ( - Node, - Script, - Command, - Comment, - CommandSub, - BareWord, - QuotedWord, - BracedWord, - CompoundBareWord, - VarSub, - ArgExpansion, - Expression, - BracedExpression, - ParenExpression, - UnaryOp, - BinaryOp, - TernaryOp, - Function, -) -from src.tools.parser import Parser -from src.tools.syntax_tree import List as ListNode - - -@dataclasses.dataclass -class LiteralBlock: - block: List[str] - pos: Tuple[int, int] - end_pos: Tuple[int, int] - - -@dataclasses.dataclass -class FormatterOpts: - indent: str - spaces_in_braces: bool - max_blank_lines: int - indent_namespace_eval: bool - - -class Formatter: - def __init__(self, opts: FormatterOpts): - self.opts = opts - - def _indent(self, lines: List[str], indent: str) -> List[str]: - indented = [] - for line in lines: - if line == "": - indented.append("") - else: - indented.append(indent + line) - - return indented - - def _brace(self, lines: List[str]) -> List[str]: - spaces_in_braces = " " if self.opts.spaces_in_braces else "" - if lines == [""]: - return ["{" + spaces_in_braces + "}"] - - braced_lines = lines[:] - braced_lines[0] = "{" + spaces_in_braces + lines[0] - braced_lines[-1] += spaces_in_braces + "}" - return braced_lines - - def format(self, *nodes: Union[Node, LiteralBlock]) -> List[str]: - formatted = [] - for node in nodes: - if isinstance(node, Script): - formatted += self.format_script(node) - elif isinstance(node, Command): - formatted += self.format_command(node) - elif isinstance(node, Comment): - formatted += self.format_comment(node) - elif isinstance(node, CommandSub): - formatted += self.format_command_sub(node) - elif isinstance(node, BareWord): - formatted += self.format_bare_word(node) - elif isinstance(node, QuotedWord): - formatted += self.format_quoted_word(node) - elif isinstance(node, BracedWord): - formatted += self.format_braced_word(node) - elif isinstance(node, CompoundBareWord): - formatted += self.format_compound_bare_word(node) - elif isinstance(node, VarSub): - formatted += self.format_var_sub(node) - elif isinstance(node, ArgExpansion): - formatted += self.format_arg_expansion(node) - elif isinstance(node, ListNode): - formatted += self.format_list(node) - elif isinstance(node, Expression): - formatted += self.format_expression(node) - elif isinstance(node, BracedExpression): - formatted += self.format_braced_expression(node) - elif isinstance(node, ParenExpression): - formatted += self.format_paren_expression(node) - elif isinstance(node, UnaryOp): - formatted += self.format_unary_op(node) - elif isinstance(node, BinaryOp): - formatted += self.format_binary_op(node) - elif isinstance(node, TernaryOp): - formatted += self.format_ternary_op(node) - elif isinstance(node, Function): - formatted += self.format_function(node) - elif isinstance(node, LiteralBlock): - formatted += node.block - else: - assert False, f"unrecognized node: {type(node)}" - - return formatted - - def format_top(self, script: str, parser: Parser) -> str: - tree = parser.parse(script) - self.script = script.split("\n") - return "\n".join(self.format_script_contents(tree)) + "\n" - - def format_partial(self, script: str, parser: Parser) -> str: - """Formats a partial Tcl script. - - This function formats a partial script according to the gofmt partial formatting - rules, "[preserving] leading indentation as well as leading and trailing spaces" - (ref: https://pkg.go.dev/cmd/gofmt#pkg-overview). Unlike Go, we have no way of - detecting if a given script is a program fragment, hence the distinct method - from `format_top` . - """ - leading = "".join(itertools.takewhile(str.isspace, script)) - try: - leading, indent = leading.rsplit("\n", 1) - leading += "\n" - except ValueError: - leading, indent = "", leading - trailing = "".join(itertools.takewhile(str.isspace, reversed(script)))[::-1] - - script = script.strip() - tree = parser.parse(script) - self.script = script.split("\n") - - formatted = "\n".join(self.format_script_contents(tree)) - - return leading + textwrap.indent(formatted, indent) + trailing - - def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]: - to_format = [] - skip_formatting_start = None - for child in script.children: - if skip_formatting_start is None: - to_format.append(child) - - if isinstance(child, Comment): - if child.value.strip() == "tclfmt-disable": - if skip_formatting_start is not None: - print( - "Warning: encountered 'tclint-disable' while formatting is" - " already disabled, ignoring...", - file=sys.stderr, - ) - else: - skip_formatting_start = child.pos[0] - elif child.value.strip() == "tclfmt-enable": - if skip_formatting_start is None: - print( - "Warning: encountered 'tclint-enable' while formatting is" - " already disabled, ignoring...", - file=sys.stderr, - ) - else: - skip_formatting_end = child.pos[0] - block = self.script[skip_formatting_start:skip_formatting_end] - to_format.append( - LiteralBlock( - block, - pos=(skip_formatting_start + 1, 1), - end_pos=(skip_formatting_end, 1), - ) - ) - skip_formatting_start = None - - if skip_formatting_start is not None: - print("Warning: missing 'tclint-enable'", file=sys.stderr) - to_format.append( - LiteralBlock( - self.script[skip_formatting_start:], - pos=(skip_formatting_start + 1, 1), - end_pos=script.end_pos, - ) - ) - - formatted = [""] - last_line = None - for child in to_format: - if last_line is not None: - if last_line == child.pos[0]: - if isinstance(child, Comment): - formatted[-1] += " ;" - else: - formatted[-1] += "; " - else: - newlines = child.pos[0] - last_line - newlines = min(newlines, self.opts.max_blank_lines + 1) - formatted.extend([""] * newlines) - last_line = child.end_pos[0] - - lines = self.format(child) - formatted[-1] += lines[0] - formatted.extend(lines[1:]) - - return formatted - - def format_script(self, script: Script, should_indent=True) -> List[str]: - lines = self.format_script_contents(script) - if script.pos[0] == script.end_pos[0]: - return self._brace(lines) - - # Usually, we enforce that multi-line scripts start on a new line after the open - # brace. However, if a comment was originally on the same line as the open brace - # we preserve it, since it's probably meant to be associated with this line - # (e.g. a tclint-disable-line). - open_brace = "{" - if ( - len(script.children) > 0 - and isinstance(script.children[0], Comment) - and script.pos[0] == script.children[0].pos[0] - ): - open_brace += " " + lines[0] - lines = lines[1:] - - if should_indent: - return [open_brace] + self._indent(lines, self.opts.indent) + ["}"] - else: - return [open_brace] + lines + ["}"] - - def format_command(self, command: Command) -> List[str]: - is_namespace_eval = ( - command.routine.contents == "namespace" - and len(command.args) > 0 - and command.args[0].contents == "eval" - ) - should_indent = not is_namespace_eval or self.opts.indent_namespace_eval - - hanging_indent = False - formatted = self.format(command.routine) - last_line = command.routine.end_pos[0] - for child in command.args: - if isinstance(child, Script): - child_lines = self.format_script(child, should_indent=should_indent) - else: - child_lines = self.format(child) - - if last_line == child.pos[0]: - formatted[-1] += " " - formatted[-1] += child_lines[0] - else: - formatted[-1] += " \\" - formatted.append(self.opts.indent + child_lines[0]) - hanging_indent = True - - if hanging_indent: - formatted.extend(self._indent(child_lines[1:], self.opts.indent)) - else: - formatted.extend(child_lines[1:]) - - last_line = child.end_pos[0] - - return formatted - - def format_comment(self, comment: Comment) -> List[str]: - return [f"#{comment.value}"] - - def format_command_sub(self, command_sub): - if len(command_sub.children) == 0: - return ["[]"] - - formatted = [] - contents = self.format_script_contents(command_sub) - if len(command_sub.children) > 1 and len(contents) > 1: - formatted.append("[") - formatted.extend(self._indent(contents, self.opts.indent)) - formatted.append("]") - else: - formatted.append("[" + contents[0]) - formatted.extend(contents[1:]) - formatted[-1] += "]" - - return formatted - - def format_bare_word(self, word) -> List[str]: - # Property enforced by parser - assert word.contents is not None - return [word.contents] - - def format_quoted_word(self, word) -> List[str]: - if word.contents is not None: - return [f'"{word.contents}"'] - - formatted = "" - for child in word.children: - formatted += "\n".join(self.format(child)) - - return [f'"{formatted}"'] - - def format_braced_word(self, word) -> List[str]: - assert word.contents is not None - return [f"{{{word.contents}}}"] - - def format_compound_bare_word(self, word) -> List[str]: - formatted = [""] - for child in word.children: - child_lines = self.format(child) - formatted[-1] += child_lines[0] - formatted.extend(child_lines[1:]) - - return formatted - - def format_var_sub(self, varsub) -> List[str]: - # We might be able to make the formatter infer whether braces are required, and - # remove them from the syntax tree. For now it's easier to just mimic the - # original format. - if varsub.braced: - formatted = [f"${{{varsub.value}}}"] - else: - formatted = [f"${varsub.value}"] - - if varsub.children: - # We just concatenate everything as is, since changes in whitespace are - # semantically meaningful in this context. Any newlines are captured by - # BareWords. - formatted[-1] += "(" - for child in varsub.children: - child_lines = self.format(child) - formatted[-1] += child_lines[0] - formatted.extend(child_lines[1:]) - formatted[-1] += ")" - - return formatted - - def format_arg_expansion(self, arg_expansion) -> List[str]: - lines = self.format(arg_expansion.list) - lines[0] = "{*}" + lines[0] - - return lines - - def format_list(self, list_node) -> List[str]: - # Similar to Script, but the contents are a bit more straightforward. - contents = [""] - last_line = None - for child in list_node.children: - if last_line is not None: - if last_line == child.pos[0]: - contents[-1] += " " - else: - newlines = child.pos[0] - last_line - newlines = min(newlines, 3) - contents.extend([""] * newlines) - - lines = self.format(child) - contents[-1] += lines[0] - contents.extend(lines[1:]) - - last_line = child.end_pos[0] - - if list_node.pos[0] == list_node.end_pos[0]: - return self._brace(contents) - - return ["{"] + self._indent(contents, self.opts.indent) + ["}"] - - def format_expression(self, expr) -> List[str]: - formatted = [""] - for child in expr.children: - lines = self.format(child) - formatted[-1] += lines[0] - for line in lines[1:]: - formatted[-1] += " \\" - formatted += self._indent([line], self.opts.indent) - - # Trick: we know there are quotes around the expression if the start of the - # expression is a different column than its first child. - quoted = expr.pos[1] != expr.children[0].pos[1] - if quoted: - formatted[0] = '"' + formatted[0] - formatted[-1] += '"' - - return formatted - - def format_braced_expression(self, expr) -> List[str]: - formatted = [""] - for child in expr.children: - lines = self.format(child) - formatted[-1] += lines[0] - formatted.extend(lines[1:]) - - if expr.pos[0] == expr.end_pos[0]: - return self._brace(formatted) - - return ["{"] + self._indent(formatted, self.opts.indent) + ["}"] - - def format_paren_expression(self, expr) -> List[str]: - body = expr.body - - formatted = ["("] - lines = self.format(body) - if expr.pos[0] != body.pos[0]: - formatted.extend(lines) - else: - formatted[-1] += lines[0] - formatted.extend(lines[1:]) - - formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) - - if expr.end_pos[0] != body.end_pos[0]: - formatted.append(")") - else: - formatted[-1] += ")" - - return formatted - - def format_unary_op(self, expr): - op = self.format(expr.operator) - assert len(op) == 1 - - lines = self.format(expr.operand) - lines[0] = op[0] + lines[0] - return lines - - def _format_op(self, expr) -> List[str]: - nodes = expr.children - formatted = self.format(nodes[0]) - - last = nodes[0] - for next in nodes[1:]: - lines = self.format(next) - if last.end_pos[0] != next.pos[0]: - formatted.extend(lines) - else: - formatted[-1] += " " - formatted[-1] += lines[0] - formatted.extend(lines[1:]) - last = next - - return formatted - - def format_binary_op(self, expr) -> List[str]: - return self._format_op(expr) - - def format_ternary_op(self, expr) -> List[str]: - return self._format_op(expr) - - def format_function(self, function): - name = self.format(function.name) - assert len(name) == 1 - name = name[0] - - formatted = [f"{name}("] - - last = function.name - for i, child in enumerate(function.args): - if i > 0: - formatted[-1] += "," - lines = self.format(child) - if last.end_pos[0] != child.pos[0]: - formatted.extend(lines) - else: - if i > 0: - formatted[-1] += " " - formatted[-1] += lines[0] - formatted.extend(lines[1:]) - last = child - - # indent any continuation lines, but we leave the closing paren dedented - formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) - - if last.end_pos[0] != function.end_pos[0]: - formatted.append(")") - else: - formatted[-1] += ")" - - return formatted diff --git a/server/src/tools/lexer.py b/server/src/tools/lexer.py index dc18312..dcea50e 100644 --- a/server/src/tools/lexer.py +++ b/server/src/tools/lexer.py @@ -1,27 +1,32 @@ +from enum import Enum import ply.lex as lex from typing import Tuple -TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" -TOK_BACKSLASH_SUB = "BACKSLASH_SUB" -TOK_NEWLINE = "NEWLINE" -TOK_SEMI = "SEMI" -TOK_WS = "WS" -TOK_QUOTE = "QUOTE" -TOK_ARG_EXPANSION = "ARG_EXPANSION" -TOK_LBRACE = "LBRACE" -TOK_RBRACE = "RBRACE" -TOK_STAR = "STAR" -TOK_LBRACKET = "LBRACKET" -TOK_RBRACKET = "RBRACKET" -TOK_DOLLAR = "DOLLAR" -TOK_LPAREN = "LPAREN" -TOK_RPAREN = "RPAREN" -TOK_HASH = "HASH" -TOK_ALPHA_CHARS = "ALPHA_CHARS" -TOK_NUM_CHARS = "NUM_CHARS" -TOK_NAMESPACE_SEP = "NAMESPACE_SEP" -TOK_CHAR = "CHAR" -TOK_CONTENTS = "CONTENTS" + +class Tok(str, Enum): + TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" + TOK_BACKSLASH_SUB = "BACKSLASH_SUB" + TOK_NEWLINE = "NEWLINE" + TOK_SEMI = "SEMI" + TOK_WS = "WS" + TOK_QUOTE = "QUOTE" + TOK_ARG_EXPANSION = "ARG_EXPANSION" + TOK_LBRACE = "LBRACE" + TOK_RBRACE = "RBRACE" + TOK_STAR = "STAR" + TOK_LBRACKET = "LBRACKET" + TOK_RBRACKET = "RBRACKET" + TOK_DOLLAR = "DOLLAR" + TOK_LPAREN = "LPAREN" + TOK_RPAREN = "RPAREN" + TOK_HASH = "HASH" + TOK_ALPHA_CHARS = "ALPHA_CHARS" + TOK_NUM_CHARS = "NUM_CHARS" + TOK_NAMESPACE_SEP = "NAMESPACE_SEP" + TOK_CHAR = "CHAR" + TOK_CONTENTS = "CONTENTS" + + TOK_EOF = None STATE_BRACEDWORD = "bracedword" @@ -35,30 +40,7 @@ class TclSyntaxError(Exception): class _LexTable: - tokens = ( - TOK_BACKSLASH_NEWLINE, - TOK_BACKSLASH_SUB, - TOK_NEWLINE, - TOK_SEMI, - TOK_WS, - TOK_QUOTE, - TOK_ARG_EXPANSION, - TOK_LBRACE, - TOK_RBRACE, - TOK_STAR, - TOK_LBRACKET, - TOK_RBRACKET, - TOK_DOLLAR, - TOK_LPAREN, - TOK_RPAREN, - TOK_HASH, - TOK_ALPHA_CHARS, - TOK_NUM_CHARS, - TOK_NAMESPACE_SEP, - TOK_CHAR, - TOK_CONTENTS, - ) - + tokens = tuple(t.value for t in Tok) # This defines a conditional lexing state for parsing braced words. This is a # performance optimization; since there are few special characters in this context, # we can use a smaller set of tokens to parse them faster. This has a large impact @@ -241,3 +223,23 @@ class Lexer: def assert_(self, *tokens): assert self.current.type in tokens self.next() + + +def main(): + code = 'puts "Hello, World!"\nset x 42\n' + lexer = Lexer() + lexer.input(code) + + print("Lexing input:\n", code) + print("\nTokens:\n" + "-" * 30) + + while lexer.type() is not None: + tok_type = lexer.type() + tok_value = lexer.value() + tok_pos = lexer.pos() + print(f"Type: {tok_type:20} | Value: {repr(tok_value):20} | Pos: {tok_pos}") + lexer.next() + + +if __name__ == "__main__": + main() diff --git a/server/src/tools/parser.py b/server/src/tools/parser.py index 5819a75..27145d8 100644 --- a/server/src/tools/parser.py +++ b/server/src/tools/parser.py @@ -1,64 +1,40 @@ import string import re -from src.tools.lexer import ( - Lexer, - TclSyntaxError, - STATE_BRACEDWORD, - TOK_BACKSLASH_NEWLINE, - TOK_NEWLINE, - TOK_SEMI, - TOK_WS, - TOK_QUOTE, - TOK_ARG_EXPANSION, - TOK_LBRACE, - TOK_RBRACE, - TOK_LBRACKET, - TOK_RBRACKET, - TOK_DOLLAR, - TOK_LPAREN, - TOK_RPAREN, - TOK_HASH, - TOK_ALPHA_CHARS, - TOK_NUM_CHARS, - TOK_NAMESPACE_SEP, - TOK_EOF, -) -from src.tools.syntax_tree import ( - Script, - Comment, +from tools.commands.checks import CommandArgError, check_command +from tools.lexer import Lexer, TclSyntaxError, Tok, TOK_EOF, STATE_BRACEDWORD +from tools.syntax_tree import ( + ArgExpansion, + BareWord, + BinaryOp, + BracedExpression, + BracedWord, Command, CommandSub, - ArgExpansion, - VarSub, - BareWord, - BracedWord, - QuotedWord, + Comment, CompoundBareWord, - List, Expression, - BracedExpression, - ParenExpression, - UnaryOp, - BinaryOp, - TernaryOp, Function, + List, + ParenExpression, + QuotedWord, + Script, + TernaryOp, + UnaryOp, + VarSub, ) -from src.tools.commands import CommandArgError, get_commands -from src.tools.commands.checks import check_command -from src.tools.violations import Rule, Violation def _strip_ws(parse_func): """Decorator used by expression parser for stripping whitespace around a node.""" def func(parser, ts): - while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: ts.next() node = parse_func(parser, ts) - while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: ts.next() return node @@ -98,15 +74,12 @@ class _Word: class Parser: - def __init__(self, debug=False, command_plugins=None): + def __init__(self, debug=False): self._debug = debug self._debug_indent = 0 - # TODO: better way to handle this? self.violations = [] - if command_plugins is None: - command_plugins = [] - self._commands = get_commands(command_plugins) + self._commands = [] def debug(self, *msg): if self._debug: @@ -197,12 +170,12 @@ class Parser: script = Script(pos=pos) while ts.type() is not TOK_EOF: - if ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + if ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: # strip whitespace at start of command ts.next() continue - if ts.type() == TOK_HASH: + if ts.type() == Tok.TOK_HASH: script.add(self.parse_comment(ts)) else: cmd = self.parse_command(ts, in_command_sub=in_command_sub) @@ -210,13 +183,13 @@ class Parser: script.add(cmd) # when in command sub mode, a script is terminated by ] - if in_command_sub and ts.type() == TOK_RBRACKET: + if in_command_sub and ts.type() == Tok.TOK_RBRACKET: return script ts.expect( TOK_EOF, - TOK_NEWLINE, - TOK_SEMI, + Tok.TOK_NEWLINE, + Tok.TOK_SEMI, message=f"expected newline or semicolon, got {ts.value()}", pos=ts.pos(), ) @@ -236,10 +209,10 @@ class Parser: self.debug(f"parse_comment({ts.current})") pos = ts.pos() - ts.assert_(TOK_HASH) + ts.assert_(Tok.TOK_HASH) value = "" - while ts.type() not in {TOK_NEWLINE, TOK_EOF}: + while ts.type() not in {Tok.TOK_NEWLINE, TOK_EOF}: value += ts.value() ts.next() @@ -261,10 +234,10 @@ class Parser: args = [] while True: - if ts.type() not in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + if ts.type() not in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: break - while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: ts.next() word = self.parse_word(ts, in_command_sub) @@ -276,9 +249,10 @@ class Parser: self._debug_indent -= 1 try: + pass parsed_args = self._parse_command_args(routine.contents, args) except CommandArgError as e: - self.violations.append(Violation(Rule.COMMAND_ARGS, str(e), pos, ts.pos())) + # self.violations.append(Violation(Rule.COMMAND_ARGS, str(e), pos, ts.pos())) parsed_args = args children = [routine, *parsed_args] @@ -289,11 +263,11 @@ class Parser: def parse_word(self, ts, in_command_sub): self.debug(f"parse_word({ts.current})") - if ts.type() == TOK_ARG_EXPANSION: + if ts.type() == Tok.TOK_ARG_EXPANSION: return self.parse_arg_expansion(ts, in_command_sub) - elif ts.type() == TOK_LBRACE: + elif ts.type() == Tok.TOK_LBRACE: return self.parse_braced_word(ts) - elif ts.type() == TOK_QUOTE: + elif ts.type() == Tok.TOK_QUOTE: return self.parse_quoted_word(ts) else: return self.parse_bare_word(ts, in_command_sub) @@ -302,11 +276,17 @@ class Parser: self.debug(f"parse_arg_expansion({ts.current})") pos = ts.pos() - ts.assert_(TOK_ARG_EXPANSION) + ts.assert_(Tok.TOK_ARG_EXPANSION) - delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + delimiters = [ + Tok.TOK_WS, + Tok.TOK_BACKSLASH_NEWLINE, + Tok.TOK_NEWLINE, + Tok.TOK_SEMI, + TOK_EOF, + ] if in_command_sub: - delimiters.append(TOK_RBRACKET) + delimiters.append(Tok.TOK_RBRACKET) # Arg expansion is just a regular braced word if followed by whitespace, # or other word boundaries such as semicolon or right bracket @@ -323,18 +303,18 @@ class Parser: self._debug_indent += 1 pos = ts.pos() - ts.assert_(TOK_QUOTE) + ts.assert_(Tok.TOK_QUOTE) word = _Word() - while ts.type() not in {TOK_QUOTE, TOK_EOF}: - if ts.type() == TOK_DOLLAR: + while ts.type() not in {Tok.TOK_QUOTE, TOK_EOF}: + if ts.type() == Tok.TOK_DOLLAR: dollar_tok = ts.current var_sub = self.parse_var_sub(ts) if var_sub: word.add_node(var_sub) else: word.add_tok(dollar_tok) - elif ts.type() == TOK_LBRACKET: + elif ts.type() == Tok.TOK_LBRACKET: command_sub = self.parse_command_sub(ts) word.add_node(command_sub) else: @@ -344,7 +324,9 @@ class Parser: res = word.resolve(ts.pos()) ts.expect( - TOK_QUOTE, message="reached EOF without finding match for quote", pos=pos + Tok.TOK_QUOTE, + message="reached EOF without finding match for quote", + pos=pos, ) self._debug_indent -= 1 @@ -360,7 +342,7 @@ class Parser: ts.lexer.push_state(STATE_BRACEDWORD) - ts.assert_(TOK_LBRACE) + ts.assert_(Tok.TOK_LBRACE) word = "" # store position for each brace we want to match, facilitating good @@ -375,9 +357,9 @@ class Parser: ts.pos(), ) - if toktype == TOK_LBRACE: + if toktype == Tok.TOK_LBRACE: expected_braces.append(ts.pos()) - elif toktype == TOK_RBRACE: + elif toktype == Tok.TOK_RBRACE: try: expected_braces.pop() except IndexError: @@ -404,21 +386,27 @@ class Parser: pos = ts.pos() word = _Word() - delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + delimiters = [ + Tok.TOK_WS, + Tok.TOK_BACKSLASH_NEWLINE, + Tok.TOK_NEWLINE, + Tok.TOK_SEMI, + TOK_EOF, + ] # In command sub mode, words are ended by ] if in_command_sub: - delimiters.append(TOK_RBRACKET) + delimiters.append(Tok.TOK_RBRACKET) while ts.type() not in delimiters: - if ts.type() == TOK_DOLLAR: + if ts.type() == Tok.TOK_DOLLAR: dollar_tok = ts.current var_sub = self.parse_var_sub(ts) if var_sub: word.add_node(var_sub) else: word.add_tok(dollar_tok) - elif ts.type() == TOK_LBRACKET: + elif ts.type() == Tok.TOK_LBRACKET: command_sub = self.parse_command_sub(ts) word.add_node(command_sub) else: @@ -439,13 +427,13 @@ class Parser: self.debug(f"parse_var_sub({ts.current})") pos = ts.pos() - ts.assert_(TOK_DOLLAR) + ts.assert_(Tok.TOK_DOLLAR) var = "" - if ts.type() == TOK_LBRACE: + if ts.type() == Tok.TOK_LBRACE: brace_pos = ts.pos() ts.next() - while ts.type() != TOK_RBRACE: + while ts.type() != Tok.TOK_RBRACE: if ts.type() is TOK_EOF: raise TclSyntaxError( "reached EOF without finding match for brace", @@ -458,7 +446,11 @@ class Parser: return VarSub(var, pos=pos, end_pos=ts.pos(), braced=True) - while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS, TOK_NAMESPACE_SEP}: + while ts.type() in { + Tok.TOK_ALPHA_CHARS, + Tok.TOK_NUM_CHARS, + Tok.TOK_NAMESPACE_SEP, + }: var += ts.value() ts.next() @@ -466,25 +458,25 @@ class Parser: return None index_nodes = [] - if ts.type() == TOK_LPAREN: + if ts.type() == Tok.TOK_LPAREN: paren_pos = ts.pos() index = _Word() ts.next() - while ts.type() != TOK_RPAREN: + while ts.type() != Tok.TOK_RPAREN: if ts.type() == TOK_EOF: raise TclSyntaxError( "reached EOF without finding match for paren", paren_pos, ts.pos(), ) - if ts.type() == TOK_DOLLAR: + if ts.type() == Tok.TOK_DOLLAR: dollar_tok = ts.current var_sub = self.parse_var_sub(ts) if var_sub: index.add_node(var_sub) else: index.add_tok(dollar_tok) - elif ts.type() == TOK_LBRACKET: + elif ts.type() == Tok.TOK_LBRACKET: command_sub = self.parse_command_sub(ts) index.add_node(command_sub) else: @@ -506,11 +498,11 @@ class Parser: self._debug_indent += 1 pos = ts.pos() - ts.assert_(TOK_LBRACKET) + ts.assert_(Tok.TOK_LBRACKET) script = self._parse_script(ts, in_command_sub=True) - ts.assert_(TOK_RBRACKET) + ts.assert_(Tok.TOK_RBRACKET) end_pos = ts.pos() script.line = pos[0] @@ -537,7 +529,7 @@ class Parser: ts = Lexer(pos=node.contents_pos) ts.input(node.contents) - DELIMITERS = {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE} + DELIMITERS = {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE} list_node = List(pos=node.pos, end_pos=node.end_pos) while ts.type() is not TOK_EOF: @@ -547,24 +539,24 @@ class Parser: if ts.type() is TOK_EOF: break - if ts.type() == TOK_LBRACE: + if ts.type() == Tok.TOK_LBRACE: # we can reuse parse_braced_word, since it doesn't use # substitutions in any case list_node.add(self.parse_braced_word(ts)) - elif ts.type() == TOK_QUOTE: + elif ts.type() == Tok.TOK_QUOTE: quote_word_pos = ts.pos() - ts.assert_(TOK_QUOTE) + ts.assert_(Tok.TOK_QUOTE) bare_word_pos = ts.pos() contents = "" - while ts.type() not in {TOK_QUOTE, TOK_EOF}: + while ts.type() not in {Tok.TOK_QUOTE, TOK_EOF}: contents += ts.value() ts.next() word = BareWord(contents, pos=bare_word_pos, end_pos=ts.pos()) ts.expect( - TOK_QUOTE, + Tok.TOK_QUOTE, message="reached EOF without finding match for quote", pos=quote_word_pos, ) @@ -607,7 +599,7 @@ class Parser: expr = op1 # last condition is hack to break out of expression in case we're in ternary op - if ts.type() not in {TOK_EOF, TOK_RPAREN} and ts.value() not in {":", ","}: + if ts.type() not in {TOK_EOF, Tok.TOK_RPAREN} and ts.value() not in {":", ","}: if ts.value() == "?": # weird hack to record operator start = ts.pos() @@ -637,27 +629,27 @@ class Parser: op2 = self._parse_expression(ts) expr = BinaryOp(op1, operator, op2, pos=op1.pos, end_pos=op2.end_pos) - if ts.type() != TOK_RPAREN and ts.value() not in {":", ","}: + if ts.type() != Tok.TOK_RPAREN and ts.value() not in {":", ","}: ts.expect(TOK_EOF, message="expected end of expression", pos=ts.pos()) return expr @_strip_ws def _parse_operand(self, ts): - if ts.type() == TOK_DOLLAR: + if ts.type() == Tok.TOK_DOLLAR: return self.parse_var_sub(ts) - if ts.type() == TOK_QUOTE: + if ts.type() == Tok.TOK_QUOTE: return self.parse_quoted_word(ts) - if ts.type() == TOK_LBRACE: + if ts.type() == Tok.TOK_LBRACE: return self.parse_braced_word(ts) - if ts.type() == TOK_LBRACKET: + if ts.type() == Tok.TOK_LBRACKET: return self.parse_command_sub(ts) - if ts.type() == TOK_LPAREN: + if ts.type() == Tok.TOK_LPAREN: start = ts.pos() ts.next() expr = self._parse_expression(ts) ts.expect( - TOK_RPAREN, + Tok.TOK_RPAREN, message="reached EOF without finding match for paren", pos=expr.pos, ) @@ -693,7 +685,7 @@ class Parser: # move on. If not, we keep consuming tokens that may correspond to a # valid bareword (pretty much just alphanumeric chars). if not (_is_int_literal(operand) or _is_float_literal(operand)): - while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS}: + while ts.type() in {Tok.TOK_ALPHA_CHARS, Tok.TOK_NUM_CHARS}: operand += ts.value() ts.next() @@ -763,16 +755,16 @@ class Parser: return BareWord(operator, pos=pos, end_pos=ts.pos()) def _parse_function(self, ts, name): - while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: ts.next() ts.expect( - TOK_LPAREN, + Tok.TOK_LPAREN, message="expected open paren after function name", pos=name.pos, ) - delims = {TOK_RPAREN, TOK_EOF} + delims = {Tok.TOK_RPAREN, TOK_EOF} arguments = [] if ts.type() not in delims: @@ -791,7 +783,7 @@ class Parser: arguments.append(self._parse_expression(ts)) ts.expect( - TOK_RPAREN, + Tok.TOK_RPAREN, message="expected close paren after function arguments", pos=name.pos, ) diff --git a/server/src/tools/plugins.py b/server/src/tools/plugins.py deleted file mode 100644 index 927e874..0000000 --- a/server/src/tools/plugins.py +++ /dev/null @@ -1,81 +0,0 @@ -from importlib.metadata import entry_points -import json -import pathlib -from typing import Dict, Optional - - -class _PluginManager: - def __init__(self): - self._loaded = {} - self._installed = {} - self._loaded_specs = {} - for plugin in entry_points(group="tclint.plugins"): - if plugin.name in self._installed: - print(f"Warning: found duplicate definitions for plugin {plugin.name}") - self._installed[plugin.name] = plugin - - def load(self, name: str) -> Optional[Dict]: - if name in self._loaded: - return self._loaded[name] - - mod = self._load(name) - self._loaded[name] = mod - return mod - - def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: - if path in self._loaded_specs: - return self._loaded_specs[path] - - spec = self._load_from_spec(path) - self._loaded_specs[path] = spec - return spec - - def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: - try: - with open(path.expanduser(), "r") as f: - spec = json.load(f) - except (FileNotFoundError, RuntimeError): - print(f"Warning: command spec {path} not found, skipping...") - return None - - try: - # Apply defaults and validate the spec. - spec = command_schema(spec) - except voluptuous.Invalid as e: - print(f"Warning: invalid command spec {path}: {e}") - return None - - return spec["commands"] - - def get_mod(self, name: str) -> Optional[ModuleType]: - if name not in self._installed: - print(f"Warning: plugin {name} is not installed") - return None - - plugin = self._installed[name] - - try: - module = plugin.load() - except Exception as e: - print(f"Warning: error loading plugin {name}: {e}") - return None - - return module - - def _load(self, name: str): - module = self.get_mod(name) - if module is None: - print(f"Skipping requested plugin {name}") - return None - - if not hasattr(module, "commands"): - print(f"Warning: skipping plugin {name} since it does not define commands") - return None - - return getattr(module, "commands") - - -# TODO: we'll probably want to construct this in the tclint entry point and pass -# it around rather than using a singleton instance, but this made for an easier -# refactor. -PluginManager = _PluginManager() diff --git a/server/src/tools/schema.py b/server/src/tools/schema.py deleted file mode 100644 index 7662a10..0000000 --- a/server/src/tools/schema.py +++ /dev/null @@ -1,35 +0,0 @@ -from collections.abc import Callable -from voluptuous import Schema, Optional, Or, Self - -# Need to define this as a Schema with required=True to ensure that this requirement -# persists through the Or in the main schema definition. -_command_args = Schema( - { - Optional("positionals", default=[]): [ - { - "name": str, - "required": bool, - "value": Or({"type": "any"}, {"type": "variadic"}), - } - ], - Optional("switches", default={}): { - Optional(str): { - "required": bool, - "repeated": bool, - "value": Or({"type": "any"}, None), - Optional("metavar"): str, - } - }, - }, - required=True, -) - -commands_schema = Schema( - {Optional(str): Or(_command_args, None, {"subcommands": Self}, Callable)}, - required=True, -) - -schema = Schema( - {"name": str, "commands": commands_schema}, - required=True, -) diff --git a/server/src/tools/syntax_tree.py b/server/src/tools/syntax_tree.py index d11f6df..fdfac56 100644 --- a/server/src/tools/syntax_tree.py +++ b/server/src/tools/syntax_tree.py @@ -1,4 +1,4 @@ -"""Classes for representing and interacting with Tcl syntax trees. """ +"""Classes for representing and interacting with Tcl syntax trees.""" class Visitor: @@ -67,9 +67,6 @@ class Node: """ def __init__(self, *init, pos=None, end_pos=None): - """pos: line, column of first character of parsed region (1-indexed) - end_pos: line, column of first character after parsed region (1-indexed) - """ self.line = None self.col = None if pos is not None: @@ -80,7 +77,6 @@ class Node: if len(init) > 0 and not isinstance(init[0], Node): self.value = init[0] init = init[1:] - if not all(isinstance(v, Node) for v in init): raise TypeError("Children must be Node instances") @@ -188,12 +184,12 @@ class Node: return lines if len(self.children) != len(other.children): - my_children = ",".join([ - child.__class__.__name__ for child in self.children - ]) - other_children = ",".join([ - child.__class__.__name__ for child in other.children - ]) + my_children = ",".join( + [child.__class__.__name__ for child in self.children] + ) + other_children = ",".join( + [child.__class__.__name__ for child in other.children] + ) lines += [f"{indent}-{my_cls}({my_children})"] lines += [f"{indent}+{other_cls}({other_children})"] diff --git a/server/src/tools/violations.py b/server/src/tools/violations.py deleted file mode 100644 index 8b08329..0000000 --- a/server/src/tools/violations.py +++ /dev/null @@ -1,50 +0,0 @@ -from enum import Enum -from typing import Tuple - - -class Rule(Enum): - """This enum serves a few purposes: - - 1) define symbols for rule IDs to be used in code - 2) map these symbols to names in the UI - 3) collect all rule IDs/provide validation for IDs - """ - - LINE_LENGTH = "line-length" - TRAILING_WHITESPACE = "trailing-whitespace" - COMMAND_ARGS = "command-args" - REDEFINED_BUILTIN = "redefined-builtin" - UNBRACED_EXPR = "unbraced-expr" - REDUNDANT_EXPR = "redundant-expr" - - def __str__(self): - return self.value - - -ALL_RULES = [rule for rule in Rule] - - -class Violation: - def __init__( - self, id: Rule, message: str, start: Tuple[int, int], end: Tuple[int, int] - ): - self.id = id - self.message = message - self.start = start - self.end = end - - def __lt__(self, other): - return self.start < other.start - - def __str__(self): - line, col = self.start - rule = str(self.id) - - return f"{line}:{col}: {self.message} [{rule}]" - - @classmethod - def create(cls, id): - def func(message: str, start: Tuple[int, int], end: Tuple[int, int]): - return cls(id, message, start, end) - - return func -- 2.54.0 From c6f0758b974fe9398c1b2f30d22b177fb04dd083 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Sun, 27 Jul 2025 17:55:48 +0200 Subject: [PATCH 08/10] use tclint as parser / formatter --- server/libs/bin/tclfmt.exe | Bin 0 -> 108435 bytes server/libs/bin/tclint.exe | Bin 0 -> 108435 bytes server/libs/bin/tclsp.exe | Bin 0 -> 108434 bytes .../INSTALLER | 0 .../LICENSE | 202 +++ .../METADATA | 138 ++ .../importlib_metadata-6.8.0.dist-info/RECORD | 26 + .../REQUESTED | 0 .../importlib_metadata-6.8.0.dist-info/WHEEL | 5 + .../top_level.txt | 1 + server/libs/importlib_metadata/__init__.py | 1015 ++++++++++++ server/libs/importlib_metadata/_adapters.py | 90 ++ .../libs/importlib_metadata/_collections.py | 30 + server/libs/importlib_metadata/_compat.py | 67 + server/libs/importlib_metadata/_functools.py | 104 ++ server/libs/importlib_metadata/_itertools.py | 73 + server/libs/importlib_metadata/_meta.py | 63 + server/libs/importlib_metadata/_py39compat.py | 35 + server/libs/importlib_metadata/_text.py | 99 ++ .../{lark => importlib_metadata}/py.typed | 0 server/libs/lark-1.2.2.dist-info/LICENSE | 18 - server/libs/lark-1.2.2.dist-info/METADATA | 47 - server/libs/lark-1.2.2.dist-info/RECORD | 83 - .../lark-1.2.2.dist-info/entry_points.txt | 2 - .../libs/lark-1.2.2.dist-info/top_level.txt | 1 - server/libs/lark/__init__.py | 38 - server/libs/lark/__pyinstaller/__init__.py | 6 - server/libs/lark/__pyinstaller/hook-lark.py | 14 - server/libs/lark/ast_utils.py | 59 - server/libs/lark/common.py | 86 - server/libs/lark/exceptions.py | 292 ---- server/libs/lark/grammar.py | 130 -- server/libs/lark/grammars/common.lark | 59 - server/libs/lark/grammars/lark.lark | 62 - server/libs/lark/grammars/python.lark | 302 ---- server/libs/lark/grammars/unicode.lark | 7 - server/libs/lark/indenter.py | 143 -- server/libs/lark/lark.py | 658 -------- server/libs/lark/lexer.py | 678 -------- server/libs/lark/load_grammar.py | 1428 ----------------- server/libs/lark/parse_tree_builder.py | 391 ----- server/libs/lark/parser_frontends.py | 257 --- server/libs/lark/parsers/cyk.py | 340 ---- server/libs/lark/parsers/earley.py | 317 ---- server/libs/lark/parsers/earley_common.py | 42 - server/libs/lark/parsers/earley_forest.py | 802 --------- server/libs/lark/parsers/grammar_analysis.py | 203 --- server/libs/lark/parsers/lalr_analysis.py | 332 ---- .../lark/parsers/lalr_interactive_parser.py | 158 -- server/libs/lark/parsers/lalr_parser.py | 122 -- server/libs/lark/parsers/lalr_parser_state.py | 110 -- server/libs/lark/parsers/xearley.py | 165 -- server/libs/lark/reconstruct.py | 107 -- server/libs/lark/tools/__init__.py | 70 - server/libs/lark/tools/nearley.py | 202 --- server/libs/lark/tools/serialize.py | 32 - server/libs/lark/tools/standalone.py | 196 --- server/libs/lark/tree.py | 267 --- server/libs/lark/tree_matcher.py | 186 --- server/libs/lark/tree_templates.py | 180 --- server/libs/lark/utils.py | 346 ---- server/libs/lark/visitors.py | 596 ------- .../libs/pathspec-0.11.2.dist-info/INSTALLER | 1 + server/libs/pathspec-0.11.2.dist-info/LICENSE | 373 +++++ .../libs/pathspec-0.11.2.dist-info/METADATA | 601 +++++++ server/libs/pathspec-0.11.2.dist-info/RECORD | 23 + .../REQUESTED} | 0 server/libs/pathspec-0.11.2.dist-info/WHEEL | 4 + server/libs/pathspec/__init__.py | 76 + server/libs/pathspec/_meta.py | 57 + server/libs/pathspec/gitignore.py | 138 ++ server/libs/pathspec/pathspec.py | 304 ++++ server/libs/pathspec/pattern.py | 206 +++ server/libs/pathspec/patterns/__init__.py | 11 + server/libs/pathspec/patterns/gitwildmatch.py | 421 +++++ server/libs/pathspec/py.typed | 1 + server/libs/pathspec/util.py | 719 +++++++++ server/libs/tclint-0.6.0.dist-info/INSTALLER | 1 + server/libs/tclint-0.6.0.dist-info/METADATA | 112 ++ server/libs/tclint-0.6.0.dist-info/RECORD | 51 + .../REQUESTED} | 0 .../WHEEL | 2 +- .../tclint-0.6.0.dist-info/entry_points.txt | 4 + .../tclint-0.6.0.dist-info/licenses/LICENSE | 8 + .../libs/tclint-0.6.0.dist-info/top_level.txt | 1 + .../tcl.lark => libs/tclint/__init__.py} | 0 server/libs/tclint/__main__.py | 4 + server/libs/tclint/_version.py | 21 + server/libs/tclint/checks.py | 227 +++ server/libs/tclint/cli/tclfmt.py | 182 +++ server/libs/tclint/cli/tclint.py | 173 ++ server/libs/tclint/cli/tclsp.py | 437 +++++ server/libs/tclint/cli/utils.py | 88 + server/libs/tclint/commands/__init__.py | 37 + .../tools => libs/tclint}/commands/builtin.py | 526 +++--- .../tools => libs/tclint}/commands/checks.py | 2 +- server/libs/tclint/commands/plugins.py | 86 + .../tools => libs/tclint}/commands/schema.py | 0 server/libs/tclint/comments.py | 91 ++ server/libs/tclint/config.py | 427 +++++ server/libs/tclint/format.py | 480 ++++++ server/{src/tools => libs/tclint}/lexer.py | 92 +- server/libs/tclint/parser.py | 900 +++++++++++ .../{src/tools => libs/tclint}/syntax_tree.py | 18 +- server/libs/tclint/violations.py | 50 + server/libs/zipp-3.23.0.dist-info/INSTALLER | 1 + server/libs/zipp-3.23.0.dist-info/METADATA | 106 ++ server/libs/zipp-3.23.0.dist-info/RECORD | 21 + server/libs/zipp-3.23.0.dist-info/REQUESTED | 0 server/libs/zipp-3.23.0.dist-info/WHEEL | 5 + .../zipp-3.23.0.dist-info/licenses/LICENSE | 18 + .../libs/zipp-3.23.0.dist-info/top_level.txt | 1 + server/libs/zipp/__init__.py | 456 ++++++ server/libs/zipp/_functools.py | 20 + server/libs/zipp/compat/__init__.py | 0 server/libs/zipp/compat/overlay.py | 37 + server/libs/zipp/compat/py310.py | 13 + server/libs/zipp/compat/py313.py | 34 + server/libs/zipp/glob.py | 116 ++ server/requirements.in | 4 +- server/requirements.txt | 26 +- server/src/lsp_server.py | 95 +- server/src/parser/ast.py | 93 ++ server/src/parser/lexer.py | 243 +++ server/src/parser/parser.py | 144 +- server/src/test_tcl.py | 97 +- server/src/tools/commands/__init__.py | 17 - server/src/tools/formatter.py | 0 server/src/tools/nx_plugins.py | 16 + server/src/tools/parser.py | 903 +---------- server/src/tools/semantic_tokens.py | 76 + test/test.tcl | 8 + 132 files changed, 10193 insertions(+), 10794 deletions(-) create mode 100644 server/libs/bin/tclfmt.exe create mode 100644 server/libs/bin/tclint.exe create mode 100644 server/libs/bin/tclsp.exe rename server/libs/{lark-1.2.2.dist-info => importlib_metadata-6.8.0.dist-info}/INSTALLER (100%) create mode 100644 server/libs/importlib_metadata-6.8.0.dist-info/LICENSE create mode 100644 server/libs/importlib_metadata-6.8.0.dist-info/METADATA create mode 100644 server/libs/importlib_metadata-6.8.0.dist-info/RECORD rename server/libs/{lark-1.2.2.dist-info => importlib_metadata-6.8.0.dist-info}/REQUESTED (100%) create mode 100644 server/libs/importlib_metadata-6.8.0.dist-info/WHEEL create mode 100644 server/libs/importlib_metadata-6.8.0.dist-info/top_level.txt create mode 100644 server/libs/importlib_metadata/__init__.py create mode 100644 server/libs/importlib_metadata/_adapters.py create mode 100644 server/libs/importlib_metadata/_collections.py create mode 100644 server/libs/importlib_metadata/_compat.py create mode 100644 server/libs/importlib_metadata/_functools.py create mode 100644 server/libs/importlib_metadata/_itertools.py create mode 100644 server/libs/importlib_metadata/_meta.py create mode 100644 server/libs/importlib_metadata/_py39compat.py create mode 100644 server/libs/importlib_metadata/_text.py rename server/libs/{lark => importlib_metadata}/py.typed (100%) delete mode 100644 server/libs/lark-1.2.2.dist-info/LICENSE delete mode 100644 server/libs/lark-1.2.2.dist-info/METADATA delete mode 100644 server/libs/lark-1.2.2.dist-info/RECORD delete mode 100644 server/libs/lark-1.2.2.dist-info/entry_points.txt delete mode 100644 server/libs/lark-1.2.2.dist-info/top_level.txt delete mode 100644 server/libs/lark/__init__.py delete mode 100644 server/libs/lark/__pyinstaller/__init__.py delete mode 100644 server/libs/lark/__pyinstaller/hook-lark.py delete mode 100644 server/libs/lark/ast_utils.py delete mode 100644 server/libs/lark/common.py delete mode 100644 server/libs/lark/exceptions.py delete mode 100644 server/libs/lark/grammar.py delete mode 100644 server/libs/lark/grammars/common.lark delete mode 100644 server/libs/lark/grammars/lark.lark delete mode 100644 server/libs/lark/grammars/python.lark delete mode 100644 server/libs/lark/grammars/unicode.lark delete mode 100644 server/libs/lark/indenter.py delete mode 100644 server/libs/lark/lark.py delete mode 100644 server/libs/lark/lexer.py delete mode 100644 server/libs/lark/load_grammar.py delete mode 100644 server/libs/lark/parse_tree_builder.py delete mode 100644 server/libs/lark/parser_frontends.py delete mode 100644 server/libs/lark/parsers/cyk.py delete mode 100644 server/libs/lark/parsers/earley.py delete mode 100644 server/libs/lark/parsers/earley_common.py delete mode 100644 server/libs/lark/parsers/earley_forest.py delete mode 100644 server/libs/lark/parsers/grammar_analysis.py delete mode 100644 server/libs/lark/parsers/lalr_analysis.py delete mode 100644 server/libs/lark/parsers/lalr_interactive_parser.py delete mode 100644 server/libs/lark/parsers/lalr_parser.py delete mode 100644 server/libs/lark/parsers/lalr_parser_state.py delete mode 100644 server/libs/lark/parsers/xearley.py delete mode 100644 server/libs/lark/reconstruct.py delete mode 100644 server/libs/lark/tools/__init__.py delete mode 100644 server/libs/lark/tools/nearley.py delete mode 100644 server/libs/lark/tools/serialize.py delete mode 100644 server/libs/lark/tools/standalone.py delete mode 100644 server/libs/lark/tree.py delete mode 100644 server/libs/lark/tree_matcher.py delete mode 100644 server/libs/lark/tree_templates.py delete mode 100644 server/libs/lark/utils.py delete mode 100644 server/libs/lark/visitors.py create mode 100644 server/libs/pathspec-0.11.2.dist-info/INSTALLER create mode 100644 server/libs/pathspec-0.11.2.dist-info/LICENSE create mode 100644 server/libs/pathspec-0.11.2.dist-info/METADATA create mode 100644 server/libs/pathspec-0.11.2.dist-info/RECORD rename server/libs/{lark/grammars/__init__.py => pathspec-0.11.2.dist-info/REQUESTED} (100%) create mode 100644 server/libs/pathspec-0.11.2.dist-info/WHEEL create mode 100644 server/libs/pathspec/__init__.py create mode 100644 server/libs/pathspec/_meta.py create mode 100644 server/libs/pathspec/gitignore.py create mode 100644 server/libs/pathspec/pathspec.py create mode 100644 server/libs/pathspec/pattern.py create mode 100644 server/libs/pathspec/patterns/__init__.py create mode 100644 server/libs/pathspec/patterns/gitwildmatch.py create mode 100644 server/libs/pathspec/py.typed create mode 100644 server/libs/pathspec/util.py create mode 100644 server/libs/tclint-0.6.0.dist-info/INSTALLER create mode 100644 server/libs/tclint-0.6.0.dist-info/METADATA create mode 100644 server/libs/tclint-0.6.0.dist-info/RECORD rename server/libs/{lark/parsers/__init__.py => tclint-0.6.0.dist-info/REQUESTED} (100%) rename server/libs/{lark-1.2.2.dist-info => tclint-0.6.0.dist-info}/WHEEL (65%) create mode 100644 server/libs/tclint-0.6.0.dist-info/entry_points.txt create mode 100644 server/libs/tclint-0.6.0.dist-info/licenses/LICENSE create mode 100644 server/libs/tclint-0.6.0.dist-info/top_level.txt rename server/{src/parser/tcl.lark => libs/tclint/__init__.py} (100%) create mode 100644 server/libs/tclint/__main__.py create mode 100644 server/libs/tclint/_version.py create mode 100644 server/libs/tclint/checks.py create mode 100644 server/libs/tclint/cli/tclfmt.py create mode 100644 server/libs/tclint/cli/tclint.py create mode 100644 server/libs/tclint/cli/tclsp.py create mode 100644 server/libs/tclint/cli/utils.py create mode 100644 server/libs/tclint/commands/__init__.py rename server/{src/tools => libs/tclint}/commands/builtin.py (69%) rename server/{src/tools => libs/tclint}/commands/checks.py (99%) create mode 100644 server/libs/tclint/commands/plugins.py rename server/{src/tools => libs/tclint}/commands/schema.py (100%) create mode 100644 server/libs/tclint/comments.py create mode 100644 server/libs/tclint/config.py create mode 100644 server/libs/tclint/format.py rename server/{src/tools => libs/tclint}/lexer.py (81%) create mode 100644 server/libs/tclint/parser.py rename server/{src/tools => libs/tclint}/syntax_tree.py (96%) create mode 100644 server/libs/tclint/violations.py create mode 100644 server/libs/zipp-3.23.0.dist-info/INSTALLER create mode 100644 server/libs/zipp-3.23.0.dist-info/METADATA create mode 100644 server/libs/zipp-3.23.0.dist-info/RECORD create mode 100644 server/libs/zipp-3.23.0.dist-info/REQUESTED create mode 100644 server/libs/zipp-3.23.0.dist-info/WHEEL create mode 100644 server/libs/zipp-3.23.0.dist-info/licenses/LICENSE create mode 100644 server/libs/zipp-3.23.0.dist-info/top_level.txt create mode 100644 server/libs/zipp/__init__.py create mode 100644 server/libs/zipp/_functools.py create mode 100644 server/libs/zipp/compat/__init__.py create mode 100644 server/libs/zipp/compat/overlay.py create mode 100644 server/libs/zipp/compat/py310.py create mode 100644 server/libs/zipp/compat/py313.py create mode 100644 server/libs/zipp/glob.py create mode 100644 server/src/parser/ast.py create mode 100644 server/src/parser/lexer.py delete mode 100644 server/src/tools/commands/__init__.py create mode 100644 server/src/tools/formatter.py create mode 100644 server/src/tools/nx_plugins.py create mode 100644 server/src/tools/semantic_tokens.py diff --git a/server/libs/bin/tclfmt.exe b/server/libs/bin/tclfmt.exe new file mode 100644 index 0000000000000000000000000000000000000000..3173b085b53035c7579f9efa491d33ebde150c89 GIT binary patch literal 108435 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 literal 0 HcmV?d00001 diff --git a/server/libs/bin/tclint.exe b/server/libs/bin/tclint.exe new file mode 100644 index 0000000000000000000000000000000000000000..6585f69d2856e35e08f348110794dae92e5dce2e GIT binary patch literal 108435 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 literal 0 HcmV?d00001 diff --git a/server/libs/bin/tclsp.exe b/server/libs/bin/tclsp.exe new file mode 100644 index 0000000000000000000000000000000000000000..8fbb54d2564f81ed434a2d040dcc15c45e24ed70 GIT binary patch literal 108434 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 literal 0 HcmV?d00001 diff --git a/server/libs/lark-1.2.2.dist-info/INSTALLER b/server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER similarity index 100% rename from server/libs/lark-1.2.2.dist-info/INSTALLER rename to server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/LICENSE b/server/libs/importlib_metadata-6.8.0.dist-info/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/server/libs/importlib_metadata-6.8.0.dist-info/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/METADATA b/server/libs/importlib_metadata-6.8.0.dist-info/METADATA new file mode 100644 index 0000000..639bbea --- /dev/null +++ b/server/libs/importlib_metadata-6.8.0.dist-info/METADATA @@ -0,0 +1,138 @@ +Metadata-Version: 2.1 +Name: importlib-metadata +Version: 6.8.0 +Summary: Read metadata from Python packages +Home-page: https://github.com/python/importlib_metadata +Author: Jason R. Coombs +Author-email: jaraco@jaraco.com +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Requires-Python: >=3.8 +License-File: LICENSE +Requires-Dist: zipp (>=0.5) +Requires-Dist: typing-extensions (>=3.6.4) ; python_version < "3.8" +Provides-Extra: docs +Requires-Dist: sphinx (>=3.5) ; extra == 'docs' +Requires-Dist: jaraco.packaging (>=9) ; extra == 'docs' +Requires-Dist: rst.linker (>=1.9) ; extra == 'docs' +Requires-Dist: furo ; extra == 'docs' +Requires-Dist: sphinx-lint ; extra == 'docs' +Requires-Dist: jaraco.tidelift (>=1.4) ; extra == 'docs' +Provides-Extra: perf +Requires-Dist: ipython ; extra == 'perf' +Provides-Extra: testing +Requires-Dist: pytest (>=6) ; extra == 'testing' +Requires-Dist: pytest-checkdocs (>=2.4) ; extra == 'testing' +Requires-Dist: pytest-cov ; extra == 'testing' +Requires-Dist: pytest-enabler (>=2.2) ; extra == 'testing' +Requires-Dist: pytest-ruff ; extra == 'testing' +Requires-Dist: packaging ; extra == 'testing' +Requires-Dist: pyfakefs ; extra == 'testing' +Requires-Dist: flufl.flake8 ; extra == 'testing' +Requires-Dist: pytest-perf (>=0.9.2) ; extra == 'testing' +Requires-Dist: pytest-black (>=0.3.7) ; (platform_python_implementation != "PyPy") and extra == 'testing' +Requires-Dist: pytest-mypy (>=0.9.1) ; (platform_python_implementation != "PyPy") and extra == 'testing' +Requires-Dist: importlib-resources (>=1.3) ; (python_version < "3.9") and extra == 'testing' + +.. image:: https://img.shields.io/pypi/v/importlib_metadata.svg + :target: https://pypi.org/project/importlib_metadata + +.. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg + +.. image:: https://github.com/python/importlib_metadata/workflows/tests/badge.svg + :target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22 + :alt: tests + +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + :alt: Code style: Black + +.. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest + :target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest + +.. image:: https://img.shields.io/badge/skeleton-2023-informational + :target: https://blog.jaraco.com/skeleton + +.. image:: https://tidelift.com/badges/package/pypi/importlib-metadata + :target: https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=readme + +Library to access the metadata for a Python package. + +This package supplies third-party access to the functionality of +`importlib.metadata `_ +including improvements added to subsequent Python versions. + + +Compatibility +============= + +New features are introduced in this third-party library and later merged +into CPython. The following table indicates which versions of this library +were contributed to different versions in the standard library: + +.. list-table:: + :header-rows: 1 + + * - importlib_metadata + - stdlib + * - 6.5 + - 3.12 + * - 4.13 + - 3.11 + * - 4.6 + - 3.10 + * - 1.4 + - 3.8 + + +Usage +===== + +See the `online documentation `_ +for usage details. + +`Finder authors +`_ can +also add support for custom package installers. See the above documentation +for details. + + +Caveats +======= + +This project primarily supports third-party packages installed by PyPA +tools (or other conforming packages). It does not support: + +- Packages in the stdlib. +- Packages installed without metadata. + +Project details +=============== + + * Project home: https://github.com/python/importlib_metadata + * Report bugs at: https://github.com/python/importlib_metadata/issues + * Code hosting: https://github.com/python/importlib_metadata + * Documentation: https://importlib-metadata.readthedocs.io/ + +For Enterprise +============== + +Available as part of the Tidelift Subscription. + +This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. + +`Learn more `_. + +Security Contact +================ + +To report a security vulnerability, please use the +`Tidelift security contact `_. +Tidelift will coordinate the fix and disclosure. diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/RECORD b/server/libs/importlib_metadata-6.8.0.dist-info/RECORD new file mode 100644 index 0000000..9e6c0a6 --- /dev/null +++ b/server/libs/importlib_metadata-6.8.0.dist-info/RECORD @@ -0,0 +1,26 @@ +importlib_metadata-6.8.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +importlib_metadata-6.8.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358 +importlib_metadata-6.8.0.dist-info/METADATA,sha256=X79qGRh7gqvuaL_utK5X-MnwHJuIWke0e3eAx0IiLhc,5067 +importlib_metadata-6.8.0.dist-info/RECORD,, +importlib_metadata-6.8.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +importlib_metadata-6.8.0.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92 +importlib_metadata-6.8.0.dist-info/top_level.txt,sha256=CO3fD9yylANiXkrMo4qHLV_mqXL2sC5JFKgt1yWAT-A,19 +importlib_metadata/__init__.py,sha256=EiH0qTKP_6oa6pRGJgPrq0kvjnL3hJ18BJH8VaAYSBA,30749 +importlib_metadata/__pycache__/__init__.cpython-311.pyc,, +importlib_metadata/__pycache__/_adapters.cpython-311.pyc,, +importlib_metadata/__pycache__/_collections.cpython-311.pyc,, +importlib_metadata/__pycache__/_compat.cpython-311.pyc,, +importlib_metadata/__pycache__/_functools.cpython-311.pyc,, +importlib_metadata/__pycache__/_itertools.cpython-311.pyc,, +importlib_metadata/__pycache__/_meta.cpython-311.pyc,, +importlib_metadata/__pycache__/_py39compat.cpython-311.pyc,, +importlib_metadata/__pycache__/_text.cpython-311.pyc,, +importlib_metadata/_adapters.py,sha256=i8S6Ib1OQjcILA-l4gkzktMZe18TaeUNI49PLRp6OBU,2454 +importlib_metadata/_collections.py,sha256=CJ0OTCHIjWA0ZIVS4voORAsn2R4R2cQBEtPsZEJpASY,743 +importlib_metadata/_compat.py,sha256=zhjcWMfA9SNExFVVVBozOYbuiok0A4tdMsNk9ZDZi-A,1554 +importlib_metadata/_functools.py,sha256=PsY2-4rrKX4RVeRC1oGp1lB1pmC9eKN88_f-bD9uOoA,2895 +importlib_metadata/_itertools.py,sha256=cvr_2v8BRbxcIl5x5ldfqdHjhI8Yi8s8yk50G_nm6jQ,2068 +importlib_metadata/_meta.py,sha256=kypMW_-xSStooSm0WpJc6eupjT-Ipc2ZBIl23PyC3No,1613 +importlib_metadata/_py39compat.py,sha256=2Tk5twb_VgLCY-1NEAQjdZp_S9OFMC-pUzP2isuaPsQ,1098 +importlib_metadata/_text.py,sha256=HCsFksZpJLeTP3NEk_ngrAeXVRRtTrtyh9eOABoRP4A,2166 +importlib_metadata/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/server/libs/lark-1.2.2.dist-info/REQUESTED b/server/libs/importlib_metadata-6.8.0.dist-info/REQUESTED similarity index 100% rename from server/libs/lark-1.2.2.dist-info/REQUESTED rename to server/libs/importlib_metadata-6.8.0.dist-info/REQUESTED diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/WHEEL b/server/libs/importlib_metadata-6.8.0.dist-info/WHEEL new file mode 100644 index 0000000..1f37c02 --- /dev/null +++ b/server/libs/importlib_metadata-6.8.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.40.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/top_level.txt b/server/libs/importlib_metadata-6.8.0.dist-info/top_level.txt new file mode 100644 index 0000000..bbb0754 --- /dev/null +++ b/server/libs/importlib_metadata-6.8.0.dist-info/top_level.txt @@ -0,0 +1 @@ +importlib_metadata diff --git a/server/libs/importlib_metadata/__init__.py b/server/libs/importlib_metadata/__init__.py new file mode 100644 index 0000000..6ba414e --- /dev/null +++ b/server/libs/importlib_metadata/__init__.py @@ -0,0 +1,1015 @@ +import os +import re +import abc +import csv +import sys +import zipp +import email +import inspect +import pathlib +import operator +import textwrap +import warnings +import functools +import itertools +import posixpath +import collections + +from . import _adapters, _meta, _py39compat +from ._collections import FreezableDefaultDict, Pair +from ._compat import ( + NullFinder, + StrPath, + install, + pypy_partial, +) +from ._functools import method_cache, pass_none +from ._itertools import always_iterable, unique_everseen +from ._meta import PackageMetadata, SimplePath + +from contextlib import suppress +from importlib import import_module +from importlib.abc import MetaPathFinder +from itertools import starmap +from typing import Iterable, List, Mapping, Optional, Set, cast + +__all__ = [ + 'Distribution', + 'DistributionFinder', + 'PackageMetadata', + 'PackageNotFoundError', + 'distribution', + 'distributions', + 'entry_points', + 'files', + 'metadata', + 'packages_distributions', + 'requires', + 'version', +] + + +class PackageNotFoundError(ModuleNotFoundError): + """The package was not found.""" + + def __str__(self) -> str: + return f"No package metadata was found for {self.name}" + + @property + def name(self) -> str: # type: ignore[override] + (name,) = self.args + return name + + +class Sectioned: + """ + A simple entry point config parser for performance + + >>> for item in Sectioned.read(Sectioned._sample): + ... print(item) + Pair(name='sec1', value='# comments ignored') + Pair(name='sec1', value='a = 1') + Pair(name='sec1', value='b = 2') + Pair(name='sec2', value='a = 2') + + >>> res = Sectioned.section_pairs(Sectioned._sample) + >>> item = next(res) + >>> item.name + 'sec1' + >>> item.value + Pair(name='a', value='1') + >>> item = next(res) + >>> item.value + Pair(name='b', value='2') + >>> item = next(res) + >>> item.name + 'sec2' + >>> item.value + Pair(name='a', value='2') + >>> list(res) + [] + """ + + _sample = textwrap.dedent( + """ + [sec1] + # comments ignored + a = 1 + b = 2 + + [sec2] + a = 2 + """ + ).lstrip() + + @classmethod + def section_pairs(cls, text): + return ( + section._replace(value=Pair.parse(section.value)) + for section in cls.read(text, filter_=cls.valid) + if section.name is not None + ) + + @staticmethod + def read(text, filter_=None): + lines = filter(filter_, map(str.strip, text.splitlines())) + name = None + for value in lines: + section_match = value.startswith('[') and value.endswith(']') + if section_match: + name = value.strip('[]') + continue + yield Pair(name, value) + + @staticmethod + def valid(line: str): + return line and not line.startswith('#') + + +class DeprecatedTuple: + """ + Provide subscript item access for backward compatibility. + + >>> recwarn = getfixture('recwarn') + >>> ep = EntryPoint(name='name', value='value', group='group') + >>> ep[:] + ('name', 'value', 'group') + >>> ep[0] + 'name' + >>> len(recwarn) + 1 + """ + + # Do not remove prior to 2023-05-01 or Python 3.13 + _warn = functools.partial( + warnings.warn, + "EntryPoint tuple interface is deprecated. Access members by name.", + DeprecationWarning, + stacklevel=pypy_partial(2), + ) + + def __getitem__(self, item): + self._warn() + return self._key()[item] + + +class EntryPoint(DeprecatedTuple): + """An entry point as defined by Python packaging conventions. + + See `the packaging docs on entry points + `_ + for more information. + + >>> ep = EntryPoint( + ... name=None, group=None, value='package.module:attr [extra1, extra2]') + >>> ep.module + 'package.module' + >>> ep.attr + 'attr' + >>> ep.extras + ['extra1', 'extra2'] + """ + + pattern = re.compile( + r'(?P[\w.]+)\s*' + r'(:\s*(?P[\w.]+)\s*)?' + r'((?P\[.*\])\s*)?$' + ) + """ + A regular expression describing the syntax for an entry point, + which might look like: + + - module + - package.module + - package.module:attribute + - package.module:object.attribute + - package.module:attr [extra1, extra2] + + Other combinations are possible as well. + + The expression is lenient about whitespace around the ':', + following the attr, and following any extras. + """ + + name: str + value: str + group: str + + dist: Optional['Distribution'] = None + + def __init__(self, name: str, value: str, group: str) -> None: + vars(self).update(name=name, value=value, group=group) + + def load(self): + """Load the entry point from its definition. If only a module + is indicated by the value, return that module. Otherwise, + return the named object. + """ + match = self.pattern.match(self.value) + module = import_module(match.group('module')) + attrs = filter(None, (match.group('attr') or '').split('.')) + return functools.reduce(getattr, attrs, module) + + @property + def module(self) -> str: + match = self.pattern.match(self.value) + assert match is not None + return match.group('module') + + @property + def attr(self) -> str: + match = self.pattern.match(self.value) + assert match is not None + return match.group('attr') + + @property + def extras(self) -> List[str]: + match = self.pattern.match(self.value) + assert match is not None + return re.findall(r'\w+', match.group('extras') or '') + + def _for(self, dist): + vars(self).update(dist=dist) + return self + + def matches(self, **params): + """ + EntryPoint matches the given parameters. + + >>> ep = EntryPoint(group='foo', name='bar', value='bing:bong [extra1, extra2]') + >>> ep.matches(group='foo') + True + >>> ep.matches(name='bar', value='bing:bong [extra1, extra2]') + True + >>> ep.matches(group='foo', name='other') + False + >>> ep.matches() + True + >>> ep.matches(extras=['extra1', 'extra2']) + True + >>> ep.matches(module='bing') + True + >>> ep.matches(attr='bong') + True + """ + attrs = (getattr(self, param) for param in params) + return all(map(operator.eq, params.values(), attrs)) + + def _key(self): + return self.name, self.value, self.group + + def __lt__(self, other): + return self._key() < other._key() + + def __eq__(self, other): + return self._key() == other._key() + + def __setattr__(self, name, value): + raise AttributeError("EntryPoint objects are immutable.") + + def __repr__(self): + return ( + f'EntryPoint(name={self.name!r}, value={self.value!r}, ' + f'group={self.group!r})' + ) + + def __hash__(self) -> int: + return hash(self._key()) + + +class EntryPoints(tuple): + """ + An immutable collection of selectable EntryPoint objects. + """ + + __slots__ = () + + def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] + """ + Get the EntryPoint in self matching name. + """ + try: + return next(iter(self.select(name=name))) + except StopIteration: + raise KeyError(name) + + def select(self, **params): + """ + Select entry points from self that match the + given parameters (typically group and/or name). + """ + return EntryPoints(ep for ep in self if _py39compat.ep_matches(ep, **params)) + + @property + def names(self) -> Set[str]: + """ + Return the set of all names of all entry points. + """ + return {ep.name for ep in self} + + @property + def groups(self) -> Set[str]: + """ + Return the set of all groups of all entry points. + """ + return {ep.group for ep in self} + + @classmethod + def _from_text_for(cls, text, dist): + return cls(ep._for(dist) for ep in cls._from_text(text)) + + @staticmethod + def _from_text(text): + return ( + EntryPoint(name=item.value.name, value=item.value.value, group=item.name) + for item in Sectioned.section_pairs(text or '') + ) + + +class PackagePath(pathlib.PurePosixPath): + """A reference to a path in a package""" + + hash: Optional["FileHash"] + size: int + dist: "Distribution" + + def read_text(self, encoding: str = 'utf-8') -> str: # type: ignore[override] + with self.locate().open(encoding=encoding) as stream: + return stream.read() + + def read_binary(self) -> bytes: + with self.locate().open('rb') as stream: + return stream.read() + + def locate(self) -> pathlib.Path: + """Return a path-like object for this path""" + return self.dist.locate_file(self) + + +class FileHash: + def __init__(self, spec: str) -> None: + self.mode, _, self.value = spec.partition('=') + + def __repr__(self) -> str: + return f'' + + +class DeprecatedNonAbstract: + def __new__(cls, *args, **kwargs): + all_names = { + name for subclass in inspect.getmro(cls) for name in vars(subclass) + } + abstract = { + name + for name in all_names + if getattr(getattr(cls, name), '__isabstractmethod__', False) + } + if abstract: + warnings.warn( + f"Unimplemented abstract methods {abstract}", + DeprecationWarning, + stacklevel=2, + ) + return super().__new__(cls) + + +class Distribution(DeprecatedNonAbstract): + """A Python distribution package.""" + + @abc.abstractmethod + def read_text(self, filename) -> Optional[str]: + """Attempt to load metadata file given by the name. + + :param filename: The name of the file in the distribution info. + :return: The text if found, otherwise None. + """ + + @abc.abstractmethod + def locate_file(self, path: StrPath) -> pathlib.Path: + """ + Given a path to a file in this distribution, return a path + to it. + """ + + @classmethod + def from_name(cls, name: str) -> "Distribution": + """Return the Distribution for the given package name. + + :param name: The name of the distribution package to search for. + :return: The Distribution instance (or subclass thereof) for the named + package, if found. + :raises PackageNotFoundError: When the named package's distribution + metadata cannot be found. + :raises ValueError: When an invalid value is supplied for name. + """ + if not name: + raise ValueError("A distribution name is required.") + try: + return next(iter(cls.discover(name=name))) + except StopIteration: + raise PackageNotFoundError(name) + + @classmethod + def discover(cls, **kwargs) -> Iterable["Distribution"]: + """Return an iterable of Distribution objects for all packages. + + Pass a ``context`` or pass keyword arguments for constructing + a context. + + :context: A ``DistributionFinder.Context`` object. + :return: Iterable of Distribution objects for all packages. + """ + context = kwargs.pop('context', None) + if context and kwargs: + raise ValueError("cannot accept context and kwargs") + context = context or DistributionFinder.Context(**kwargs) + return itertools.chain.from_iterable( + resolver(context) for resolver in cls._discover_resolvers() + ) + + @staticmethod + def at(path: StrPath) -> "Distribution": + """Return a Distribution for the indicated metadata path + + :param path: a string or path-like object + :return: a concrete Distribution instance for the path + """ + return PathDistribution(pathlib.Path(path)) + + @staticmethod + def _discover_resolvers(): + """Search the meta_path for resolvers.""" + declared = ( + getattr(finder, 'find_distributions', None) for finder in sys.meta_path + ) + return filter(None, declared) + + @property + def metadata(self) -> _meta.PackageMetadata: + """Return the parsed metadata for this Distribution. + + The returned object will have keys that name the various bits of + metadata. See PEP 566 for details. + """ + opt_text = ( + self.read_text('METADATA') + or self.read_text('PKG-INFO') + # This last clause is here to support old egg-info files. Its + # effect is to just end up using the PathDistribution's self._path + # (which points to the egg-info file) attribute unchanged. + or self.read_text('') + ) + text = cast(str, opt_text) + return _adapters.Message(email.message_from_string(text)) + + @property + def name(self) -> str: + """Return the 'Name' metadata for the distribution package.""" + return self.metadata['Name'] + + @property + def _normalized_name(self): + """Return a normalized version of the name.""" + return Prepared.normalize(self.name) + + @property + def version(self) -> str: + """Return the 'Version' metadata for the distribution package.""" + return self.metadata['Version'] + + @property + def entry_points(self) -> EntryPoints: + return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) + + @property + def files(self) -> Optional[List[PackagePath]]: + """Files in this distribution. + + :return: List of PackagePath for this distribution or None + + Result is `None` if the metadata file that enumerates files + (i.e. RECORD for dist-info, or installed-files.txt or + SOURCES.txt for egg-info) is missing. + Result may be empty if the metadata exists but is empty. + """ + + def make_file(name, hash=None, size_str=None): + result = PackagePath(name) + result.hash = FileHash(hash) if hash else None + result.size = int(size_str) if size_str else None + result.dist = self + return result + + @pass_none + def make_files(lines): + return starmap(make_file, csv.reader(lines)) + + @pass_none + def skip_missing_files(package_paths): + return list(filter(lambda path: path.locate().exists(), package_paths)) + + return skip_missing_files( + make_files( + self._read_files_distinfo() + or self._read_files_egginfo_installed() + or self._read_files_egginfo_sources() + ) + ) + + def _read_files_distinfo(self): + """ + Read the lines of RECORD + """ + text = self.read_text('RECORD') + return text and text.splitlines() + + def _read_files_egginfo_installed(self): + """ + Read installed-files.txt and return lines in a similar + CSV-parsable format as RECORD: each file must be placed + relative to the site-packages directory and must also be + quoted (since file names can contain literal commas). + + This file is written when the package is installed by pip, + but it might not be written for other installation methods. + Assume the file is accurate if it exists. + """ + text = self.read_text('installed-files.txt') + # Prepend the .egg-info/ subdir to the lines in this file. + # But this subdir is only available from PathDistribution's + # self._path. + subdir = getattr(self, '_path', None) + if not text or not subdir: + return + + paths = ( + (subdir / name) + .resolve() + .relative_to(self.locate_file('').resolve()) + .as_posix() + for name in text.splitlines() + ) + return map('"{}"'.format, paths) + + def _read_files_egginfo_sources(self): + """ + Read SOURCES.txt and return lines in a similar CSV-parsable + format as RECORD: each file name must be quoted (since it + might contain literal commas). + + Note that SOURCES.txt is not a reliable source for what + files are installed by a package. This file is generated + for a source archive, and the files that are present + there (e.g. setup.py) may not correctly reflect the files + that are present after the package has been installed. + """ + text = self.read_text('SOURCES.txt') + return text and map('"{}"'.format, text.splitlines()) + + @property + def requires(self) -> Optional[List[str]]: + """Generated requirements specified for this Distribution""" + reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() + return reqs and list(reqs) + + def _read_dist_info_reqs(self): + return self.metadata.get_all('Requires-Dist') + + def _read_egg_info_reqs(self): + source = self.read_text('requires.txt') + return pass_none(self._deps_from_requires_text)(source) + + @classmethod + def _deps_from_requires_text(cls, source): + return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source)) + + @staticmethod + def _convert_egg_info_reqs_to_simple_reqs(sections): + """ + Historically, setuptools would solicit and store 'extra' + requirements, including those with environment markers, + in separate sections. More modern tools expect each + dependency to be defined separately, with any relevant + extras and environment markers attached directly to that + requirement. This method converts the former to the + latter. See _test_deps_from_requires_text for an example. + """ + + def make_condition(name): + return name and f'extra == "{name}"' + + def quoted_marker(section): + section = section or '' + extra, sep, markers = section.partition(':') + if extra and markers: + markers = f'({markers})' + conditions = list(filter(None, [markers, make_condition(extra)])) + return '; ' + ' and '.join(conditions) if conditions else '' + + def url_req_space(req): + """ + PEP 508 requires a space between the url_spec and the quoted_marker. + Ref python/importlib_metadata#357. + """ + # '@' is uniquely indicative of a url_req. + return ' ' * ('@' in req) + + for section in sections: + space = url_req_space(section.value) + yield section.value + space + quoted_marker(section.name) + + +class DistributionFinder(MetaPathFinder): + """ + A MetaPathFinder capable of discovering installed distributions. + """ + + class Context: + """ + Keyword arguments presented by the caller to + ``distributions()`` or ``Distribution.discover()`` + to narrow the scope of a search for distributions + in all DistributionFinders. + + Each DistributionFinder may expect any parameters + and should attempt to honor the canonical + parameters defined below when appropriate. + """ + + name = None + """ + Specific name for which a distribution finder should match. + A name of ``None`` matches all distributions. + """ + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + @property + def path(self) -> List[str]: + """ + The sequence of directory path that a distribution finder + should search. + + Typically refers to Python installed package paths such as + "site-packages" directories and defaults to ``sys.path``. + """ + return vars(self).get('path', sys.path) + + @abc.abstractmethod + def find_distributions(self, context=Context()) -> Iterable[Distribution]: + """ + Find distributions. + + Return an iterable of all Distribution instances capable of + loading the metadata for packages matching the ``context``, + a DistributionFinder.Context instance. + """ + + +class FastPath: + """ + Micro-optimized class for searching a path for + children. + + >>> FastPath('').children() + ['...'] + """ + + @functools.lru_cache() # type: ignore + def __new__(cls, root): + return super().__new__(cls) + + def __init__(self, root): + self.root = root + + def joinpath(self, child): + return pathlib.Path(self.root, child) + + def children(self): + with suppress(Exception): + return os.listdir(self.root or '.') + with suppress(Exception): + return self.zip_children() + return [] + + def zip_children(self): + zip_path = zipp.Path(self.root) + names = zip_path.root.namelist() + self.joinpath = zip_path.joinpath + + return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) + + def search(self, name): + return self.lookup(self.mtime).search(name) + + @property + def mtime(self): + with suppress(OSError): + return os.stat(self.root).st_mtime + self.lookup.cache_clear() + + @method_cache + def lookup(self, mtime): + return Lookup(self) + + +class Lookup: + def __init__(self, path: FastPath): + base = os.path.basename(path.root).lower() + base_is_egg = base.endswith(".egg") + self.infos = FreezableDefaultDict(list) + self.eggs = FreezableDefaultDict(list) + + for child in path.children(): + low = child.lower() + if low.endswith((".dist-info", ".egg-info")): + # rpartition is faster than splitext and suitable for this purpose. + name = low.rpartition(".")[0].partition("-")[0] + normalized = Prepared.normalize(name) + self.infos[normalized].append(path.joinpath(child)) + elif base_is_egg and low == "egg-info": + name = base.rpartition(".")[0].partition("-")[0] + legacy_normalized = Prepared.legacy_normalize(name) + self.eggs[legacy_normalized].append(path.joinpath(child)) + + self.infos.freeze() + self.eggs.freeze() + + def search(self, prepared): + infos = ( + self.infos[prepared.normalized] + if prepared + else itertools.chain.from_iterable(self.infos.values()) + ) + eggs = ( + self.eggs[prepared.legacy_normalized] + if prepared + else itertools.chain.from_iterable(self.eggs.values()) + ) + return itertools.chain(infos, eggs) + + +class Prepared: + """ + A prepared search for metadata on a possibly-named package. + """ + + normalized = None + legacy_normalized = None + + def __init__(self, name): + self.name = name + if name is None: + return + self.normalized = self.normalize(name) + self.legacy_normalized = self.legacy_normalize(name) + + @staticmethod + def normalize(name): + """ + PEP 503 normalization plus dashes as underscores. + """ + return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') + + @staticmethod + def legacy_normalize(name): + """ + Normalize the package name as found in the convention in + older packaging tools versions and specs. + """ + return name.lower().replace('-', '_') + + def __bool__(self): + return bool(self.name) + + +@install +class MetadataPathFinder(NullFinder, DistributionFinder): + """A degenerate finder for distribution packages on the file system. + + This finder supplies only a find_distributions() method for versions + of Python that do not have a PathFinder find_distributions(). + """ + + def find_distributions( + self, context=DistributionFinder.Context() + ) -> Iterable["PathDistribution"]: + """ + Find distributions. + + Return an iterable of all Distribution instances capable of + loading the metadata for packages matching ``context.name`` + (or all names if ``None`` indicated) along the paths in the list + of directories ``context.path``. + """ + found = self._search_paths(context.name, context.path) + return map(PathDistribution, found) + + @classmethod + def _search_paths(cls, name, paths): + """Find metadata directories in paths heuristically.""" + prepared = Prepared(name) + return itertools.chain.from_iterable( + path.search(prepared) for path in map(FastPath, paths) + ) + + def invalidate_caches(cls) -> None: + FastPath.__new__.cache_clear() + + +class PathDistribution(Distribution): + def __init__(self, path: SimplePath) -> None: + """Construct a distribution. + + :param path: SimplePath indicating the metadata directory. + """ + self._path = path + + def read_text(self, filename: StrPath) -> Optional[str]: + with suppress( + FileNotFoundError, + IsADirectoryError, + KeyError, + NotADirectoryError, + PermissionError, + ): + return self._path.joinpath(filename).read_text(encoding='utf-8') + + return None + + read_text.__doc__ = Distribution.read_text.__doc__ + + def locate_file(self, path: StrPath) -> pathlib.Path: + return self._path.parent / path + + @property + def _normalized_name(self): + """ + Performance optimization: where possible, resolve the + normalized name from the file system path. + """ + stem = os.path.basename(str(self._path)) + return ( + pass_none(Prepared.normalize)(self._name_from_stem(stem)) + or super()._normalized_name + ) + + @staticmethod + def _name_from_stem(stem): + """ + >>> PathDistribution._name_from_stem('foo-3.0.egg-info') + 'foo' + >>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info') + 'CherryPy' + >>> PathDistribution._name_from_stem('face.egg-info') + 'face' + >>> PathDistribution._name_from_stem('foo.bar') + """ + filename, ext = os.path.splitext(stem) + if ext not in ('.dist-info', '.egg-info'): + return + name, sep, rest = filename.partition('-') + return name + + +def distribution(distribution_name: str) -> Distribution: + """Get the ``Distribution`` instance for the named package. + + :param distribution_name: The name of the distribution package as a string. + :return: A ``Distribution`` instance (or subclass thereof). + """ + return Distribution.from_name(distribution_name) + + +def distributions(**kwargs) -> Iterable[Distribution]: + """Get all ``Distribution`` instances in the current environment. + + :return: An iterable of ``Distribution`` instances. + """ + return Distribution.discover(**kwargs) + + +def metadata(distribution_name: str) -> _meta.PackageMetadata: + """Get the metadata for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: A PackageMetadata containing the parsed metadata. + """ + return Distribution.from_name(distribution_name).metadata + + +def version(distribution_name: str) -> str: + """Get the version string for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: The version string for the package as defined in the package's + "Version" metadata key. + """ + return distribution(distribution_name).version + + +_unique = functools.partial( + unique_everseen, + key=_py39compat.normalized_name, +) +""" +Wrapper for ``distributions`` to return unique distributions by name. +""" + + +def entry_points(**params) -> EntryPoints: + """Return EntryPoint objects for all installed packages. + + Pass selection parameters (group or name) to filter the + result to entry points matching those properties (see + EntryPoints.select()). + + :return: EntryPoints for all installed packages. + """ + eps = itertools.chain.from_iterable( + dist.entry_points for dist in _unique(distributions()) + ) + return EntryPoints(eps).select(**params) + + +def files(distribution_name: str) -> Optional[List[PackagePath]]: + """Return a list of files for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: List of files composing the distribution. + """ + return distribution(distribution_name).files + + +def requires(distribution_name: str) -> Optional[List[str]]: + """ + Return a list of requirements for the named package. + + :return: An iterable of requirements, suitable for + packaging.requirement.Requirement. + """ + return distribution(distribution_name).requires + + +def packages_distributions() -> Mapping[str, List[str]]: + """ + Return a mapping of top-level packages to their + distributions. + + >>> import collections.abc + >>> pkgs = packages_distributions() + >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values()) + True + """ + pkg_to_dist = collections.defaultdict(list) + for dist in distributions(): + for pkg in _top_level_declared(dist) or _top_level_inferred(dist): + pkg_to_dist[pkg].append(dist.metadata['Name']) + return dict(pkg_to_dist) + + +def _top_level_declared(dist): + return (dist.read_text('top_level.txt') or '').split() + + +def _topmost(name: PackagePath) -> Optional[str]: + """ + Return the top-most parent as long as there is a parent. + """ + top, *rest = name.parts + return top if rest else None + + +def _get_toplevel_name(name: PackagePath) -> str: + """ + Infer a possibly importable module name from a name presumed on + sys.path. + + >>> _get_toplevel_name(PackagePath('foo.py')) + 'foo' + >>> _get_toplevel_name(PackagePath('foo')) + 'foo' + >>> _get_toplevel_name(PackagePath('foo.pyc')) + 'foo' + >>> _get_toplevel_name(PackagePath('foo/__init__.py')) + 'foo' + >>> _get_toplevel_name(PackagePath('foo.pth')) + 'foo.pth' + >>> _get_toplevel_name(PackagePath('foo.dist-info')) + 'foo.dist-info' + """ + return _topmost(name) or ( + # python/typeshed#10328 + inspect.getmodulename(name) # type: ignore + or str(name) + ) + + +def _top_level_inferred(dist): + opt_names = set(map(_get_toplevel_name, always_iterable(dist.files))) + + def importable_name(name): + return '.' not in name + + return filter(importable_name, opt_names) diff --git a/server/libs/importlib_metadata/_adapters.py b/server/libs/importlib_metadata/_adapters.py new file mode 100644 index 0000000..e33cba5 --- /dev/null +++ b/server/libs/importlib_metadata/_adapters.py @@ -0,0 +1,90 @@ +import functools +import warnings +import re +import textwrap +import email.message + +from ._text import FoldedCase +from ._compat import pypy_partial + + +# Do not remove prior to 2024-01-01 or Python 3.14 +_warn = functools.partial( + warnings.warn, + "Implicit None on return values is deprecated and will raise KeyErrors.", + DeprecationWarning, + stacklevel=pypy_partial(2), +) + + +class Message(email.message.Message): + multiple_use_keys = set( + map( + FoldedCase, + [ + 'Classifier', + 'Obsoletes-Dist', + 'Platform', + 'Project-URL', + 'Provides-Dist', + 'Provides-Extra', + 'Requires-Dist', + 'Requires-External', + 'Supported-Platform', + 'Dynamic', + ], + ) + ) + """ + Keys that may be indicated multiple times per PEP 566. + """ + + def __new__(cls, orig: email.message.Message): + res = super().__new__(cls) + vars(res).update(vars(orig)) + return res + + def __init__(self, *args, **kwargs): + self._headers = self._repair_headers() + + # suppress spurious error from mypy + def __iter__(self): + return super().__iter__() + + def __getitem__(self, item): + """ + Warn users that a ``KeyError`` can be expected when a + mising key is supplied. Ref python/importlib_metadata#371. + """ + res = super().__getitem__(item) + if res is None: + _warn() + return res + + def _repair_headers(self): + def redent(value): + "Correct for RFC822 indentation" + if not value or '\n' not in value: + return value + return textwrap.dedent(' ' * 8 + value) + + headers = [(key, redent(value)) for key, value in vars(self)['_headers']] + if self._payload: + headers.append(('Description', self.get_payload())) + return headers + + @property + def json(self): + """ + Convert PackageMetadata to a JSON-compatible format + per PEP 0566. + """ + + def transform(key): + value = self.get_all(key) if key in self.multiple_use_keys else self[key] + if key == 'Keywords': + value = re.split(r'\s+', value) + tk = key.lower().replace('-', '_') + return tk, value + + return dict(map(transform, map(FoldedCase, self))) diff --git a/server/libs/importlib_metadata/_collections.py b/server/libs/importlib_metadata/_collections.py new file mode 100644 index 0000000..cf0954e --- /dev/null +++ b/server/libs/importlib_metadata/_collections.py @@ -0,0 +1,30 @@ +import collections + + +# from jaraco.collections 3.3 +class FreezableDefaultDict(collections.defaultdict): + """ + Often it is desirable to prevent the mutation of + a default dict after its initial construction, such + as to prevent mutation during iteration. + + >>> dd = FreezableDefaultDict(list) + >>> dd[0].append('1') + >>> dd.freeze() + >>> dd[1] + [] + >>> len(dd) + 1 + """ + + def __missing__(self, key): + return getattr(self, '_frozen', super().__missing__)(key) + + def freeze(self): + self._frozen = lambda key: self.default_factory() + + +class Pair(collections.namedtuple('Pair', 'name value')): + @classmethod + def parse(cls, text): + return cls(*map(str.strip, text.split("=", 1))) diff --git a/server/libs/importlib_metadata/_compat.py b/server/libs/importlib_metadata/_compat.py new file mode 100644 index 0000000..c0f15c7 --- /dev/null +++ b/server/libs/importlib_metadata/_compat.py @@ -0,0 +1,67 @@ +import os +import sys +import platform + +from typing import Union + + +__all__ = ['install', 'NullFinder'] + + +def install(cls): + """ + Class decorator for installation on sys.meta_path. + + Adds the backport DistributionFinder to sys.meta_path and + attempts to disable the finder functionality of the stdlib + DistributionFinder. + """ + sys.meta_path.append(cls()) + disable_stdlib_finder() + return cls + + +def disable_stdlib_finder(): + """ + Give the backport primacy for discovering path-based distributions + by monkey-patching the stdlib O_O. + + See #91 for more background for rationale on this sketchy + behavior. + """ + + def matches(finder): + return getattr( + finder, '__module__', None + ) == '_frozen_importlib_external' and hasattr(finder, 'find_distributions') + + for finder in filter(matches, sys.meta_path): # pragma: nocover + del finder.find_distributions + + +class NullFinder: + """ + A "Finder" (aka "MetaClassFinder") that never finds any modules, + but may find distributions. + """ + + @staticmethod + def find_spec(*args, **kwargs): + return None + + +def pypy_partial(val): + """ + Adjust for variable stacklevel on partial under PyPy. + + Workaround for #327. + """ + is_pypy = platform.python_implementation() == 'PyPy' + return val + is_pypy + + +if sys.version_info >= (3, 9): + StrPath = Union[str, os.PathLike[str]] +else: + # PathLike is only subscriptable at runtime in 3.9+ + StrPath = Union[str, "os.PathLike[str]"] # pragma: no cover diff --git a/server/libs/importlib_metadata/_functools.py b/server/libs/importlib_metadata/_functools.py new file mode 100644 index 0000000..71f66bd --- /dev/null +++ b/server/libs/importlib_metadata/_functools.py @@ -0,0 +1,104 @@ +import types +import functools + + +# from jaraco.functools 3.3 +def method_cache(method, cache_wrapper=None): + """ + Wrap lru_cache to support storing the cache data in the object instances. + + Abstracts the common paradigm where the method explicitly saves an + underscore-prefixed protected property on first call and returns that + subsequently. + + >>> class MyClass: + ... calls = 0 + ... + ... @method_cache + ... def method(self, value): + ... self.calls += 1 + ... return value + + >>> a = MyClass() + >>> a.method(3) + 3 + >>> for x in range(75): + ... res = a.method(x) + >>> a.calls + 75 + + Note that the apparent behavior will be exactly like that of lru_cache + except that the cache is stored on each instance, so values in one + instance will not flush values from another, and when an instance is + deleted, so are the cached values for that instance. + + >>> b = MyClass() + >>> for x in range(35): + ... res = b.method(x) + >>> b.calls + 35 + >>> a.method(0) + 0 + >>> a.calls + 75 + + Note that if method had been decorated with ``functools.lru_cache()``, + a.calls would have been 76 (due to the cached value of 0 having been + flushed by the 'b' instance). + + Clear the cache with ``.cache_clear()`` + + >>> a.method.cache_clear() + + Same for a method that hasn't yet been called. + + >>> c = MyClass() + >>> c.method.cache_clear() + + Another cache wrapper may be supplied: + + >>> cache = functools.lru_cache(maxsize=2) + >>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache) + >>> a = MyClass() + >>> a.method2() + 3 + + Caution - do not subsequently wrap the method with another decorator, such + as ``@property``, which changes the semantics of the function. + + See also + http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/ + for another implementation and additional justification. + """ + cache_wrapper = cache_wrapper or functools.lru_cache() + + def wrapper(self, *args, **kwargs): + # it's the first call, replace the method with a cached, bound method + bound_method = types.MethodType(method, self) + cached_method = cache_wrapper(bound_method) + setattr(self, method.__name__, cached_method) + return cached_method(*args, **kwargs) + + # Support cache clear even before cache has been created. + wrapper.cache_clear = lambda: None + + return wrapper + + +# From jaraco.functools 3.3 +def pass_none(func): + """ + Wrap func so it's not called if its first param is None + + >>> print_text = pass_none(print) + >>> print_text('text') + text + >>> print_text(None) + """ + + @functools.wraps(func) + def wrapper(param, *args, **kwargs): + if param is not None: + return func(param, *args, **kwargs) + + return wrapper diff --git a/server/libs/importlib_metadata/_itertools.py b/server/libs/importlib_metadata/_itertools.py new file mode 100644 index 0000000..d4ca9b9 --- /dev/null +++ b/server/libs/importlib_metadata/_itertools.py @@ -0,0 +1,73 @@ +from itertools import filterfalse + + +def unique_everseen(iterable, key=None): + "List unique elements, preserving order. Remember all elements ever seen." + # unique_everseen('AAAABBBCCDAABBB') --> A B C D + # unique_everseen('ABBCcAD', str.lower) --> A B C D + seen = set() + seen_add = seen.add + if key is None: + for element in filterfalse(seen.__contains__, iterable): + seen_add(element) + yield element + else: + for element in iterable: + k = key(element) + if k not in seen: + seen_add(k) + yield element + + +# copied from more_itertools 8.8 +def always_iterable(obj, base_type=(str, bytes)): + """If *obj* is iterable, return an iterator over its items:: + + >>> obj = (1, 2, 3) + >>> list(always_iterable(obj)) + [1, 2, 3] + + If *obj* is not iterable, return a one-item iterable containing *obj*:: + + >>> obj = 1 + >>> list(always_iterable(obj)) + [1] + + If *obj* is ``None``, return an empty iterable: + + >>> obj = None + >>> list(always_iterable(None)) + [] + + By default, binary and text strings are not considered iterable:: + + >>> obj = 'foo' + >>> list(always_iterable(obj)) + ['foo'] + + If *base_type* is set, objects for which ``isinstance(obj, base_type)`` + returns ``True`` won't be considered iterable. + + >>> obj = {'a': 1} + >>> list(always_iterable(obj)) # Iterate over the dict's keys + ['a'] + >>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit + [{'a': 1}] + + Set *base_type* to ``None`` to avoid any special handling and treat objects + Python considers iterable as iterable: + + >>> obj = 'foo' + >>> list(always_iterable(obj, base_type=None)) + ['f', 'o', 'o'] + """ + if obj is None: + return iter(()) + + if (base_type is not None) and isinstance(obj, base_type): + return iter((obj,)) + + try: + return iter(obj) + except TypeError: + return iter((obj,)) diff --git a/server/libs/importlib_metadata/_meta.py b/server/libs/importlib_metadata/_meta.py new file mode 100644 index 0000000..f670016 --- /dev/null +++ b/server/libs/importlib_metadata/_meta.py @@ -0,0 +1,63 @@ +from typing import Protocol +from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload + + +_T = TypeVar("_T") + + +class PackageMetadata(Protocol): + def __len__(self) -> int: + ... # pragma: no cover + + def __contains__(self, item: str) -> bool: + ... # pragma: no cover + + def __getitem__(self, key: str) -> str: + ... # pragma: no cover + + def __iter__(self) -> Iterator[str]: + ... # pragma: no cover + + @overload + def get(self, name: str, failobj: None = None) -> Optional[str]: + ... # pragma: no cover + + @overload + def get(self, name: str, failobj: _T) -> Union[str, _T]: + ... # pragma: no cover + + # overload per python/importlib_metadata#435 + @overload + def get_all(self, name: str, failobj: None = None) -> Optional[List[Any]]: + ... # pragma: no cover + + @overload + def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]: + """ + Return all values associated with a possibly multi-valued key. + """ + + @property + def json(self) -> Dict[str, Union[str, List[str]]]: + """ + A JSON-compatible form of the metadata. + """ + + +class SimplePath(Protocol[_T]): + """ + A minimal subset of pathlib.Path required by PathDistribution. + """ + + def joinpath(self, other: Union[str, _T]) -> _T: + ... # pragma: no cover + + def __truediv__(self, other: Union[str, _T]) -> _T: + ... # pragma: no cover + + @property + def parent(self) -> _T: + ... # pragma: no cover + + def read_text(self) -> str: + ... # pragma: no cover diff --git a/server/libs/importlib_metadata/_py39compat.py b/server/libs/importlib_metadata/_py39compat.py new file mode 100644 index 0000000..cde4558 --- /dev/null +++ b/server/libs/importlib_metadata/_py39compat.py @@ -0,0 +1,35 @@ +""" +Compatibility layer with Python 3.8/3.9 +""" +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: # pragma: no cover + # Prevent circular imports on runtime. + from . import Distribution, EntryPoint +else: + Distribution = EntryPoint = Any + + +def normalized_name(dist: Distribution) -> Optional[str]: + """ + Honor name normalization for distributions that don't provide ``_normalized_name``. + """ + try: + return dist._normalized_name + except AttributeError: + from . import Prepared # -> delay to prevent circular imports. + + return Prepared.normalize(getattr(dist, "name", None) or dist.metadata['Name']) + + +def ep_matches(ep: EntryPoint, **params) -> bool: + """ + Workaround for ``EntryPoint`` objects without the ``matches`` method. + """ + try: + return ep.matches(**params) + except AttributeError: + from . import EntryPoint # -> delay to prevent circular imports. + + # Reconstruct the EntryPoint object to make sure it is compatible. + return EntryPoint(ep.name, ep.value, ep.group).matches(**params) diff --git a/server/libs/importlib_metadata/_text.py b/server/libs/importlib_metadata/_text.py new file mode 100644 index 0000000..c88cfbb --- /dev/null +++ b/server/libs/importlib_metadata/_text.py @@ -0,0 +1,99 @@ +import re + +from ._functools import method_cache + + +# from jaraco.text 3.5 +class FoldedCase(str): + """ + A case insensitive string class; behaves just like str + except compares equal when the only variation is case. + + >>> s = FoldedCase('hello world') + + >>> s == 'Hello World' + True + + >>> 'Hello World' == s + True + + >>> s != 'Hello World' + False + + >>> s.index('O') + 4 + + >>> s.split('O') + ['hell', ' w', 'rld'] + + >>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta'])) + ['alpha', 'Beta', 'GAMMA'] + + Sequence membership is straightforward. + + >>> "Hello World" in [s] + True + >>> s in ["Hello World"] + True + + You may test for set inclusion, but candidate and elements + must both be folded. + + >>> FoldedCase("Hello World") in {s} + True + >>> s in {FoldedCase("Hello World")} + True + + String inclusion works as long as the FoldedCase object + is on the right. + + >>> "hello" in FoldedCase("Hello World") + True + + But not if the FoldedCase object is on the left: + + >>> FoldedCase('hello') in 'Hello World' + False + + In that case, use in_: + + >>> FoldedCase('hello').in_('Hello World') + True + + >>> FoldedCase('hello') > FoldedCase('Hello') + False + """ + + def __lt__(self, other): + return self.lower() < other.lower() + + def __gt__(self, other): + return self.lower() > other.lower() + + def __eq__(self, other): + return self.lower() == other.lower() + + def __ne__(self, other): + return self.lower() != other.lower() + + def __hash__(self): + return hash(self.lower()) + + def __contains__(self, other): + return super().lower().__contains__(other.lower()) + + def in_(self, other): + "Does self appear in other?" + return self in FoldedCase(other) + + # cache lower since it's likely to be called frequently. + @method_cache + def lower(self): + return super().lower() + + def index(self, sub): + return self.lower().index(sub.lower()) + + def split(self, splitter=' ', maxsplit=0): + pattern = re.compile(re.escape(splitter), re.I) + return pattern.split(self, maxsplit) diff --git a/server/libs/lark/py.typed b/server/libs/importlib_metadata/py.typed similarity index 100% rename from server/libs/lark/py.typed rename to server/libs/importlib_metadata/py.typed diff --git a/server/libs/lark-1.2.2.dist-info/LICENSE b/server/libs/lark-1.2.2.dist-info/LICENSE deleted file mode 100644 index aaf210b..0000000 --- a/server/libs/lark-1.2.2.dist-info/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -Copyright © 2017 Erez Shinan - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/libs/lark-1.2.2.dist-info/METADATA b/server/libs/lark-1.2.2.dist-info/METADATA deleted file mode 100644 index 6d5f341..0000000 --- a/server/libs/lark-1.2.2.dist-info/METADATA +++ /dev/null @@ -1,47 +0,0 @@ -Metadata-Version: 2.1 -Name: lark -Version: 1.2.2 -Summary: a modern parsing library -Author-email: Erez Shinan -License: MIT -Project-URL: Homepage, https://github.com/lark-parser/lark -Project-URL: Download, https://github.com/lark-parser/lark/tarball/master -Keywords: Earley,LALR,parser,parsing,ast -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: Programming Language :: Python :: 3 -Classifier: Topic :: Software Development :: Libraries :: Python Modules -Classifier: Topic :: Text Processing :: General -Classifier: Topic :: Text Processing :: Linguistic -Classifier: License :: OSI Approved :: MIT License -Requires-Python: >=3.8 -Description-Content-Type: text/markdown -License-File: LICENSE -Provides-Extra: atomic_cache -Requires-Dist: atomicwrites ; extra == 'atomic_cache' -Provides-Extra: interegular -Requires-Dist: interegular <0.4.0,>=0.3.1 ; extra == 'interegular' -Provides-Extra: nearley -Requires-Dist: js2py ; extra == 'nearley' -Provides-Extra: regex -Requires-Dist: regex ; extra == 'regex' - -Lark is a modern general-purpose parsing library for Python. -With Lark, you can parse any context-free grammar, efficiently, with very little code. -Main Features: -- Builds a parse-tree (AST) automagically, based on the structure of the grammar -- Earley parser -- Can parse all context-free grammars -- Full support for ambiguous grammars -- LALR(1) parser -- Fast and light, competitive with PLY -- Can generate a stand-alone parser -- CYK parser, for highly ambiguous grammars -- EBNF grammar -- Unicode fully supported -- Automatic line & column tracking -- Standard library of terminals (strings, numbers, names, etc.) -- Import grammars from Nearley.js -- Extensive test suite -- And much more! -Since version 1.2, only Python versions 3.8 and up are supported. diff --git a/server/libs/lark-1.2.2.dist-info/RECORD b/server/libs/lark-1.2.2.dist-info/RECORD deleted file mode 100644 index 0ca7133..0000000 --- a/server/libs/lark-1.2.2.dist-info/RECORD +++ /dev/null @@ -1,83 +0,0 @@ -lark-1.2.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -lark-1.2.2.dist-info/LICENSE,sha256=Lu5g9S1OETV7-J5ysDTQUOKF5H_aE2HlZi-zIu4n13E,1055 -lark-1.2.2.dist-info/METADATA,sha256=S-69HuNJr0ktlvb7J5XE48ghb_6ahYn8ksdW9HcB-d0,1831 -lark-1.2.2.dist-info/RECORD,, -lark-1.2.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -lark-1.2.2.dist-info/WHEEL,sha256=HiCZjzuy6Dw0hdX5R3LCFPDmFS4BWl8H-8W39XfmgX4,91 -lark-1.2.2.dist-info/entry_points.txt,sha256=WXYg_uCUdFlxQDPUhli3HFah37bNNFQfXLdzCqsacGI,61 -lark-1.2.2.dist-info/top_level.txt,sha256=dyS6jg8hCHHkXWvsfcIMO8rjlv_bdzAxiE0lkkzJ5hk,5 -lark/__init__.py,sha256=bc0tK7h7XwHA-Y4vVeJoNIqSMA-MHVTihq8yy795WXo,744 -lark/__pycache__/__init__.cpython-311.pyc,, -lark/__pycache__/ast_utils.cpython-311.pyc,, -lark/__pycache__/common.cpython-311.pyc,, -lark/__pycache__/exceptions.cpython-311.pyc,, -lark/__pycache__/grammar.cpython-311.pyc,, -lark/__pycache__/indenter.cpython-311.pyc,, -lark/__pycache__/lark.cpython-311.pyc,, -lark/__pycache__/lexer.cpython-311.pyc,, -lark/__pycache__/load_grammar.cpython-311.pyc,, -lark/__pycache__/parse_tree_builder.cpython-311.pyc,, -lark/__pycache__/parser_frontends.cpython-311.pyc,, -lark/__pycache__/reconstruct.cpython-311.pyc,, -lark/__pycache__/tree.cpython-311.pyc,, -lark/__pycache__/tree_matcher.cpython-311.pyc,, -lark/__pycache__/tree_templates.cpython-311.pyc,, -lark/__pycache__/utils.cpython-311.pyc,, -lark/__pycache__/visitors.cpython-311.pyc,, -lark/__pyinstaller/__init__.py,sha256=_PpFm44f_mwHlCpvYgv9ZgubLfNDc3PlePVir4sxRfI,182 -lark/__pyinstaller/__pycache__/__init__.cpython-311.pyc,, -lark/__pyinstaller/__pycache__/hook-lark.cpython-311.pyc,, -lark/__pyinstaller/hook-lark.py,sha256=5aFHiZWVHPRdHT8qnb4kW4JSOql5GusHodHR25_q9sU,599 -lark/ast_utils.py,sha256=jwn44ocNQhZGbfcFsEZnwi_gGvPbNgzjQ-0RuEtwDzI,2117 -lark/common.py,sha256=M9-CFAUP3--OkftyyWjke-Kc1-pQMczT1MluHCFwdy4,3008 -lark/exceptions.py,sha256=g76ygMPfSMl6ukKqFAZVpR2EAJTOOdyfJ_ALXc_MCR8,10939 -lark/grammar.py,sha256=DR17QSLSKCRhMOqx2UQh4n-Ywu4CD-wjdQxtuM8OHkY,3665 -lark/grammars/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -lark/grammars/__pycache__/__init__.cpython-311.pyc,, -lark/grammars/common.lark,sha256=FV9xGIPiPqHRM4ULAxP6jApXRTVsSwbOe697I9s7DLs,885 -lark/grammars/lark.lark,sha256=nq1NTZYqm_DPI2mjRIlpd3ZcxPjGhapA4GUzkcfBTQs,1541 -lark/grammars/python.lark,sha256=WMakTkpzCqOd0jUjYONI3LOnSy2KRN9NoL9pFtAZYCI,10641 -lark/grammars/unicode.lark,sha256=d9YCz0XWimdl4F8M5YCptavBcFG9D58Yd4aMwxjYtEI,96 -lark/indenter.py,sha256=L5uNDYUMNrk4ZTWKmW0Tu-H-3GGErLOHygMC32N_twE,4221 -lark/lark.py,sha256=_IHWmTxt43kfd9eYVtwx58zEWWSFAq9_gKH7Oeu5PZs,28184 -lark/lexer.py,sha256=OwgQPCpQ-vUi-2aeZztsydd4DLkEgCbZeucvEPvHFi4,24037 -lark/load_grammar.py,sha256=WYZDxyO6omhA8NKyMjSckfAMwVKuIMF3liiYXE_-kHo,53946 -lark/parse_tree_builder.py,sha256=jT_3gCEkBGZoTXAWSnhMn1kRuJILWB-E7XkUciYNHI4,14412 -lark/parser_frontends.py,sha256=mxMXxux2hkfTfE859wuVp4-Fr1no6YVEUt8toDjEdPQ,10165 -lark/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -lark/parsers/__pycache__/__init__.cpython-311.pyc,, -lark/parsers/__pycache__/cyk.cpython-311.pyc,, -lark/parsers/__pycache__/earley.cpython-311.pyc,, -lark/parsers/__pycache__/earley_common.cpython-311.pyc,, -lark/parsers/__pycache__/earley_forest.cpython-311.pyc,, -lark/parsers/__pycache__/grammar_analysis.cpython-311.pyc,, -lark/parsers/__pycache__/lalr_analysis.cpython-311.pyc,, -lark/parsers/__pycache__/lalr_interactive_parser.cpython-311.pyc,, -lark/parsers/__pycache__/lalr_parser.cpython-311.pyc,, -lark/parsers/__pycache__/lalr_parser_state.cpython-311.pyc,, -lark/parsers/__pycache__/xearley.cpython-311.pyc,, -lark/parsers/cyk.py,sha256=c3GLk3kq23Xwb8MqUOjvivwP488KJY6NUWgxqeR5980,12192 -lark/parsers/earley.py,sha256=03sW9vfBkcH4NR72EBt8HkndDKSVSH3IdRnDulXWy24,15117 -lark/parsers/earley_common.py,sha256=e2e6NrNucw-WMiNV8HqQ_TpGx6P7v_S8f5aEcF0Tkqo,1620 -lark/parsers/earley_forest.py,sha256=w4JTb4tVMewue8dL-gCO96-Uo0wd4BbQUfSfIhr7txY,31332 -lark/parsers/grammar_analysis.py,sha256=rQ4Sn9EP8gjXGTZXEiWLW0KByPPpeKpN5hSIQZgNl3I,7141 -lark/parsers/lalr_analysis.py,sha256=DGHFk2tIluIyeFEVFfsMRU77DVbd598IJnUUOXO04yo,12207 -lark/parsers/lalr_interactive_parser.py,sha256=LsgfT1gdne8pXHTCsN6bl6zD6Pdh2dDqp1rIWOzp7Yw,5757 -lark/parsers/lalr_parser.py,sha256=6U8jP1AlUsuGxgJBWMq15WuGuyaolsLPevcf8HZ_zZk,4586 -lark/parsers/lalr_parser_state.py,sha256=QZ12p4CtvcvFAIKIqkeDBJYgEU3ntQllBJDYXb419ls,3793 -lark/parsers/xearley.py,sha256=DboXMNtuN0G-SXrrDm5zgUDUekz85h0Rih2PRvcf1LM,7825 -lark/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -lark/reconstruct.py,sha256=s7CevBXchUG_fe2otdAITxIaSXCEIiSjy4Sbh5QC0hs,3763 -lark/tools/__init__.py,sha256=FeKYmVUjXSt-vlQm2ktyWkcxaOCTOkZnHD_kOUWjUuA,2469 -lark/tools/__pycache__/__init__.cpython-311.pyc,, -lark/tools/__pycache__/nearley.cpython-311.pyc,, -lark/tools/__pycache__/serialize.cpython-311.pyc,, -lark/tools/__pycache__/standalone.cpython-311.pyc,, -lark/tools/nearley.py,sha256=QaLYdW6mYQdDq8JKMisV3lvPqzF0wPgu8q8BtsSA33g,6265 -lark/tools/serialize.py,sha256=nwt46LNxkDm0T_Uh9k2wS4fcfgvZQ2dy4-YC_aKhTQk,965 -lark/tools/standalone.py,sha256=6eXDqBuzZSpE5BGZm_Fh6X5yRhAPYxNVyl2aUU3ABzA,5627 -lark/tree.py,sha256=aWWHMazid8bbJanhmCjK9XK2jRFJ6N6WmlwXJGTsz28,8522 -lark/tree_matcher.py,sha256=jHdZJggn405SXmPpGf9U9HLrrsfP4eNNZaj267UTB00,6003 -lark/tree_templates.py,sha256=sSnfw1m8txAkJOYhcQrooG7xajVyVplunzTnNsxY720,6139 -lark/utils.py,sha256=3qd1-c0YgHYklvx1hA28qF7N_Ty1Zz6TbtCFMzQanNk,11270 -lark/visitors.py,sha256=VJ3T1m8p78MwXJotpOAvn06mYEqKyuIlhsAF51U-a3w,21422 diff --git a/server/libs/lark-1.2.2.dist-info/entry_points.txt b/server/libs/lark-1.2.2.dist-info/entry_points.txt deleted file mode 100644 index ec317d7..0000000 --- a/server/libs/lark-1.2.2.dist-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[pyinstaller40] -hook-dirs = lark.__pyinstaller:get_hook_dirs diff --git a/server/libs/lark-1.2.2.dist-info/top_level.txt b/server/libs/lark-1.2.2.dist-info/top_level.txt deleted file mode 100644 index bc30e96..0000000 --- a/server/libs/lark-1.2.2.dist-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -lark diff --git a/server/libs/lark/__init__.py b/server/libs/lark/__init__.py deleted file mode 100644 index d22cc2d..0000000 --- a/server/libs/lark/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -from .exceptions import ( - GrammarError, - LarkError, - LexError, - ParseError, - UnexpectedCharacters, - UnexpectedEOF, - UnexpectedInput, - UnexpectedToken, -) -from .lark import Lark -from .lexer import Token -from .tree import ParseTree, Tree -from .utils import logger -from .visitors import Discard, Transformer, Transformer_NonRecursive, Visitor, v_args - -__version__: str = "1.2.2" - -__all__ = ( - "GrammarError", - "LarkError", - "LexError", - "ParseError", - "UnexpectedCharacters", - "UnexpectedEOF", - "UnexpectedInput", - "UnexpectedToken", - "Lark", - "Token", - "ParseTree", - "Tree", - "logger", - "Discard", - "Transformer", - "Transformer_NonRecursive", - "Visitor", - "v_args", -) diff --git a/server/libs/lark/__pyinstaller/__init__.py b/server/libs/lark/__pyinstaller/__init__.py deleted file mode 100644 index 9da62a3..0000000 --- a/server/libs/lark/__pyinstaller/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# For usage of lark with PyInstaller. See https://pyinstaller-sample-hook.readthedocs.io/en/latest/index.html - -import os - -def get_hook_dirs(): - return [os.path.dirname(__file__)] diff --git a/server/libs/lark/__pyinstaller/hook-lark.py b/server/libs/lark/__pyinstaller/hook-lark.py deleted file mode 100644 index cf3d8e3..0000000 --- a/server/libs/lark/__pyinstaller/hook-lark.py +++ /dev/null @@ -1,14 +0,0 @@ -#----------------------------------------------------------------------------- -# Copyright (c) 2017-2020, PyInstaller Development Team. -# -# Distributed under the terms of the GNU General Public License (version 2 -# or later) with exception for distributing the bootloader. -# -# The full license is in the file COPYING.txt, distributed with this software. -# -# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception) -#----------------------------------------------------------------------------- - -from PyInstaller.utils.hooks import collect_data_files - -datas = collect_data_files('lark') diff --git a/server/libs/lark/ast_utils.py b/server/libs/lark/ast_utils.py deleted file mode 100644 index a5460f3..0000000 --- a/server/libs/lark/ast_utils.py +++ /dev/null @@ -1,59 +0,0 @@ -""" - Module of utilities for transforming a lark.Tree into a custom Abstract Syntax Tree (AST defined in classes) -""" - -import inspect, re -import types -from typing import Optional, Callable - -from lark import Transformer, v_args - -class Ast: - """Abstract class - - Subclasses will be collected by `create_transformer()` - """ - pass - -class AsList: - """Abstract class - - Subclasses will be instantiated with the parse results as a single list, instead of as arguments. - """ - -class WithMeta: - """Abstract class - - Subclasses will be instantiated with the Meta instance of the tree. (see ``v_args`` for more detail) - """ - pass - -def camel_to_snake(name): - return re.sub(r'(? Transformer: - """Collects `Ast` subclasses from the given module, and creates a Lark transformer that builds the AST. - - For each class, we create a corresponding rule in the transformer, with a matching name. - CamelCase names will be converted into snake_case. Example: "CodeBlock" -> "code_block". - - Classes starting with an underscore (`_`) will be skipped. - - Parameters: - ast_module: A Python module containing all the subclasses of ``ast_utils.Ast`` - transformer (Optional[Transformer]): An initial transformer. Its attributes may be overwritten. - decorator_factory (Callable): An optional callable accepting two booleans, inline, and meta, - and returning a decorator for the methods of ``transformer``. (default: ``v_args``). - """ - t = transformer or Transformer() - - for name, obj in inspect.getmembers(ast_module): - if not name.startswith('_') and inspect.isclass(obj): - if issubclass(obj, Ast): - wrapper = decorator_factory(inline=not issubclass(obj, AsList), meta=issubclass(obj, WithMeta)) - obj = wrapper(obj).__get__(t) - setattr(t, camel_to_snake(name), obj) - - return t diff --git a/server/libs/lark/common.py b/server/libs/lark/common.py deleted file mode 100644 index 71b6a4c..0000000 --- a/server/libs/lark/common.py +++ /dev/null @@ -1,86 +0,0 @@ -from copy import deepcopy -import sys -from types import ModuleType -from typing import Callable, Collection, Dict, Optional, TYPE_CHECKING, List - -if TYPE_CHECKING: - from .lark import PostLex - from .lexer import Lexer - from .grammar import Rule - from typing import Union, Type - from typing import Literal - if sys.version_info >= (3, 10): - from typing import TypeAlias - else: - from typing_extensions import TypeAlias - -from .utils import Serialize -from .lexer import TerminalDef, Token - -###{standalone - -_ParserArgType: 'TypeAlias' = 'Literal["earley", "lalr", "cyk", "auto"]' -_LexerArgType: 'TypeAlias' = 'Union[Literal["auto", "basic", "contextual", "dynamic", "dynamic_complete"], Type[Lexer]]' -_LexerCallback = Callable[[Token], Token] -ParserCallbacks = Dict[str, Callable] - -class LexerConf(Serialize): - __serialize_fields__ = 'terminals', 'ignore', 'g_regex_flags', 'use_bytes', 'lexer_type' - __serialize_namespace__ = TerminalDef, - - terminals: Collection[TerminalDef] - re_module: ModuleType - ignore: Collection[str] - postlex: 'Optional[PostLex]' - callbacks: Dict[str, _LexerCallback] - g_regex_flags: int - skip_validation: bool - use_bytes: bool - lexer_type: Optional[_LexerArgType] - strict: bool - - def __init__(self, terminals: Collection[TerminalDef], re_module: ModuleType, ignore: Collection[str]=(), postlex: 'Optional[PostLex]'=None, - callbacks: Optional[Dict[str, _LexerCallback]]=None, g_regex_flags: int=0, skip_validation: bool=False, use_bytes: bool=False, strict: bool=False): - self.terminals = terminals - self.terminals_by_name = {t.name: t for t in self.terminals} - assert len(self.terminals) == len(self.terminals_by_name) - self.ignore = ignore - self.postlex = postlex - self.callbacks = callbacks or {} - self.g_regex_flags = g_regex_flags - self.re_module = re_module - self.skip_validation = skip_validation - self.use_bytes = use_bytes - self.strict = strict - self.lexer_type = None - - def _deserialize(self): - self.terminals_by_name = {t.name: t for t in self.terminals} - - def __deepcopy__(self, memo=None): - return type(self)( - deepcopy(self.terminals, memo), - self.re_module, - deepcopy(self.ignore, memo), - deepcopy(self.postlex, memo), - deepcopy(self.callbacks, memo), - deepcopy(self.g_regex_flags, memo), - deepcopy(self.skip_validation, memo), - deepcopy(self.use_bytes, memo), - ) - -class ParserConf(Serialize): - __serialize_fields__ = 'rules', 'start', 'parser_type' - - rules: List['Rule'] - callbacks: ParserCallbacks - start: List[str] - parser_type: _ParserArgType - - def __init__(self, rules: List['Rule'], callbacks: ParserCallbacks, start: List[str]): - assert isinstance(start, list) - self.rules = rules - self.callbacks = callbacks - self.start = start - -###} diff --git a/server/libs/lark/exceptions.py b/server/libs/lark/exceptions.py deleted file mode 100644 index e099d59..0000000 --- a/server/libs/lark/exceptions.py +++ /dev/null @@ -1,292 +0,0 @@ -from .utils import logger, NO_VALUE -from typing import Mapping, Iterable, Callable, Union, TypeVar, Tuple, Any, List, Set, Optional, Collection, TYPE_CHECKING - -if TYPE_CHECKING: - from .lexer import Token - from .parsers.lalr_interactive_parser import InteractiveParser - from .tree import Tree - -###{standalone - -class LarkError(Exception): - pass - - -class ConfigurationError(LarkError, ValueError): - pass - - -def assert_config(value, options: Collection, msg='Got %r, expected one of %s'): - if value not in options: - raise ConfigurationError(msg % (value, options)) - - -class GrammarError(LarkError): - pass - - -class ParseError(LarkError): - pass - - -class LexError(LarkError): - pass - -T = TypeVar('T') - -class UnexpectedInput(LarkError): - """UnexpectedInput Error. - - Used as a base class for the following exceptions: - - - ``UnexpectedCharacters``: The lexer encountered an unexpected string - - ``UnexpectedToken``: The parser received an unexpected token - - ``UnexpectedEOF``: The parser expected a token, but the input ended - - After catching one of these exceptions, you may call the following helper methods to create a nicer error message. - """ - line: int - column: int - pos_in_stream = None - state: Any - _terminals_by_name = None - interactive_parser: 'InteractiveParser' - - def get_context(self, text: str, span: int=40) -> str: - """Returns a pretty string pinpointing the error in the text, - with span amount of context characters around it. - - Note: - The parser doesn't hold a copy of the text it has to parse, - so you have to provide it again - """ - assert self.pos_in_stream is not None, self - pos = self.pos_in_stream - start = max(pos - span, 0) - end = pos + span - if not isinstance(text, bytes): - before = text[start:pos].rsplit('\n', 1)[-1] - after = text[pos:end].split('\n', 1)[0] - return before + after + '\n' + ' ' * len(before.expandtabs()) + '^\n' - else: - before = text[start:pos].rsplit(b'\n', 1)[-1] - after = text[pos:end].split(b'\n', 1)[0] - return (before + after + b'\n' + b' ' * len(before.expandtabs()) + b'^\n').decode("ascii", "backslashreplace") - - def match_examples(self, parse_fn: 'Callable[[str], Tree]', - examples: Union[Mapping[T, Iterable[str]], Iterable[Tuple[T, Iterable[str]]]], - token_type_match_fallback: bool=False, - use_accepts: bool=True - ) -> Optional[T]: - """Allows you to detect what's wrong in the input text by matching - against example errors. - - Given a parser instance and a dictionary mapping some label with - some malformed syntax examples, it'll return the label for the - example that bests matches the current error. The function will - iterate the dictionary until it finds a matching error, and - return the corresponding value. - - For an example usage, see `examples/error_reporting_lalr.py` - - Parameters: - parse_fn: parse function (usually ``lark_instance.parse``) - examples: dictionary of ``{'example_string': value}``. - use_accepts: Recommended to keep this as ``use_accepts=True``. - """ - assert self.state is not None, "Not supported for this exception" - - if isinstance(examples, Mapping): - examples = examples.items() - - candidate = (None, False) - for i, (label, example) in enumerate(examples): - assert not isinstance(example, str), "Expecting a list" - - for j, malformed in enumerate(example): - try: - parse_fn(malformed) - except UnexpectedInput as ut: - if ut.state == self.state: - if ( - use_accepts - and isinstance(self, UnexpectedToken) - and isinstance(ut, UnexpectedToken) - and ut.accepts != self.accepts - ): - logger.debug("Different accepts with same state[%d]: %s != %s at example [%s][%s]" % - (self.state, self.accepts, ut.accepts, i, j)) - continue - if ( - isinstance(self, (UnexpectedToken, UnexpectedEOF)) - and isinstance(ut, (UnexpectedToken, UnexpectedEOF)) - ): - if ut.token == self.token: # Try exact match first - logger.debug("Exact Match at example [%s][%s]" % (i, j)) - return label - - if token_type_match_fallback: - # Fallback to token types match - if (ut.token.type == self.token.type) and not candidate[-1]: - logger.debug("Token Type Fallback at example [%s][%s]" % (i, j)) - candidate = label, True - - if candidate[0] is None: - logger.debug("Same State match at example [%s][%s]" % (i, j)) - candidate = label, False - - return candidate[0] - - def _format_expected(self, expected): - if self._terminals_by_name: - d = self._terminals_by_name - expected = [d[t_name].user_repr() if t_name in d else t_name for t_name in expected] - return "Expected one of: \n\t* %s\n" % '\n\t* '.join(expected) - - -class UnexpectedEOF(ParseError, UnexpectedInput): - """An exception that is raised by the parser, when the input ends while it still expects a token. - """ - expected: 'List[Token]' - - def __init__(self, expected, state=None, terminals_by_name=None): - super(UnexpectedEOF, self).__init__() - - self.expected = expected - self.state = state - from .lexer import Token - self.token = Token("", "") # , line=-1, column=-1, pos_in_stream=-1) - self.pos_in_stream = -1 - self.line = -1 - self.column = -1 - self._terminals_by_name = terminals_by_name - - - def __str__(self): - message = "Unexpected end-of-input. " - message += self._format_expected(self.expected) - return message - - -class UnexpectedCharacters(LexError, UnexpectedInput): - """An exception that is raised by the lexer, when it cannot match the next - string of characters to any of its terminals. - """ - - allowed: Set[str] - considered_tokens: Set[Any] - - def __init__(self, seq, lex_pos, line, column, allowed=None, considered_tokens=None, state=None, token_history=None, - terminals_by_name=None, considered_rules=None): - super(UnexpectedCharacters, self).__init__() - - # TODO considered_tokens and allowed can be figured out using state - self.line = line - self.column = column - self.pos_in_stream = lex_pos - self.state = state - self._terminals_by_name = terminals_by_name - - self.allowed = allowed - self.considered_tokens = considered_tokens - self.considered_rules = considered_rules - self.token_history = token_history - - if isinstance(seq, bytes): - self.char = seq[lex_pos:lex_pos + 1].decode("ascii", "backslashreplace") - else: - self.char = seq[lex_pos] - self._context = self.get_context(seq) - - - def __str__(self): - message = "No terminal matches '%s' in the current parser context, at line %d col %d" % (self.char, self.line, self.column) - message += '\n\n' + self._context - if self.allowed: - message += self._format_expected(self.allowed) - if self.token_history: - message += '\nPrevious tokens: %s\n' % ', '.join(repr(t) for t in self.token_history) - return message - - -class UnexpectedToken(ParseError, UnexpectedInput): - """An exception that is raised by the parser, when the token it received - doesn't match any valid step forward. - - Parameters: - token: The mismatched token - expected: The set of expected tokens - considered_rules: Which rules were considered, to deduce the expected tokens - state: A value representing the parser state. Do not rely on its value or type. - interactive_parser: An instance of ``InteractiveParser``, that is initialized to the point of failure, - and can be used for debugging and error handling. - - Note: These parameters are available as attributes of the instance. - """ - - expected: Set[str] - considered_rules: Set[str] - - def __init__(self, token, expected, considered_rules=None, state=None, interactive_parser=None, terminals_by_name=None, token_history=None): - super(UnexpectedToken, self).__init__() - - # TODO considered_rules and expected can be figured out using state - self.line = getattr(token, 'line', '?') - self.column = getattr(token, 'column', '?') - self.pos_in_stream = getattr(token, 'start_pos', None) - self.state = state - - self.token = token - self.expected = expected # XXX deprecate? `accepts` is better - self._accepts = NO_VALUE - self.considered_rules = considered_rules - self.interactive_parser = interactive_parser - self._terminals_by_name = terminals_by_name - self.token_history = token_history - - - @property - def accepts(self) -> Set[str]: - if self._accepts is NO_VALUE: - self._accepts = self.interactive_parser and self.interactive_parser.accepts() - return self._accepts - - def __str__(self): - message = ("Unexpected token %r at line %s, column %s.\n%s" - % (self.token, self.line, self.column, self._format_expected(self.accepts or self.expected))) - if self.token_history: - message += "Previous tokens: %r\n" % self.token_history - - return message - - - -class VisitError(LarkError): - """VisitError is raised when visitors are interrupted by an exception - - It provides the following attributes for inspection: - - Parameters: - rule: the name of the visit rule that failed - obj: the tree-node or token that was being processed - orig_exc: the exception that cause it to fail - - Note: These parameters are available as attributes - """ - - obj: 'Union[Tree, Token]' - orig_exc: Exception - - def __init__(self, rule, obj, orig_exc): - message = 'Error trying to process rule "%s":\n\n%s' % (rule, orig_exc) - super(VisitError, self).__init__(message) - - self.rule = rule - self.obj = obj - self.orig_exc = orig_exc - - -class MissingVariableError(LarkError): - pass - -###} diff --git a/server/libs/lark/grammar.py b/server/libs/lark/grammar.py deleted file mode 100644 index 1d226d9..0000000 --- a/server/libs/lark/grammar.py +++ /dev/null @@ -1,130 +0,0 @@ -from typing import Optional, Tuple, ClassVar, Sequence - -from .utils import Serialize - -###{standalone -TOKEN_DEFAULT_PRIORITY = 0 - - -class Symbol(Serialize): - __slots__ = ('name',) - - name: str - is_term: ClassVar[bool] = NotImplemented - - def __init__(self, name: str) -> None: - self.name = name - - def __eq__(self, other): - assert isinstance(other, Symbol), other - return self.is_term == other.is_term and self.name == other.name - - def __ne__(self, other): - return not (self == other) - - def __hash__(self): - return hash(self.name) - - def __repr__(self): - return '%s(%r)' % (type(self).__name__, self.name) - - fullrepr = property(__repr__) - - def renamed(self, f): - return type(self)(f(self.name)) - - -class Terminal(Symbol): - __serialize_fields__ = 'name', 'filter_out' - - is_term: ClassVar[bool] = True - - def __init__(self, name, filter_out=False): - self.name = name - self.filter_out = filter_out - - @property - def fullrepr(self): - return '%s(%r, %r)' % (type(self).__name__, self.name, self.filter_out) - - def renamed(self, f): - return type(self)(f(self.name), self.filter_out) - - -class NonTerminal(Symbol): - __serialize_fields__ = 'name', - - is_term: ClassVar[bool] = False - - -class RuleOptions(Serialize): - __serialize_fields__ = 'keep_all_tokens', 'expand1', 'priority', 'template_source', 'empty_indices' - - keep_all_tokens: bool - expand1: bool - priority: Optional[int] - template_source: Optional[str] - empty_indices: Tuple[bool, ...] - - def __init__(self, keep_all_tokens: bool=False, expand1: bool=False, priority: Optional[int]=None, template_source: Optional[str]=None, empty_indices: Tuple[bool, ...]=()) -> None: - self.keep_all_tokens = keep_all_tokens - self.expand1 = expand1 - self.priority = priority - self.template_source = template_source - self.empty_indices = empty_indices - - def __repr__(self): - return 'RuleOptions(%r, %r, %r, %r)' % ( - self.keep_all_tokens, - self.expand1, - self.priority, - self.template_source - ) - - -class Rule(Serialize): - """ - origin : a symbol - expansion : a list of symbols - order : index of this expansion amongst all rules of the same name - """ - __slots__ = ('origin', 'expansion', 'alias', 'options', 'order', '_hash') - - __serialize_fields__ = 'origin', 'expansion', 'order', 'alias', 'options' - __serialize_namespace__ = Terminal, NonTerminal, RuleOptions - - origin: NonTerminal - expansion: Sequence[Symbol] - order: int - alias: Optional[str] - options: RuleOptions - _hash: int - - def __init__(self, origin: NonTerminal, expansion: Sequence[Symbol], - order: int=0, alias: Optional[str]=None, options: Optional[RuleOptions]=None): - self.origin = origin - self.expansion = expansion - self.alias = alias - self.order = order - self.options = options or RuleOptions() - self._hash = hash((self.origin, tuple(self.expansion))) - - def _deserialize(self): - self._hash = hash((self.origin, tuple(self.expansion))) - - def __str__(self): - return '<%s : %s>' % (self.origin.name, ' '.join(x.name for x in self.expansion)) - - def __repr__(self): - return 'Rule(%r, %r, %r, %r)' % (self.origin, self.expansion, self.alias, self.options) - - def __hash__(self): - return self._hash - - def __eq__(self, other): - if not isinstance(other, Rule): - return False - return self.origin == other.origin and self.expansion == other.expansion - - -###} diff --git a/server/libs/lark/grammars/common.lark b/server/libs/lark/grammars/common.lark deleted file mode 100644 index d2e86d1..0000000 --- a/server/libs/lark/grammars/common.lark +++ /dev/null @@ -1,59 +0,0 @@ -// Basic terminals for common use - - -// -// Numbers -// - -DIGIT: "0".."9" -HEXDIGIT: "a".."f"|"A".."F"|DIGIT - -INT: DIGIT+ -SIGNED_INT: ["+"|"-"] INT -DECIMAL: INT "." INT? | "." INT - -// float = /-?\d+(\.\d+)?([eE][+-]?\d+)?/ -_EXP: ("e"|"E") SIGNED_INT -FLOAT: INT _EXP | DECIMAL _EXP? -SIGNED_FLOAT: ["+"|"-"] FLOAT - -NUMBER: FLOAT | INT -SIGNED_NUMBER: ["+"|"-"] NUMBER - -// -// Strings -// -_STRING_INNER: /.*?/ -_STRING_ESC_INNER: _STRING_INNER /(? ignore - | "%import" import_path ["->" name] -> import - | "%import" import_path name_list -> multi_import - | "%override" rule -> override_rule - | "%declare" name+ -> declare - -!import_path: "."? name ("." name)* -name_list: "(" name ("," name)* ")" - -?expansions: alias (_VBAR alias)* - -?alias: expansion ["->" RULE] - -?expansion: expr* - -?expr: atom [OP | "~" NUMBER [".." NUMBER]] - -?atom: "(" expansions ")" - | "[" expansions "]" -> maybe - | value - -?value: STRING ".." STRING -> literal_range - | name - | (REGEXP | STRING) -> literal - | name "{" value ("," value)* "}" -> template_usage - -name: RULE - | TOKEN - -_VBAR: _NL? "|" -OP: /[+*]|[?](?![a-z])/ -RULE: /!?[_?]?[a-z][_a-z0-9]*/ -TOKEN: /_?[A-Z][_A-Z0-9]*/ -STRING: _STRING "i"? -REGEXP: /\/(?!\/)(\\\/|\\\\|[^\/])*?\/[imslux]*/ -_NL: /(\r?\n)+\s*/ - -%import common.ESCAPED_STRING -> _STRING -%import common.SIGNED_INT -> NUMBER -%import common.WS_INLINE - -COMMENT: /\s*/ "//" /[^\n]/* | /\s*/ "#" /[^\n]/* - -%ignore WS_INLINE -%ignore COMMENT diff --git a/server/libs/lark/grammars/python.lark b/server/libs/lark/grammars/python.lark deleted file mode 100644 index 8a75966..0000000 --- a/server/libs/lark/grammars/python.lark +++ /dev/null @@ -1,302 +0,0 @@ -// Python 3 grammar for Lark - -// This grammar should parse all python 3.x code successfully. - -// Adapted from: https://docs.python.org/3/reference/grammar.html - -// Start symbols for the grammar: -// single_input is a single interactive statement; -// file_input is a module or sequence of commands read from an input file; -// eval_input is the input for the eval() functions. -// NB: compound_stmt in single_input is followed by extra NEWLINE! -// - -single_input: _NEWLINE | simple_stmt | compound_stmt _NEWLINE -file_input: (_NEWLINE | stmt)* -eval_input: testlist _NEWLINE* - -decorator: "@" dotted_name [ "(" [arguments] ")" ] _NEWLINE -decorators: decorator+ -decorated: decorators (classdef | funcdef | async_funcdef) - -async_funcdef: "async" funcdef -funcdef: "def" name "(" [parameters] ")" ["->" test] ":" suite - -parameters: paramvalue ("," paramvalue)* ["," SLASH ("," paramvalue)*] ["," [starparams | kwparams]] - | starparams - | kwparams - -SLASH: "/" // Otherwise the it will completely disappear and it will be undisguisable in the result -starparams: (starparam | starguard) poststarparams -starparam: "*" typedparam -starguard: "*" -poststarparams: ("," paramvalue)* ["," kwparams] -kwparams: "**" typedparam ","? - -?paramvalue: typedparam ("=" test)? -?typedparam: name (":" test)? - - -lambdef: "lambda" [lambda_params] ":" test -lambdef_nocond: "lambda" [lambda_params] ":" test_nocond -lambda_params: lambda_paramvalue ("," lambda_paramvalue)* ["," [lambda_starparams | lambda_kwparams]] - | lambda_starparams - | lambda_kwparams -?lambda_paramvalue: name ("=" test)? -lambda_starparams: "*" [name] ("," lambda_paramvalue)* ["," [lambda_kwparams]] -lambda_kwparams: "**" name ","? - - -?stmt: simple_stmt | compound_stmt -?simple_stmt: small_stmt (";" small_stmt)* [";"] _NEWLINE -?small_stmt: (expr_stmt | assign_stmt | del_stmt | pass_stmt | flow_stmt | import_stmt | global_stmt | nonlocal_stmt | assert_stmt) -expr_stmt: testlist_star_expr -assign_stmt: annassign | augassign | assign - -annassign: testlist_star_expr ":" test ["=" test] -assign: testlist_star_expr ("=" (yield_expr|testlist_star_expr))+ -augassign: testlist_star_expr augassign_op (yield_expr|testlist) -!augassign_op: "+=" | "-=" | "*=" | "@=" | "/=" | "%=" | "&=" | "|=" | "^=" | "<<=" | ">>=" | "**=" | "//=" -?testlist_star_expr: test_or_star_expr - | test_or_star_expr ("," test_or_star_expr)+ ","? -> tuple - | test_or_star_expr "," -> tuple - -// For normal and annotated assignments, additional restrictions enforced by the interpreter -del_stmt: "del" exprlist -pass_stmt: "pass" -?flow_stmt: break_stmt | continue_stmt | return_stmt | raise_stmt | yield_stmt -break_stmt: "break" -continue_stmt: "continue" -return_stmt: "return" [testlist] -yield_stmt: yield_expr -raise_stmt: "raise" [test ["from" test]] -import_stmt: import_name | import_from -import_name: "import" dotted_as_names -// note below: the ("." | "...") is necessary because "..." is tokenized as ELLIPSIS -import_from: "from" (dots? dotted_name | dots) "import" ("*" | "(" import_as_names ")" | import_as_names) -!dots: "."+ -import_as_name: name ["as" name] -dotted_as_name: dotted_name ["as" name] -import_as_names: import_as_name ("," import_as_name)* [","] -dotted_as_names: dotted_as_name ("," dotted_as_name)* -dotted_name: name ("." name)* -global_stmt: "global" name ("," name)* -nonlocal_stmt: "nonlocal" name ("," name)* -assert_stmt: "assert" test ["," test] - -?compound_stmt: if_stmt | while_stmt | for_stmt | try_stmt | match_stmt - | with_stmt | funcdef | classdef | decorated | async_stmt -async_stmt: "async" (funcdef | with_stmt | for_stmt) -if_stmt: "if" test ":" suite elifs ["else" ":" suite] -elifs: elif_* -elif_: "elif" test ":" suite -while_stmt: "while" test ":" suite ["else" ":" suite] -for_stmt: "for" exprlist "in" testlist ":" suite ["else" ":" suite] -try_stmt: "try" ":" suite except_clauses ["else" ":" suite] [finally] - | "try" ":" suite finally -> try_finally -finally: "finally" ":" suite -except_clauses: except_clause+ -except_clause: "except" [test ["as" name]] ":" suite -// NB compile.c makes sure that the default except clause is last - - -with_stmt: "with" with_items ":" suite -with_items: with_item ("," with_item)* -with_item: test ["as" name] - -match_stmt: "match" test ":" _NEWLINE _INDENT case+ _DEDENT - -case: "case" pattern ["if" test] ":" suite - -?pattern: sequence_item_pattern "," _sequence_pattern -> sequence_pattern - | as_pattern -?as_pattern: or_pattern ("as" NAME)? -?or_pattern: closed_pattern ("|" closed_pattern)* -?closed_pattern: literal_pattern - | NAME -> capture_pattern - | "_" -> any_pattern - | attr_pattern - | "(" as_pattern ")" - | "[" _sequence_pattern "]" -> sequence_pattern - | "(" (sequence_item_pattern "," _sequence_pattern)? ")" -> sequence_pattern - | "{" (mapping_item_pattern ("," mapping_item_pattern)* ","?)?"}" -> mapping_pattern - | "{" (mapping_item_pattern ("," mapping_item_pattern)* ",")? "**" NAME ","? "}" -> mapping_star_pattern - | class_pattern - -literal_pattern: inner_literal_pattern - -?inner_literal_pattern: "None" -> const_none - | "True" -> const_true - | "False" -> const_false - | STRING -> string - | number - -attr_pattern: NAME ("." NAME)+ -> value - -name_or_attr_pattern: NAME ("." NAME)* -> value - -mapping_item_pattern: (literal_pattern|attr_pattern) ":" as_pattern - -_sequence_pattern: (sequence_item_pattern ("," sequence_item_pattern)* ","?)? -?sequence_item_pattern: as_pattern - | "*" NAME -> star_pattern - -class_pattern: name_or_attr_pattern "(" [arguments_pattern ","?] ")" -arguments_pattern: pos_arg_pattern ["," keyws_arg_pattern] - | keyws_arg_pattern -> no_pos_arguments - -pos_arg_pattern: as_pattern ("," as_pattern)* -keyws_arg_pattern: keyw_arg_pattern ("," keyw_arg_pattern)* -keyw_arg_pattern: NAME "=" as_pattern - - - -suite: simple_stmt | _NEWLINE _INDENT stmt+ _DEDENT - -?test: or_test ("if" or_test "else" test)? - | lambdef - | assign_expr - -assign_expr: name ":=" test - -?test_nocond: or_test | lambdef_nocond - -?or_test: and_test ("or" and_test)* -?and_test: not_test_ ("and" not_test_)* -?not_test_: "not" not_test_ -> not_test - | comparison -?comparison: expr (comp_op expr)* -star_expr: "*" expr - -?expr: or_expr -?or_expr: xor_expr ("|" xor_expr)* -?xor_expr: and_expr ("^" and_expr)* -?and_expr: shift_expr ("&" shift_expr)* -?shift_expr: arith_expr (_shift_op arith_expr)* -?arith_expr: term (_add_op term)* -?term: factor (_mul_op factor)* -?factor: _unary_op factor | power - -!_unary_op: "+"|"-"|"~" -!_add_op: "+"|"-" -!_shift_op: "<<"|">>" -!_mul_op: "*"|"@"|"/"|"%"|"//" -// <> isn't actually a valid comparison operator in Python. It's here for the -// sake of a __future__ import described in PEP 401 (which really works :-) -!comp_op: "<"|">"|"=="|">="|"<="|"<>"|"!="|"in"|"not" "in"|"is"|"is" "not" - -?power: await_expr ("**" factor)? -?await_expr: AWAIT? atom_expr -AWAIT: "await" - -?atom_expr: atom_expr "(" [arguments] ")" -> funccall - | atom_expr "[" subscriptlist "]" -> getitem - | atom_expr "." name -> getattr - | atom - -?atom: "(" yield_expr ")" - | "(" _tuple_inner? ")" -> tuple - | "(" comprehension{test_or_star_expr} ")" -> tuple_comprehension - | "[" _exprlist? "]" -> list - | "[" comprehension{test_or_star_expr} "]" -> list_comprehension - | "{" _dict_exprlist? "}" -> dict - | "{" comprehension{key_value} "}" -> dict_comprehension - | "{" _exprlist "}" -> set - | "{" comprehension{test} "}" -> set_comprehension - | name -> var - | number - | string_concat - | "(" test ")" - | "..." -> ellipsis - | "None" -> const_none - | "True" -> const_true - | "False" -> const_false - - -?string_concat: string+ - -_tuple_inner: test_or_star_expr (("," test_or_star_expr)+ [","] | ",") - -?test_or_star_expr: test - | star_expr - -?subscriptlist: subscript - | subscript (("," subscript)+ [","] | ",") -> subscript_tuple -?subscript: test | ([test] ":" [test] [sliceop]) -> slice -sliceop: ":" [test] -?exprlist: (expr|star_expr) - | (expr|star_expr) (("," (expr|star_expr))+ [","]|",") -?testlist: test | testlist_tuple -testlist_tuple: test (("," test)+ [","] | ",") -_dict_exprlist: (key_value | "**" expr) ("," (key_value | "**" expr))* [","] - -key_value: test ":" test - -_exprlist: test_or_star_expr ("," test_or_star_expr)* [","] - -classdef: "class" name ["(" [arguments] ")"] ":" suite - - - -arguments: argvalue ("," argvalue)* ("," [ starargs | kwargs])? - | starargs - | kwargs - | comprehension{test} - -starargs: stararg ("," stararg)* ("," argvalue)* ["," kwargs] -stararg: "*" test -kwargs: "**" test ("," argvalue)* - -?argvalue: test ("=" test)? - - -comprehension{comp_result}: comp_result comp_fors [comp_if] -comp_fors: comp_for+ -comp_for: [ASYNC] "for" exprlist "in" or_test -ASYNC: "async" -?comp_if: "if" test_nocond - -// not used in grammar, but may appear in "node" passed from Parser to Compiler -encoding_decl: name - -yield_expr: "yield" [testlist] - | "yield" "from" test -> yield_from - -number: DEC_NUMBER | HEX_NUMBER | BIN_NUMBER | OCT_NUMBER | FLOAT_NUMBER | IMAG_NUMBER -string: STRING | LONG_STRING - -// Other terminals - -_NEWLINE: ( /\r?\n[\t ]*/ | COMMENT )+ - -%ignore /[\t \f]+/ // WS -%ignore /\\[\t \f]*\r?\n/ // LINE_CONT -%ignore COMMENT -%declare _INDENT _DEDENT - - -// Python terminals - -!name: NAME | "match" | "case" -NAME: /[^\W\d]\w*/ -COMMENT: /#[^\n]*/ - -STRING: /([ubf]?r?|r[ubf])("(?!"").*?(? None: - self.paren_level = 0 - self.indent_level = [0] - assert self.tab_len > 0 - - def handle_NL(self, token: Token) -> Iterator[Token]: - if self.paren_level > 0: - return - - yield token - - indent_str = token.rsplit('\n', 1)[1] # Tabs and spaces - indent = indent_str.count(' ') + indent_str.count('\t') * self.tab_len - - if indent > self.indent_level[-1]: - self.indent_level.append(indent) - yield Token.new_borrow_pos(self.INDENT_type, indent_str, token) - else: - while indent < self.indent_level[-1]: - self.indent_level.pop() - yield Token.new_borrow_pos(self.DEDENT_type, indent_str, token) - - if indent != self.indent_level[-1]: - raise DedentError('Unexpected dedent to column %s. Expected dedent to %s' % (indent, self.indent_level[-1])) - - def _process(self, stream): - for token in stream: - if token.type == self.NL_type: - yield from self.handle_NL(token) - else: - yield token - - if token.type in self.OPEN_PAREN_types: - self.paren_level += 1 - elif token.type in self.CLOSE_PAREN_types: - self.paren_level -= 1 - assert self.paren_level >= 0 - - while len(self.indent_level) > 1: - self.indent_level.pop() - yield Token(self.DEDENT_type, '') - - assert self.indent_level == [0], self.indent_level - - def process(self, stream): - self.paren_level = 0 - self.indent_level = [0] - return self._process(stream) - - # XXX Hack for ContextualLexer. Maybe there's a more elegant solution? - @property - def always_accept(self): - return (self.NL_type,) - - @property - @abstractmethod - def NL_type(self) -> str: - "The name of the newline token" - raise NotImplementedError() - - @property - @abstractmethod - def OPEN_PAREN_types(self) -> List[str]: - "The names of the tokens that open a parenthesis" - raise NotImplementedError() - - @property - @abstractmethod - def CLOSE_PAREN_types(self) -> List[str]: - """The names of the tokens that close a parenthesis - """ - raise NotImplementedError() - - @property - @abstractmethod - def INDENT_type(self) -> str: - """The name of the token that starts an indentation in the grammar. - - See also: %declare - """ - raise NotImplementedError() - - @property - @abstractmethod - def DEDENT_type(self) -> str: - """The name of the token that end an indentation in the grammar. - - See also: %declare - """ - raise NotImplementedError() - - @property - @abstractmethod - def tab_len(self) -> int: - """How many spaces does a tab equal""" - raise NotImplementedError() - - -class PythonIndenter(Indenter): - """A postlexer that "injects" _INDENT/_DEDENT tokens based on indentation, according to the Python syntax. - - See also: the ``postlex`` option in `Lark`. - """ - - NL_type = '_NEWLINE' - OPEN_PAREN_types = ['LPAR', 'LSQB', 'LBRACE'] - CLOSE_PAREN_types = ['RPAR', 'RSQB', 'RBRACE'] - INDENT_type = '_INDENT' - DEDENT_type = '_DEDENT' - tab_len = 8 - -###} diff --git a/server/libs/lark/lark.py b/server/libs/lark/lark.py deleted file mode 100644 index 7ae1f24..0000000 --- a/server/libs/lark/lark.py +++ /dev/null @@ -1,658 +0,0 @@ -from abc import ABC, abstractmethod -import getpass -import sys, os, pickle -import tempfile -import types -import re -from typing import ( - TypeVar, Type, List, Dict, Iterator, Callable, Union, Optional, Sequence, - Tuple, Iterable, IO, Any, TYPE_CHECKING, Collection -) -if TYPE_CHECKING: - from .parsers.lalr_interactive_parser import InteractiveParser - from .tree import ParseTree - from .visitors import Transformer - from typing import Literal - from .parser_frontends import ParsingFrontend - -from .exceptions import ConfigurationError, assert_config, UnexpectedInput -from .utils import Serialize, SerializeMemoizer, FS, logger -from .load_grammar import load_grammar, FromPackageLoader, Grammar, verify_used_files, PackageResource, sha256_digest -from .tree import Tree -from .common import LexerConf, ParserConf, _ParserArgType, _LexerArgType - -from .lexer import Lexer, BasicLexer, TerminalDef, LexerThread, Token -from .parse_tree_builder import ParseTreeBuilder -from .parser_frontends import _validate_frontend_args, _get_lexer_callbacks, _deserialize_parsing_frontend, _construct_parsing_frontend -from .grammar import Rule - - -try: - import regex - _has_regex = True -except ImportError: - _has_regex = False - - -###{standalone - - -class PostLex(ABC): - @abstractmethod - def process(self, stream: Iterator[Token]) -> Iterator[Token]: - return stream - - always_accept: Iterable[str] = () - -class LarkOptions(Serialize): - """Specifies the options for Lark - - """ - - start: List[str] - debug: bool - strict: bool - transformer: 'Optional[Transformer]' - propagate_positions: Union[bool, str] - maybe_placeholders: bool - cache: Union[bool, str] - regex: bool - g_regex_flags: int - keep_all_tokens: bool - tree_class: Optional[Callable[[str, List], Any]] - parser: _ParserArgType - lexer: _LexerArgType - ambiguity: 'Literal["auto", "resolve", "explicit", "forest"]' - postlex: Optional[PostLex] - priority: 'Optional[Literal["auto", "normal", "invert"]]' - lexer_callbacks: Dict[str, Callable[[Token], Token]] - use_bytes: bool - ordered_sets: bool - edit_terminals: Optional[Callable[[TerminalDef], TerminalDef]] - import_paths: 'List[Union[str, Callable[[Union[None, str, PackageResource], str], Tuple[str, str]]]]' - source_path: Optional[str] - - OPTIONS_DOC = r""" - **=== General Options ===** - - start - The start symbol. Either a string, or a list of strings for multiple possible starts (Default: "start") - debug - Display debug information and extra warnings. Use only when debugging (Default: ``False``) - When used with Earley, it generates a forest graph as "sppf.png", if 'dot' is installed. - strict - Throw an exception on any potential ambiguity, including shift/reduce conflicts, and regex collisions. - transformer - Applies the transformer to every parse tree (equivalent to applying it after the parse, but faster) - propagate_positions - Propagates positional attributes into the 'meta' attribute of all tree branches. - Sets attributes: (line, column, end_line, end_column, start_pos, end_pos, - container_line, container_column, container_end_line, container_end_column) - Accepts ``False``, ``True``, or a callable, which will filter which nodes to ignore when propagating. - maybe_placeholders - When ``True``, the ``[]`` operator returns ``None`` when not matched. - When ``False``, ``[]`` behaves like the ``?`` operator, and returns no value at all. - (default= ``True``) - cache - Cache the results of the Lark grammar analysis, for x2 to x3 faster loading. LALR only for now. - - - When ``False``, does nothing (default) - - When ``True``, caches to a temporary file in the local directory - - When given a string, caches to the path pointed by the string - regex - When True, uses the ``regex`` module instead of the stdlib ``re``. - g_regex_flags - Flags that are applied to all terminals (both regex and strings) - keep_all_tokens - Prevent the tree builder from automagically removing "punctuation" tokens (Default: ``False``) - tree_class - Lark will produce trees comprised of instances of this class instead of the default ``lark.Tree``. - - **=== Algorithm Options ===** - - parser - Decides which parser engine to use. Accepts "earley" or "lalr". (Default: "earley"). - (there is also a "cyk" option for legacy) - lexer - Decides whether or not to use a lexer stage - - - "auto" (default): Choose for me based on the parser - - "basic": Use a basic lexer - - "contextual": Stronger lexer (only works with parser="lalr") - - "dynamic": Flexible and powerful (only with parser="earley") - - "dynamic_complete": Same as dynamic, but tries *every* variation of tokenizing possible. - ambiguity - Decides how to handle ambiguity in the parse. Only relevant if parser="earley" - - - "resolve": The parser will automatically choose the simplest derivation - (it chooses consistently: greedy for tokens, non-greedy for rules) - - "explicit": The parser will return all derivations wrapped in "_ambig" tree nodes (i.e. a forest). - - "forest": The parser will return the root of the shared packed parse forest. - - **=== Misc. / Domain Specific Options ===** - - postlex - Lexer post-processing (Default: ``None``) Only works with the basic and contextual lexers. - priority - How priorities should be evaluated - "auto", ``None``, "normal", "invert" (Default: "auto") - lexer_callbacks - Dictionary of callbacks for the lexer. May alter tokens during lexing. Use with caution. - use_bytes - Accept an input of type ``bytes`` instead of ``str``. - ordered_sets - Should Earley use ordered-sets to achieve stable output (~10% slower than regular sets. Default: True) - edit_terminals - A callback for editing the terminals before parse. - import_paths - A List of either paths or loader functions to specify from where grammars are imported - source_path - Override the source of from where the grammar was loaded. Useful for relative imports and unconventional grammar loading - **=== End of Options ===** - """ - if __doc__: - __doc__ += OPTIONS_DOC - - - # Adding a new option needs to be done in multiple places: - # - In the dictionary below. This is the primary truth of which options `Lark.__init__` accepts - # - In the docstring above. It is used both for the docstring of `LarkOptions` and `Lark`, and in readthedocs - # - As an attribute of `LarkOptions` above - # - Potentially in `_LOAD_ALLOWED_OPTIONS` below this class, when the option doesn't change how the grammar is loaded - # - Potentially in `lark.tools.__init__`, if it makes sense, and it can easily be passed as a cmd argument - _defaults: Dict[str, Any] = { - 'debug': False, - 'strict': False, - 'keep_all_tokens': False, - 'tree_class': None, - 'cache': False, - 'postlex': None, - 'parser': 'earley', - 'lexer': 'auto', - 'transformer': None, - 'start': 'start', - 'priority': 'auto', - 'ambiguity': 'auto', - 'regex': False, - 'propagate_positions': False, - 'lexer_callbacks': {}, - 'maybe_placeholders': True, - 'edit_terminals': None, - 'g_regex_flags': 0, - 'use_bytes': False, - 'ordered_sets': True, - 'import_paths': [], - 'source_path': None, - '_plugins': {}, - } - - def __init__(self, options_dict: Dict[str, Any]) -> None: - o = dict(options_dict) - - options = {} - for name, default in self._defaults.items(): - if name in o: - value = o.pop(name) - if isinstance(default, bool) and name not in ('cache', 'use_bytes', 'propagate_positions'): - value = bool(value) - else: - value = default - - options[name] = value - - if isinstance(options['start'], str): - options['start'] = [options['start']] - - self.__dict__['options'] = options - - - assert_config(self.parser, ('earley', 'lalr', 'cyk', None)) - - if self.parser == 'earley' and self.transformer: - raise ConfigurationError('Cannot specify an embedded transformer when using the Earley algorithm. ' - 'Please use your transformer on the resulting parse tree, or use a different algorithm (i.e. LALR)') - - if o: - raise ConfigurationError("Unknown options: %s" % o.keys()) - - def __getattr__(self, name: str) -> Any: - try: - return self.__dict__['options'][name] - except KeyError as e: - raise AttributeError(e) - - def __setattr__(self, name: str, value: str) -> None: - assert_config(name, self.options.keys(), "%r isn't a valid option. Expected one of: %s") - self.options[name] = value - - def serialize(self, memo = None) -> Dict[str, Any]: - return self.options - - @classmethod - def deserialize(cls, data: Dict[str, Any], memo: Dict[int, Union[TerminalDef, Rule]]) -> "LarkOptions": - return cls(data) - - -# Options that can be passed to the Lark parser, even when it was loaded from cache/standalone. -# These options are only used outside of `load_grammar`. -_LOAD_ALLOWED_OPTIONS = {'postlex', 'transformer', 'lexer_callbacks', 'use_bytes', 'debug', 'g_regex_flags', 'regex', 'propagate_positions', 'tree_class', '_plugins'} - -_VALID_PRIORITY_OPTIONS = ('auto', 'normal', 'invert', None) -_VALID_AMBIGUITY_OPTIONS = ('auto', 'resolve', 'explicit', 'forest') - - -_T = TypeVar('_T', bound="Lark") - -class Lark(Serialize): - """Main interface for the library. - - It's mostly a thin wrapper for the many different parsers, and for the tree constructor. - - Parameters: - grammar: a string or file-object containing the grammar spec (using Lark's ebnf syntax) - options: a dictionary controlling various aspects of Lark. - - Example: - >>> Lark(r'''start: "foo" ''') - Lark(...) - """ - - source_path: str - source_grammar: str - grammar: 'Grammar' - options: LarkOptions - lexer: Lexer - parser: 'ParsingFrontend' - terminals: Collection[TerminalDef] - - def __init__(self, grammar: 'Union[Grammar, str, IO[str]]', **options) -> None: - self.options = LarkOptions(options) - re_module: types.ModuleType - - # Set regex or re module - use_regex = self.options.regex - if use_regex: - if _has_regex: - re_module = regex - else: - raise ImportError('`regex` module must be installed if calling `Lark(regex=True)`.') - else: - re_module = re - - # Some, but not all file-like objects have a 'name' attribute - if self.options.source_path is None: - try: - self.source_path = grammar.name # type: ignore[union-attr] - except AttributeError: - self.source_path = '' - else: - self.source_path = self.options.source_path - - # Drain file-like objects to get their contents - try: - read = grammar.read # type: ignore[union-attr] - except AttributeError: - pass - else: - grammar = read() - - cache_fn = None - cache_sha256 = None - if isinstance(grammar, str): - self.source_grammar = grammar - if self.options.use_bytes: - if not grammar.isascii(): - raise ConfigurationError("Grammar must be ascii only, when use_bytes=True") - - if self.options.cache: - if self.options.parser != 'lalr': - raise ConfigurationError("cache only works with parser='lalr' for now") - - unhashable = ('transformer', 'postlex', 'lexer_callbacks', 'edit_terminals', '_plugins') - options_str = ''.join(k+str(v) for k, v in options.items() if k not in unhashable) - from . import __version__ - s = grammar + options_str + __version__ + str(sys.version_info[:2]) - cache_sha256 = sha256_digest(s) - - if isinstance(self.options.cache, str): - cache_fn = self.options.cache - else: - if self.options.cache is not True: - raise ConfigurationError("cache argument must be bool or str") - - try: - username = getpass.getuser() - except Exception: - # The exception raised may be ImportError or OSError in - # the future. For the cache, we don't care about the - # specific reason - we just want a username. - username = "unknown" - - cache_fn = tempfile.gettempdir() + "/.lark_cache_%s_%s_%s_%s.tmp" % (username, cache_sha256, *sys.version_info[:2]) - - old_options = self.options - try: - with FS.open(cache_fn, 'rb') as f: - logger.debug('Loading grammar from cache: %s', cache_fn) - # Remove options that aren't relevant for loading from cache - for name in (set(options) - _LOAD_ALLOWED_OPTIONS): - del options[name] - file_sha256 = f.readline().rstrip(b'\n') - cached_used_files = pickle.load(f) - if file_sha256 == cache_sha256.encode('utf8') and verify_used_files(cached_used_files): - cached_parser_data = pickle.load(f) - self._load(cached_parser_data, **options) - return - except FileNotFoundError: - # The cache file doesn't exist; parse and compose the grammar as normal - pass - except Exception: # We should probably narrow done which errors we catch here. - logger.exception("Failed to load Lark from cache: %r. We will try to carry on.", cache_fn) - - # In theory, the Lark instance might have been messed up by the call to `_load`. - # In practice the only relevant thing that might have been overwritten should be `options` - self.options = old_options - - - # Parse the grammar file and compose the grammars - self.grammar, used_files = load_grammar(grammar, self.source_path, self.options.import_paths, self.options.keep_all_tokens) - else: - assert isinstance(grammar, Grammar) - self.grammar = grammar - - - if self.options.lexer == 'auto': - if self.options.parser == 'lalr': - self.options.lexer = 'contextual' - elif self.options.parser == 'earley': - if self.options.postlex is not None: - logger.info("postlex can't be used with the dynamic lexer, so we use 'basic' instead. " - "Consider using lalr with contextual instead of earley") - self.options.lexer = 'basic' - else: - self.options.lexer = 'dynamic' - elif self.options.parser == 'cyk': - self.options.lexer = 'basic' - else: - assert False, self.options.parser - lexer = self.options.lexer - if isinstance(lexer, type): - assert issubclass(lexer, Lexer) # XXX Is this really important? Maybe just ensure interface compliance - else: - assert_config(lexer, ('basic', 'contextual', 'dynamic', 'dynamic_complete')) - if self.options.postlex is not None and 'dynamic' in lexer: - raise ConfigurationError("Can't use postlex with a dynamic lexer. Use basic or contextual instead") - - if self.options.ambiguity == 'auto': - if self.options.parser == 'earley': - self.options.ambiguity = 'resolve' - else: - assert_config(self.options.parser, ('earley', 'cyk'), "%r doesn't support disambiguation. Use one of these parsers instead: %s") - - if self.options.priority == 'auto': - self.options.priority = 'normal' - - if self.options.priority not in _VALID_PRIORITY_OPTIONS: - raise ConfigurationError("invalid priority option: %r. Must be one of %r" % (self.options.priority, _VALID_PRIORITY_OPTIONS)) - if self.options.ambiguity not in _VALID_AMBIGUITY_OPTIONS: - raise ConfigurationError("invalid ambiguity option: %r. Must be one of %r" % (self.options.ambiguity, _VALID_AMBIGUITY_OPTIONS)) - - if self.options.parser is None: - terminals_to_keep = '*' - elif self.options.postlex is not None: - terminals_to_keep = set(self.options.postlex.always_accept) - else: - terminals_to_keep = set() - - # Compile the EBNF grammar into BNF - self.terminals, self.rules, self.ignore_tokens = self.grammar.compile(self.options.start, terminals_to_keep) - - if self.options.edit_terminals: - for t in self.terminals: - self.options.edit_terminals(t) - - self._terminals_dict = {t.name: t for t in self.terminals} - - # If the user asked to invert the priorities, negate them all here. - if self.options.priority == 'invert': - for rule in self.rules: - if rule.options.priority is not None: - rule.options.priority = -rule.options.priority - for term in self.terminals: - term.priority = -term.priority - # Else, if the user asked to disable priorities, strip them from the - # rules and terminals. This allows the Earley parsers to skip an extra forest walk - # for improved performance, if you don't need them (or didn't specify any). - elif self.options.priority is None: - for rule in self.rules: - if rule.options.priority is not None: - rule.options.priority = None - for term in self.terminals: - term.priority = 0 - - # TODO Deprecate lexer_callbacks? - self.lexer_conf = LexerConf( - self.terminals, re_module, self.ignore_tokens, self.options.postlex, - self.options.lexer_callbacks, self.options.g_regex_flags, use_bytes=self.options.use_bytes, strict=self.options.strict - ) - - if self.options.parser: - self.parser = self._build_parser() - elif lexer: - self.lexer = self._build_lexer() - - if cache_fn: - logger.debug('Saving grammar to cache: %s', cache_fn) - try: - with FS.open(cache_fn, 'wb') as f: - assert cache_sha256 is not None - f.write(cache_sha256.encode('utf8') + b'\n') - pickle.dump(used_files, f) - self.save(f, _LOAD_ALLOWED_OPTIONS) - except IOError as e: - logger.exception("Failed to save Lark to cache: %r.", cache_fn, e) - - if __doc__: - __doc__ += "\n\n" + LarkOptions.OPTIONS_DOC - - __serialize_fields__ = 'parser', 'rules', 'options' - - def _build_lexer(self, dont_ignore: bool=False) -> BasicLexer: - lexer_conf = self.lexer_conf - if dont_ignore: - from copy import copy - lexer_conf = copy(lexer_conf) - lexer_conf.ignore = () - return BasicLexer(lexer_conf) - - def _prepare_callbacks(self) -> None: - self._callbacks = {} - # we don't need these callbacks if we aren't building a tree - if self.options.ambiguity != 'forest': - self._parse_tree_builder = ParseTreeBuilder( - self.rules, - self.options.tree_class or Tree, - self.options.propagate_positions, - self.options.parser != 'lalr' and self.options.ambiguity == 'explicit', - self.options.maybe_placeholders - ) - self._callbacks = self._parse_tree_builder.create_callback(self.options.transformer) - self._callbacks.update(_get_lexer_callbacks(self.options.transformer, self.terminals)) - - def _build_parser(self) -> "ParsingFrontend": - self._prepare_callbacks() - _validate_frontend_args(self.options.parser, self.options.lexer) - parser_conf = ParserConf(self.rules, self._callbacks, self.options.start) - return _construct_parsing_frontend( - self.options.parser, - self.options.lexer, - self.lexer_conf, - parser_conf, - options=self.options - ) - - def save(self, f, exclude_options: Collection[str] = ()) -> None: - """Saves the instance into the given file object - - Useful for caching and multiprocessing. - """ - if self.options.parser != 'lalr': - raise NotImplementedError("Lark.save() is only implemented for the LALR(1) parser.") - data, m = self.memo_serialize([TerminalDef, Rule]) - if exclude_options: - data["options"] = {n: v for n, v in data["options"].items() if n not in exclude_options} - pickle.dump({'data': data, 'memo': m}, f, protocol=pickle.HIGHEST_PROTOCOL) - - @classmethod - def load(cls: Type[_T], f) -> _T: - """Loads an instance from the given file object - - Useful for caching and multiprocessing. - """ - inst = cls.__new__(cls) - return inst._load(f) - - def _deserialize_lexer_conf(self, data: Dict[str, Any], memo: Dict[int, Union[TerminalDef, Rule]], options: LarkOptions) -> LexerConf: - lexer_conf = LexerConf.deserialize(data['lexer_conf'], memo) - lexer_conf.callbacks = options.lexer_callbacks or {} - lexer_conf.re_module = regex if options.regex else re - lexer_conf.use_bytes = options.use_bytes - lexer_conf.g_regex_flags = options.g_regex_flags - lexer_conf.skip_validation = True - lexer_conf.postlex = options.postlex - return lexer_conf - - def _load(self: _T, f: Any, **kwargs) -> _T: - if isinstance(f, dict): - d = f - else: - d = pickle.load(f) - memo_json = d['memo'] - data = d['data'] - - assert memo_json - memo = SerializeMemoizer.deserialize(memo_json, {'Rule': Rule, 'TerminalDef': TerminalDef}, {}) - options = dict(data['options']) - if (set(kwargs) - _LOAD_ALLOWED_OPTIONS) & set(LarkOptions._defaults): - raise ConfigurationError("Some options are not allowed when loading a Parser: {}" - .format(set(kwargs) - _LOAD_ALLOWED_OPTIONS)) - options.update(kwargs) - self.options = LarkOptions.deserialize(options, memo) - self.rules = [Rule.deserialize(r, memo) for r in data['rules']] - self.source_path = '' - _validate_frontend_args(self.options.parser, self.options.lexer) - self.lexer_conf = self._deserialize_lexer_conf(data['parser'], memo, self.options) - self.terminals = self.lexer_conf.terminals - self._prepare_callbacks() - self._terminals_dict = {t.name: t for t in self.terminals} - self.parser = _deserialize_parsing_frontend( - data['parser'], - memo, - self.lexer_conf, - self._callbacks, - self.options, # Not all, but multiple attributes are used - ) - return self - - @classmethod - def _load_from_dict(cls, data, memo, **kwargs): - inst = cls.__new__(cls) - return inst._load({'data': data, 'memo': memo}, **kwargs) - - @classmethod - def open(cls: Type[_T], grammar_filename: str, rel_to: Optional[str]=None, **options) -> _T: - """Create an instance of Lark with the grammar given by its filename - - If ``rel_to`` is provided, the function will find the grammar filename in relation to it. - - Example: - - >>> Lark.open("grammar_file.lark", rel_to=__file__, parser="lalr") - Lark(...) - - """ - if rel_to: - basepath = os.path.dirname(rel_to) - grammar_filename = os.path.join(basepath, grammar_filename) - with open(grammar_filename, encoding='utf8') as f: - return cls(f, **options) - - @classmethod - def open_from_package(cls: Type[_T], package: str, grammar_path: str, search_paths: 'Sequence[str]'=[""], **options) -> _T: - """Create an instance of Lark with the grammar loaded from within the package `package`. - This allows grammar loading from zipapps. - - Imports in the grammar will use the `package` and `search_paths` provided, through `FromPackageLoader` - - Example: - - Lark.open_from_package(__name__, "example.lark", ("grammars",), parser=...) - """ - package_loader = FromPackageLoader(package, search_paths) - full_path, text = package_loader(None, grammar_path) - options.setdefault('source_path', full_path) - options.setdefault('import_paths', []) - options['import_paths'].append(package_loader) - return cls(text, **options) - - def __repr__(self): - return 'Lark(open(%r), parser=%r, lexer=%r, ...)' % (self.source_path, self.options.parser, self.options.lexer) - - - def lex(self, text: str, dont_ignore: bool=False) -> Iterator[Token]: - """Only lex (and postlex) the text, without parsing it. Only relevant when lexer='basic' - - When dont_ignore=True, the lexer will return all tokens, even those marked for %ignore. - - :raises UnexpectedCharacters: In case the lexer cannot find a suitable match. - """ - lexer: Lexer - if not hasattr(self, 'lexer') or dont_ignore: - lexer = self._build_lexer(dont_ignore) - else: - lexer = self.lexer - lexer_thread = LexerThread.from_text(lexer, text) - stream = lexer_thread.lex(None) - if self.options.postlex: - return self.options.postlex.process(stream) - return stream - - def get_terminal(self, name: str) -> TerminalDef: - """Get information about a terminal""" - return self._terminals_dict[name] - - def parse_interactive(self, text: Optional[str]=None, start: Optional[str]=None) -> 'InteractiveParser': - """Start an interactive parsing session. - - Parameters: - text (str, optional): Text to be parsed. Required for ``resume_parse()``. - start (str, optional): Start symbol - - Returns: - A new InteractiveParser instance. - - See Also: ``Lark.parse()`` - """ - return self.parser.parse_interactive(text, start=start) - - def parse(self, text: str, start: Optional[str]=None, on_error: 'Optional[Callable[[UnexpectedInput], bool]]'=None) -> 'ParseTree': - """Parse the given text, according to the options provided. - - Parameters: - text (str): Text to be parsed. - start (str, optional): Required if Lark was given multiple possible start symbols (using the start option). - on_error (function, optional): if provided, will be called on UnexpectedToken error. Return true to resume parsing. - LALR only. See examples/advanced/error_handling.py for an example of how to use on_error. - - Returns: - If a transformer is supplied to ``__init__``, returns whatever is the - result of the transformation. Otherwise, returns a Tree instance. - - :raises UnexpectedInput: On a parse error, one of these sub-exceptions will rise: - ``UnexpectedCharacters``, ``UnexpectedToken``, or ``UnexpectedEOF``. - For convenience, these sub-exceptions also inherit from ``ParserError`` and ``LexerError``. - - """ - return self.parser.parse(text, start=start, on_error=on_error) - - -###} diff --git a/server/libs/lark/lexer.py b/server/libs/lark/lexer.py deleted file mode 100644 index 9061d60..0000000 --- a/server/libs/lark/lexer.py +++ /dev/null @@ -1,678 +0,0 @@ -# Lexer Implementation - -from abc import abstractmethod, ABC -import re -from contextlib import suppress -from typing import ( - TypeVar, Type, Dict, Iterator, Collection, Callable, Optional, FrozenSet, Any, - ClassVar, TYPE_CHECKING, overload -) -from types import ModuleType -import warnings -try: - import interegular -except ImportError: - pass -if TYPE_CHECKING: - from .common import LexerConf - from .parsers.lalr_parser_state import ParserState - -from .utils import classify, get_regexp_width, Serialize, logger -from .exceptions import UnexpectedCharacters, LexError, UnexpectedToken -from .grammar import TOKEN_DEFAULT_PRIORITY - - -###{standalone -from copy import copy - -try: # For the standalone parser, we need to make sure that has_interegular is False to avoid NameErrors later on - has_interegular = bool(interegular) -except NameError: - has_interegular = False - -class Pattern(Serialize, ABC): - "An abstraction over regular expressions." - - value: str - flags: Collection[str] - raw: Optional[str] - type: ClassVar[str] - - def __init__(self, value: str, flags: Collection[str] = (), raw: Optional[str] = None) -> None: - self.value = value - self.flags = frozenset(flags) - self.raw = raw - - def __repr__(self): - return repr(self.to_regexp()) - - # Pattern Hashing assumes all subclasses have a different priority! - def __hash__(self): - return hash((type(self), self.value, self.flags)) - - def __eq__(self, other): - return type(self) == type(other) and self.value == other.value and self.flags == other.flags - - @abstractmethod - def to_regexp(self) -> str: - raise NotImplementedError() - - @property - @abstractmethod - def min_width(self) -> int: - raise NotImplementedError() - - @property - @abstractmethod - def max_width(self) -> int: - raise NotImplementedError() - - def _get_flags(self, value): - for f in self.flags: - value = ('(?%s:%s)' % (f, value)) - return value - - -class PatternStr(Pattern): - __serialize_fields__ = 'value', 'flags', 'raw' - - type: ClassVar[str] = "str" - - def to_regexp(self) -> str: - return self._get_flags(re.escape(self.value)) - - @property - def min_width(self) -> int: - return len(self.value) - - @property - def max_width(self) -> int: - return len(self.value) - - -class PatternRE(Pattern): - __serialize_fields__ = 'value', 'flags', 'raw', '_width' - - type: ClassVar[str] = "re" - - def to_regexp(self) -> str: - return self._get_flags(self.value) - - _width = None - def _get_width(self): - if self._width is None: - self._width = get_regexp_width(self.to_regexp()) - return self._width - - @property - def min_width(self) -> int: - return self._get_width()[0] - - @property - def max_width(self) -> int: - return self._get_width()[1] - - -class TerminalDef(Serialize): - "A definition of a terminal" - __serialize_fields__ = 'name', 'pattern', 'priority' - __serialize_namespace__ = PatternStr, PatternRE - - name: str - pattern: Pattern - priority: int - - def __init__(self, name: str, pattern: Pattern, priority: int = TOKEN_DEFAULT_PRIORITY) -> None: - assert isinstance(pattern, Pattern), pattern - self.name = name - self.pattern = pattern - self.priority = priority - - def __repr__(self): - return '%s(%r, %r)' % (type(self).__name__, self.name, self.pattern) - - def user_repr(self) -> str: - if self.name.startswith('__'): # We represent a generated terminal - return self.pattern.raw or self.name - else: - return self.name - -_T = TypeVar('_T', bound="Token") - -class Token(str): - """A string with meta-information, that is produced by the lexer. - - When parsing text, the resulting chunks of the input that haven't been discarded, - will end up in the tree as Token instances. The Token class inherits from Python's ``str``, - so normal string comparisons and operations will work as expected. - - Attributes: - type: Name of the token (as specified in grammar) - value: Value of the token (redundant, as ``token.value == token`` will always be true) - start_pos: The index of the token in the text - line: The line of the token in the text (starting with 1) - column: The column of the token in the text (starting with 1) - end_line: The line where the token ends - end_column: The next column after the end of the token. For example, - if the token is a single character with a column value of 4, - end_column will be 5. - end_pos: the index where the token ends (basically ``start_pos + len(token)``) - """ - __slots__ = ('type', 'start_pos', 'value', 'line', 'column', 'end_line', 'end_column', 'end_pos') - - __match_args__ = ('type', 'value') - - type: str - start_pos: Optional[int] - value: Any - line: Optional[int] - column: Optional[int] - end_line: Optional[int] - end_column: Optional[int] - end_pos: Optional[int] - - - @overload - def __new__( - cls, - type: str, - value: Any, - start_pos: Optional[int] = None, - line: Optional[int] = None, - column: Optional[int] = None, - end_line: Optional[int] = None, - end_column: Optional[int] = None, - end_pos: Optional[int] = None - ) -> 'Token': - ... - - @overload - def __new__( - cls, - type_: str, - value: Any, - start_pos: Optional[int] = None, - line: Optional[int] = None, - column: Optional[int] = None, - end_line: Optional[int] = None, - end_column: Optional[int] = None, - end_pos: Optional[int] = None - ) -> 'Token': ... - - def __new__(cls, *args, **kwargs): - if "type_" in kwargs: - warnings.warn("`type_` is deprecated use `type` instead", DeprecationWarning) - - if "type" in kwargs: - raise TypeError("Error: using both 'type' and the deprecated 'type_' as arguments.") - kwargs["type"] = kwargs.pop("type_") - - return cls._future_new(*args, **kwargs) - - - @classmethod - def _future_new(cls, type, value, start_pos=None, line=None, column=None, end_line=None, end_column=None, end_pos=None): - inst = super(Token, cls).__new__(cls, value) - - inst.type = type - inst.start_pos = start_pos - inst.value = value - inst.line = line - inst.column = column - inst.end_line = end_line - inst.end_column = end_column - inst.end_pos = end_pos - return inst - - @overload - def update(self, type: Optional[str] = None, value: Optional[Any] = None) -> 'Token': - ... - - @overload - def update(self, type_: Optional[str] = None, value: Optional[Any] = None) -> 'Token': - ... - - def update(self, *args, **kwargs): - if "type_" in kwargs: - warnings.warn("`type_` is deprecated use `type` instead", DeprecationWarning) - - if "type" in kwargs: - raise TypeError("Error: using both 'type' and the deprecated 'type_' as arguments.") - kwargs["type"] = kwargs.pop("type_") - - return self._future_update(*args, **kwargs) - - def _future_update(self, type: Optional[str] = None, value: Optional[Any] = None) -> 'Token': - return Token.new_borrow_pos( - type if type is not None else self.type, - value if value is not None else self.value, - self - ) - - @classmethod - def new_borrow_pos(cls: Type[_T], type_: str, value: Any, borrow_t: 'Token') -> _T: - return cls(type_, value, borrow_t.start_pos, borrow_t.line, borrow_t.column, borrow_t.end_line, borrow_t.end_column, borrow_t.end_pos) - - def __reduce__(self): - return (self.__class__, (self.type, self.value, self.start_pos, self.line, self.column)) - - def __repr__(self): - return 'Token(%r, %r)' % (self.type, self.value) - - def __deepcopy__(self, memo): - return Token(self.type, self.value, self.start_pos, self.line, self.column) - - def __eq__(self, other): - if isinstance(other, Token) and self.type != other.type: - return False - - return str.__eq__(self, other) - - __hash__ = str.__hash__ - - -class LineCounter: - "A utility class for keeping track of line & column information" - - __slots__ = 'char_pos', 'line', 'column', 'line_start_pos', 'newline_char' - - def __init__(self, newline_char): - self.newline_char = newline_char - self.char_pos = 0 - self.line = 1 - self.column = 1 - self.line_start_pos = 0 - - def __eq__(self, other): - if not isinstance(other, LineCounter): - return NotImplemented - - return self.char_pos == other.char_pos and self.newline_char == other.newline_char - - def feed(self, token: Token, test_newline=True): - """Consume a token and calculate the new line & column. - - As an optional optimization, set test_newline=False if token doesn't contain a newline. - """ - if test_newline: - newlines = token.count(self.newline_char) - if newlines: - self.line += newlines - self.line_start_pos = self.char_pos + token.rindex(self.newline_char) + 1 - - self.char_pos += len(token) - self.column = self.char_pos - self.line_start_pos + 1 - - -class UnlessCallback: - def __init__(self, scanner): - self.scanner = scanner - - def __call__(self, t): - res = self.scanner.match(t.value, 0) - if res: - _value, t.type = res - return t - - -class CallChain: - def __init__(self, callback1, callback2, cond): - self.callback1 = callback1 - self.callback2 = callback2 - self.cond = cond - - def __call__(self, t): - t2 = self.callback1(t) - return self.callback2(t) if self.cond(t2) else t2 - - -def _get_match(re_, regexp, s, flags): - m = re_.match(regexp, s, flags) - if m: - return m.group(0) - -def _create_unless(terminals, g_regex_flags, re_, use_bytes): - tokens_by_type = classify(terminals, lambda t: type(t.pattern)) - assert len(tokens_by_type) <= 2, tokens_by_type.keys() - embedded_strs = set() - callback = {} - for retok in tokens_by_type.get(PatternRE, []): - unless = [] - for strtok in tokens_by_type.get(PatternStr, []): - if strtok.priority != retok.priority: - continue - s = strtok.pattern.value - if s == _get_match(re_, retok.pattern.to_regexp(), s, g_regex_flags): - unless.append(strtok) - if strtok.pattern.flags <= retok.pattern.flags: - embedded_strs.add(strtok) - if unless: - callback[retok.name] = UnlessCallback(Scanner(unless, g_regex_flags, re_, match_whole=True, use_bytes=use_bytes)) - - new_terminals = [t for t in terminals if t not in embedded_strs] - return new_terminals, callback - - -class Scanner: - def __init__(self, terminals, g_regex_flags, re_, use_bytes, match_whole=False): - self.terminals = terminals - self.g_regex_flags = g_regex_flags - self.re_ = re_ - self.use_bytes = use_bytes - self.match_whole = match_whole - - self.allowed_types = {t.name for t in self.terminals} - - self._mres = self._build_mres(terminals, len(terminals)) - - def _build_mres(self, terminals, max_size): - # Python sets an unreasonable group limit (currently 100) in its re module - # Worse, the only way to know we reached it is by catching an AssertionError! - # This function recursively tries less and less groups until it's successful. - postfix = '$' if self.match_whole else '' - mres = [] - while terminals: - pattern = u'|'.join(u'(?P<%s>%s)' % (t.name, t.pattern.to_regexp() + postfix) for t in terminals[:max_size]) - if self.use_bytes: - pattern = pattern.encode('latin-1') - try: - mre = self.re_.compile(pattern, self.g_regex_flags) - except AssertionError: # Yes, this is what Python provides us.. :/ - return self._build_mres(terminals, max_size // 2) - - mres.append(mre) - terminals = terminals[max_size:] - return mres - - def match(self, text, pos): - for mre in self._mres: - m = mre.match(text, pos) - if m: - return m.group(0), m.lastgroup - - -def _regexp_has_newline(r: str): - r"""Expressions that may indicate newlines in a regexp: - - newlines (\n) - - escaped newline (\\n) - - anything but ([^...]) - - any-char (.) when the flag (?s) exists - - spaces (\s) - """ - return '\n' in r or '\\n' in r or '\\s' in r or '[^' in r or ('(?s' in r and '.' in r) - - -class LexerState: - """Represents the current state of the lexer as it scans the text - (Lexer objects are only instantiated per grammar, not per text) - """ - - __slots__ = 'text', 'line_ctr', 'last_token' - - text: str - line_ctr: LineCounter - last_token: Optional[Token] - - def __init__(self, text: str, line_ctr: Optional[LineCounter]=None, last_token: Optional[Token]=None): - self.text = text - self.line_ctr = line_ctr or LineCounter(b'\n' if isinstance(text, bytes) else '\n') - self.last_token = last_token - - def __eq__(self, other): - if not isinstance(other, LexerState): - return NotImplemented - - return self.text is other.text and self.line_ctr == other.line_ctr and self.last_token == other.last_token - - def __copy__(self): - return type(self)(self.text, copy(self.line_ctr), self.last_token) - - -class LexerThread: - """A thread that ties a lexer instance and a lexer state, to be used by the parser - """ - - def __init__(self, lexer: 'Lexer', lexer_state: LexerState): - self.lexer = lexer - self.state = lexer_state - - @classmethod - def from_text(cls, lexer: 'Lexer', text: str) -> 'LexerThread': - return cls(lexer, LexerState(text)) - - def lex(self, parser_state): - return self.lexer.lex(self.state, parser_state) - - def __copy__(self): - return type(self)(self.lexer, copy(self.state)) - - _Token = Token - - -_Callback = Callable[[Token], Token] - -class Lexer(ABC): - """Lexer interface - - Method Signatures: - lex(self, lexer_state, parser_state) -> Iterator[Token] - """ - @abstractmethod - def lex(self, lexer_state: LexerState, parser_state: Any) -> Iterator[Token]: - return NotImplemented - - def make_lexer_state(self, text): - "Deprecated" - return LexerState(text) - - -def _check_regex_collisions(terminal_to_regexp: Dict[TerminalDef, str], comparator, strict_mode, max_collisions_to_show=8): - if not comparator: - comparator = interegular.Comparator.from_regexes(terminal_to_regexp) - - # When in strict mode, we only ever try to provide one example, so taking - # a long time for that should be fine - max_time = 2 if strict_mode else 0.2 - - # We don't want to show too many collisions. - if comparator.count_marked_pairs() >= max_collisions_to_show: - return - for group in classify(terminal_to_regexp, lambda t: t.priority).values(): - for a, b in comparator.check(group, skip_marked=True): - assert a.priority == b.priority - # Mark this pair to not repeat warnings when multiple different BasicLexers see the same collision - comparator.mark(a, b) - - # Notify the user - message = f"Collision between Terminals {a.name} and {b.name}. " - try: - example = comparator.get_example_overlap(a, b, max_time).format_multiline() - except ValueError: - # Couldn't find an example within max_time steps. - example = "No example could be found fast enough. However, the collision does still exists" - if strict_mode: - raise LexError(f"{message}\n{example}") - logger.warning("%s The lexer will choose between them arbitrarily.\n%s", message, example) - if comparator.count_marked_pairs() >= max_collisions_to_show: - logger.warning("Found 8 regex collisions, will not check for more.") - return - - -class AbstractBasicLexer(Lexer): - terminals_by_name: Dict[str, TerminalDef] - - @abstractmethod - def __init__(self, conf: 'LexerConf', comparator=None) -> None: - ... - - @abstractmethod - def next_token(self, lex_state: LexerState, parser_state: Any = None) -> Token: - ... - - def lex(self, state: LexerState, parser_state: Any) -> Iterator[Token]: - with suppress(EOFError): - while True: - yield self.next_token(state, parser_state) - - -class BasicLexer(AbstractBasicLexer): - terminals: Collection[TerminalDef] - ignore_types: FrozenSet[str] - newline_types: FrozenSet[str] - user_callbacks: Dict[str, _Callback] - callback: Dict[str, _Callback] - re: ModuleType - - def __init__(self, conf: 'LexerConf', comparator=None) -> None: - terminals = list(conf.terminals) - assert all(isinstance(t, TerminalDef) for t in terminals), terminals - - self.re = conf.re_module - - if not conf.skip_validation: - # Sanitization - terminal_to_regexp = {} - for t in terminals: - regexp = t.pattern.to_regexp() - try: - self.re.compile(regexp, conf.g_regex_flags) - except self.re.error: - raise LexError("Cannot compile token %s: %s" % (t.name, t.pattern)) - - if t.pattern.min_width == 0: - raise LexError("Lexer does not allow zero-width terminals. (%s: %s)" % (t.name, t.pattern)) - if t.pattern.type == "re": - terminal_to_regexp[t] = regexp - - if not (set(conf.ignore) <= {t.name for t in terminals}): - raise LexError("Ignore terminals are not defined: %s" % (set(conf.ignore) - {t.name for t in terminals})) - - if has_interegular: - _check_regex_collisions(terminal_to_regexp, comparator, conf.strict) - elif conf.strict: - raise LexError("interegular must be installed for strict mode. Use `pip install 'lark[interegular]'`.") - - # Init - self.newline_types = frozenset(t.name for t in terminals if _regexp_has_newline(t.pattern.to_regexp())) - self.ignore_types = frozenset(conf.ignore) - - terminals.sort(key=lambda x: (-x.priority, -x.pattern.max_width, -len(x.pattern.value), x.name)) - self.terminals = terminals - self.user_callbacks = conf.callbacks - self.g_regex_flags = conf.g_regex_flags - self.use_bytes = conf.use_bytes - self.terminals_by_name = conf.terminals_by_name - - self._scanner = None - - def _build_scanner(self): - terminals, self.callback = _create_unless(self.terminals, self.g_regex_flags, self.re, self.use_bytes) - assert all(self.callback.values()) - - for type_, f in self.user_callbacks.items(): - if type_ in self.callback: - # Already a callback there, probably UnlessCallback - self.callback[type_] = CallChain(self.callback[type_], f, lambda t: t.type == type_) - else: - self.callback[type_] = f - - self._scanner = Scanner(terminals, self.g_regex_flags, self.re, self.use_bytes) - - @property - def scanner(self): - if self._scanner is None: - self._build_scanner() - return self._scanner - - def match(self, text, pos): - return self.scanner.match(text, pos) - - def next_token(self, lex_state: LexerState, parser_state: Any = None) -> Token: - line_ctr = lex_state.line_ctr - while line_ctr.char_pos < len(lex_state.text): - res = self.match(lex_state.text, line_ctr.char_pos) - if not res: - allowed = self.scanner.allowed_types - self.ignore_types - if not allowed: - allowed = {""} - raise UnexpectedCharacters(lex_state.text, line_ctr.char_pos, line_ctr.line, line_ctr.column, - allowed=allowed, token_history=lex_state.last_token and [lex_state.last_token], - state=parser_state, terminals_by_name=self.terminals_by_name) - - value, type_ = res - - ignored = type_ in self.ignore_types - t = None - if not ignored or type_ in self.callback: - t = Token(type_, value, line_ctr.char_pos, line_ctr.line, line_ctr.column) - line_ctr.feed(value, type_ in self.newline_types) - if t is not None: - t.end_line = line_ctr.line - t.end_column = line_ctr.column - t.end_pos = line_ctr.char_pos - if t.type in self.callback: - t = self.callback[t.type](t) - if not ignored: - if not isinstance(t, Token): - raise LexError("Callbacks must return a token (returned %r)" % t) - lex_state.last_token = t - return t - - # EOF - raise EOFError(self) - - -class ContextualLexer(Lexer): - lexers: Dict[int, AbstractBasicLexer] - root_lexer: AbstractBasicLexer - - BasicLexer: Type[AbstractBasicLexer] = BasicLexer - - def __init__(self, conf: 'LexerConf', states: Dict[int, Collection[str]], always_accept: Collection[str]=()) -> None: - terminals = list(conf.terminals) - terminals_by_name = conf.terminals_by_name - - trad_conf = copy(conf) - trad_conf.terminals = terminals - - if has_interegular and not conf.skip_validation: - comparator = interegular.Comparator.from_regexes({t: t.pattern.to_regexp() for t in terminals}) - else: - comparator = None - lexer_by_tokens: Dict[FrozenSet[str], AbstractBasicLexer] = {} - self.lexers = {} - for state, accepts in states.items(): - key = frozenset(accepts) - try: - lexer = lexer_by_tokens[key] - except KeyError: - accepts = set(accepts) | set(conf.ignore) | set(always_accept) - lexer_conf = copy(trad_conf) - lexer_conf.terminals = [terminals_by_name[n] for n in accepts if n in terminals_by_name] - lexer = self.BasicLexer(lexer_conf, comparator) - lexer_by_tokens[key] = lexer - - self.lexers[state] = lexer - - assert trad_conf.terminals is terminals - trad_conf.skip_validation = True # We don't need to verify all terminals again - self.root_lexer = self.BasicLexer(trad_conf, comparator) - - def lex(self, lexer_state: LexerState, parser_state: 'ParserState') -> Iterator[Token]: - try: - while True: - lexer = self.lexers[parser_state.position] - yield lexer.next_token(lexer_state, parser_state) - except EOFError: - pass - except UnexpectedCharacters as e: - # In the contextual lexer, UnexpectedCharacters can mean that the terminal is defined, but not in the current context. - # This tests the input against the global context, to provide a nicer error. - try: - last_token = lexer_state.last_token # Save last_token. Calling root_lexer.next_token will change this to the wrong token - token = self.root_lexer.next_token(lexer_state, parser_state) - raise UnexpectedToken(token, e.allowed, state=parser_state, token_history=[last_token], terminals_by_name=self.root_lexer.terminals_by_name) - except UnexpectedCharacters: - raise e # Raise the original UnexpectedCharacters. The root lexer raises it with the wrong expected set. - -###} diff --git a/server/libs/lark/load_grammar.py b/server/libs/lark/load_grammar.py deleted file mode 100644 index 362a845..0000000 --- a/server/libs/lark/load_grammar.py +++ /dev/null @@ -1,1428 +0,0 @@ -"""Parses and compiles Lark grammars into an internal representation. -""" - -import hashlib -import os.path -import sys -from collections import namedtuple -from copy import copy, deepcopy -import pkgutil -from ast import literal_eval -from contextlib import suppress -from typing import List, Tuple, Union, Callable, Dict, Optional, Sequence, Generator - -from .utils import bfs, logger, classify_bool, is_id_continue, is_id_start, bfs_all_unique, small_factors, OrderedSet -from .lexer import Token, TerminalDef, PatternStr, PatternRE, Pattern - -from .parse_tree_builder import ParseTreeBuilder -from .parser_frontends import ParsingFrontend -from .common import LexerConf, ParserConf -from .grammar import RuleOptions, Rule, Terminal, NonTerminal, Symbol, TOKEN_DEFAULT_PRIORITY -from .utils import classify, dedup_list -from .exceptions import GrammarError, UnexpectedCharacters, UnexpectedToken, ParseError, UnexpectedInput - -from .tree import Tree, SlottedTree as ST -from .visitors import Transformer, Visitor, v_args, Transformer_InPlace, Transformer_NonRecursive -inline_args = v_args(inline=True) - -IMPORT_PATHS = ['grammars'] - -EXT = '.lark' - -_RE_FLAGS = 'imslux' - -_EMPTY = Symbol('__empty__') - -_TERMINAL_NAMES = { - '.' : 'DOT', - ',' : 'COMMA', - ':' : 'COLON', - ';' : 'SEMICOLON', - '+' : 'PLUS', - '-' : 'MINUS', - '*' : 'STAR', - '/' : 'SLASH', - '\\' : 'BACKSLASH', - '|' : 'VBAR', - '?' : 'QMARK', - '!' : 'BANG', - '@' : 'AT', - '#' : 'HASH', - '$' : 'DOLLAR', - '%' : 'PERCENT', - '^' : 'CIRCUMFLEX', - '&' : 'AMPERSAND', - '_' : 'UNDERSCORE', - '<' : 'LESSTHAN', - '>' : 'MORETHAN', - '=' : 'EQUAL', - '"' : 'DBLQUOTE', - '\'' : 'QUOTE', - '`' : 'BACKQUOTE', - '~' : 'TILDE', - '(' : 'LPAR', - ')' : 'RPAR', - '{' : 'LBRACE', - '}' : 'RBRACE', - '[' : 'LSQB', - ']' : 'RSQB', - '\n' : 'NEWLINE', - '\r\n' : 'CRLF', - '\t' : 'TAB', - ' ' : 'SPACE', -} - -# Grammar Parser -TERMINALS = { - '_LPAR': r'\(', - '_RPAR': r'\)', - '_LBRA': r'\[', - '_RBRA': r'\]', - '_LBRACE': r'\{', - '_RBRACE': r'\}', - 'OP': '[+*]|[?](?![a-z_])', - '_COLON': ':', - '_COMMA': ',', - '_OR': r'\|', - '_DOT': r'\.(?!\.)', - '_DOTDOT': r'\.\.', - 'TILDE': '~', - 'RULE_MODIFIERS': '(!|![?]?|[?]!?)(?=[_a-z])', - 'RULE': '_?[a-z][_a-z0-9]*', - 'TERMINAL': '_?[A-Z][_A-Z0-9]*', - 'STRING': r'"(\\"|\\\\|[^"\n])*?"i?', - 'REGEXP': r'/(?!/)(\\/|\\\\|[^/])*?/[%s]*' % _RE_FLAGS, - '_NL': r'(\r?\n)+\s*', - '_NL_OR': r'(\r?\n)+\s*\|', - 'WS': r'[ \t]+', - 'COMMENT': r'\s*//[^\n]*|\s*#[^\n]*', - 'BACKSLASH': r'\\[ ]*\n', - '_TO': '->', - '_IGNORE': r'%ignore', - '_OVERRIDE': r'%override', - '_DECLARE': r'%declare', - '_EXTEND': r'%extend', - '_IMPORT': r'%import', - 'NUMBER': r'[+-]?\d+', -} - -RULES = { - 'start': ['_list'], - '_list': ['_item', '_list _item'], - '_item': ['rule', 'term', 'ignore', 'import', 'declare', 'override', 'extend', '_NL'], - - 'rule': ['rule_modifiers RULE template_params priority _COLON expansions _NL'], - 'rule_modifiers': ['RULE_MODIFIERS', - ''], - 'priority': ['_DOT NUMBER', - ''], - 'template_params': ['_LBRACE _template_params _RBRACE', - ''], - '_template_params': ['RULE', - '_template_params _COMMA RULE'], - 'expansions': ['_expansions'], - '_expansions': ['alias', - '_expansions _OR alias', - '_expansions _NL_OR alias'], - - '?alias': ['expansion _TO nonterminal', 'expansion'], - 'expansion': ['_expansion'], - - '_expansion': ['', '_expansion expr'], - - '?expr': ['atom', - 'atom OP', - 'atom TILDE NUMBER', - 'atom TILDE NUMBER _DOTDOT NUMBER', - ], - - '?atom': ['_LPAR expansions _RPAR', - 'maybe', - 'value'], - - 'value': ['terminal', - 'nonterminal', - 'literal', - 'range', - 'template_usage'], - - 'terminal': ['TERMINAL'], - 'nonterminal': ['RULE'], - - '?name': ['RULE', 'TERMINAL'], - '?symbol': ['terminal', 'nonterminal'], - - 'maybe': ['_LBRA expansions _RBRA'], - 'range': ['STRING _DOTDOT STRING'], - - 'template_usage': ['nonterminal _LBRACE _template_args _RBRACE'], - '_template_args': ['value', - '_template_args _COMMA value'], - - 'term': ['TERMINAL _COLON expansions _NL', - 'TERMINAL _DOT NUMBER _COLON expansions _NL'], - 'override': ['_OVERRIDE rule', - '_OVERRIDE term'], - 'extend': ['_EXTEND rule', - '_EXTEND term'], - 'ignore': ['_IGNORE expansions _NL'], - 'declare': ['_DECLARE _declare_args _NL'], - 'import': ['_IMPORT _import_path _NL', - '_IMPORT _import_path _LPAR name_list _RPAR _NL', - '_IMPORT _import_path _TO name _NL'], - - '_import_path': ['import_lib', 'import_rel'], - 'import_lib': ['_import_args'], - 'import_rel': ['_DOT _import_args'], - '_import_args': ['name', '_import_args _DOT name'], - - 'name_list': ['_name_list'], - '_name_list': ['name', '_name_list _COMMA name'], - - '_declare_args': ['symbol', '_declare_args symbol'], - 'literal': ['REGEXP', 'STRING'], -} - - -# Value 5 keeps the number of states in the lalr parser somewhat minimal -# It isn't optimal, but close to it. See PR #949 -SMALL_FACTOR_THRESHOLD = 5 -# The Threshold whether repeat via ~ are split up into different rules -# 50 is chosen since it keeps the number of states low and therefore lalr analysis time low, -# while not being to overaggressive and unnecessarily creating rules that might create shift/reduce conflicts. -# (See PR #949) -REPEAT_BREAK_THRESHOLD = 50 - - -class FindRuleSize(Transformer): - def __init__(self, keep_all_tokens: bool): - self.keep_all_tokens = keep_all_tokens - - def _will_not_get_removed(self, sym: Symbol) -> bool: - if isinstance(sym, NonTerminal): - return not sym.name.startswith('_') - if isinstance(sym, Terminal): - return self.keep_all_tokens or not sym.filter_out - if sym is _EMPTY: - return False - assert False, sym - - def _args_as_int(self, args: List[Union[int, Symbol]]) -> Generator[int, None, None]: - for a in args: - if isinstance(a, int): - yield a - elif isinstance(a, Symbol): - yield 1 if self._will_not_get_removed(a) else 0 - else: - assert False - - def expansion(self, args) -> int: - return sum(self._args_as_int(args)) - - def expansions(self, args) -> int: - return max(self._args_as_int(args)) - - -@inline_args -class EBNF_to_BNF(Transformer_InPlace): - def __init__(self): - self.new_rules = [] - self.rules_cache = {} - self.prefix = 'anon' - self.i = 0 - self.rule_options = None - - def _name_rule(self, inner: str): - new_name = '__%s_%s_%d' % (self.prefix, inner, self.i) - self.i += 1 - return new_name - - def _add_rule(self, key, name, expansions): - t = NonTerminal(name) - self.new_rules.append((name, expansions, self.rule_options)) - self.rules_cache[key] = t - return t - - def _add_recurse_rule(self, type_: str, expr: Tree): - try: - return self.rules_cache[expr] - except KeyError: - new_name = self._name_rule(type_) - t = NonTerminal(new_name) - tree = ST('expansions', [ - ST('expansion', [expr]), - ST('expansion', [t, expr]) - ]) - return self._add_rule(expr, new_name, tree) - - def _add_repeat_rule(self, a, b, target, atom): - """Generate a rule that repeats target ``a`` times, and repeats atom ``b`` times. - - When called recursively (into target), it repeats atom for x(n) times, where: - x(0) = 1 - x(n) = a(n) * x(n-1) + b - - Example rule when a=3, b=4: - - new_rule: target target target atom atom atom atom - - """ - key = (a, b, target, atom) - try: - return self.rules_cache[key] - except KeyError: - new_name = self._name_rule('repeat_a%d_b%d' % (a, b)) - tree = ST('expansions', [ST('expansion', [target] * a + [atom] * b)]) - return self._add_rule(key, new_name, tree) - - def _add_repeat_opt_rule(self, a, b, target, target_opt, atom): - """Creates a rule that matches atom 0 to (a*n+b)-1 times. - - When target matches n times atom, and target_opt 0 to n-1 times target_opt, - - First we generate target * i followed by target_opt, for i from 0 to a-1 - These match 0 to n*a - 1 times atom - - Then we generate target * a followed by atom * i, for i from 0 to b-1 - These match n*a to n*a + b-1 times atom - - The created rule will not have any shift/reduce conflicts so that it can be used with lalr - - Example rule when a=3, b=4: - - new_rule: target_opt - | target target_opt - | target target target_opt - - | target target target - | target target target atom - | target target target atom atom - | target target target atom atom atom - - """ - key = (a, b, target, atom, "opt") - try: - return self.rules_cache[key] - except KeyError: - new_name = self._name_rule('repeat_a%d_b%d_opt' % (a, b)) - tree = ST('expansions', [ - ST('expansion', [target]*i + [target_opt]) for i in range(a) - ] + [ - ST('expansion', [target]*a + [atom]*i) for i in range(b) - ]) - return self._add_rule(key, new_name, tree) - - def _generate_repeats(self, rule: Tree, mn: int, mx: int): - """Generates a rule tree that repeats ``rule`` exactly between ``mn`` to ``mx`` times. - """ - # For a small number of repeats, we can take the naive approach - if mx < REPEAT_BREAK_THRESHOLD: - return ST('expansions', [ST('expansion', [rule] * n) for n in range(mn, mx + 1)]) - - # For large repeat values, we break the repetition into sub-rules. - # We treat ``rule~mn..mx`` as ``rule~mn rule~0..(diff=mx-mn)``. - # We then use small_factors to split up mn and diff up into values [(a, b), ...] - # This values are used with the help of _add_repeat_rule and _add_repeat_rule_opt - # to generate a complete rule/expression that matches the corresponding number of repeats - mn_target = rule - for a, b in small_factors(mn, SMALL_FACTOR_THRESHOLD): - mn_target = self._add_repeat_rule(a, b, mn_target, rule) - if mx == mn: - return mn_target - - diff = mx - mn + 1 # We add one because _add_repeat_opt_rule generates rules that match one less - diff_factors = small_factors(diff, SMALL_FACTOR_THRESHOLD) - diff_target = rule # Match rule 1 times - diff_opt_target = ST('expansion', []) # match rule 0 times (e.g. up to 1 -1 times) - for a, b in diff_factors[:-1]: - diff_opt_target = self._add_repeat_opt_rule(a, b, diff_target, diff_opt_target, rule) - diff_target = self._add_repeat_rule(a, b, diff_target, rule) - - a, b = diff_factors[-1] - diff_opt_target = self._add_repeat_opt_rule(a, b, diff_target, diff_opt_target, rule) - - return ST('expansions', [ST('expansion', [mn_target] + [diff_opt_target])]) - - def expr(self, rule: Tree, op: Token, *args): - if op.value == '?': - empty = ST('expansion', []) - return ST('expansions', [rule, empty]) - elif op.value == '+': - # a : b c+ d - # --> - # a : b _c d - # _c : _c c | c; - return self._add_recurse_rule('plus', rule) - elif op.value == '*': - # a : b c* d - # --> - # a : b _c? d - # _c : _c c | c; - new_name = self._add_recurse_rule('star', rule) - return ST('expansions', [new_name, ST('expansion', [])]) - elif op.value == '~': - if len(args) == 1: - mn = mx = int(args[0]) - else: - mn, mx = map(int, args) - if mx < mn or mn < 0: - raise GrammarError("Bad Range for %s (%d..%d isn't allowed)" % (rule, mn, mx)) - - return self._generate_repeats(rule, mn, mx) - - assert False, op - - def maybe(self, rule: Tree): - keep_all_tokens = self.rule_options and self.rule_options.keep_all_tokens - rule_size = FindRuleSize(keep_all_tokens).transform(rule) - empty = ST('expansion', [_EMPTY] * rule_size) - return ST('expansions', [rule, empty]) - - -class SimplifyRule_Visitor(Visitor): - - @staticmethod - def _flatten(tree: Tree): - while tree.expand_kids_by_data(tree.data): - pass - - def expansion(self, tree: Tree): - # rules_list unpacking - # a : b (c|d) e - # --> - # a : b c e | b d e - # - # In AST terms: - # expansion(b, expansions(c, d), e) - # --> - # expansions( expansion(b, c, e), expansion(b, d, e) ) - - self._flatten(tree) - - for i, child in enumerate(tree.children): - if isinstance(child, Tree) and child.data == 'expansions': - tree.data = 'expansions' - tree.children = [self.visit(ST('expansion', [option if i == j else other - for j, other in enumerate(tree.children)])) - for option in dedup_list(child.children)] - self._flatten(tree) - break - - def alias(self, tree): - rule, alias_name = tree.children - if rule.data == 'expansions': - aliases = [] - for child in tree.children[0].children: - aliases.append(ST('alias', [child, alias_name])) - tree.data = 'expansions' - tree.children = aliases - - def expansions(self, tree: Tree): - self._flatten(tree) - # Ensure all children are unique - if len(set(tree.children)) != len(tree.children): - tree.children = dedup_list(tree.children) # dedup is expensive, so try to minimize its use - - -class RuleTreeToText(Transformer): - def expansions(self, x): - return x - - def expansion(self, symbols): - return symbols, None - - def alias(self, x): - (expansion, _alias), alias = x - assert _alias is None, (alias, expansion, '-', _alias) # Double alias not allowed - return expansion, alias.name - - -class PrepareAnonTerminals(Transformer_InPlace): - """Create a unique list of anonymous terminals. Attempt to give meaningful names to them when we add them""" - - def __init__(self, terminals): - self.terminals = terminals - self.term_set = {td.name for td in self.terminals} - self.term_reverse = {td.pattern: td for td in terminals} - self.i = 0 - self.rule_options = None - - @inline_args - def pattern(self, p): - value = p.value - if p in self.term_reverse and p.flags != self.term_reverse[p].pattern.flags: - raise GrammarError(u'Conflicting flags for the same terminal: %s' % p) - - term_name = None - - if isinstance(p, PatternStr): - try: - # If already defined, use the user-defined terminal name - term_name = self.term_reverse[p].name - except KeyError: - # Try to assign an indicative anon-terminal name - try: - term_name = _TERMINAL_NAMES[value] - except KeyError: - if value and is_id_continue(value) and is_id_start(value[0]) and value.upper() not in self.term_set: - term_name = value.upper() - - if term_name in self.term_set: - term_name = None - - elif isinstance(p, PatternRE): - if p in self.term_reverse: # Kind of a weird placement.name - term_name = self.term_reverse[p].name - else: - assert False, p - - if term_name is None: - term_name = '__ANON_%d' % self.i - self.i += 1 - - if term_name not in self.term_set: - assert p not in self.term_reverse - self.term_set.add(term_name) - termdef = TerminalDef(term_name, p) - self.term_reverse[p] = termdef - self.terminals.append(termdef) - - filter_out = False if self.rule_options and self.rule_options.keep_all_tokens else isinstance(p, PatternStr) - - return Terminal(term_name, filter_out=filter_out) - - -class _ReplaceSymbols(Transformer_InPlace): - """Helper for ApplyTemplates""" - - def __init__(self): - self.names = {} - - def value(self, c): - if len(c) == 1 and isinstance(c[0], Symbol) and c[0].name in self.names: - return self.names[c[0].name] - return self.__default__('value', c, None) - - def template_usage(self, c): - name = c[0].name - if name in self.names: - return self.__default__('template_usage', [self.names[name]] + c[1:], None) - return self.__default__('template_usage', c, None) - - -class ApplyTemplates(Transformer_InPlace): - """Apply the templates, creating new rules that represent the used templates""" - - def __init__(self, rule_defs): - self.rule_defs = rule_defs - self.replacer = _ReplaceSymbols() - self.created_templates = set() - - def template_usage(self, c): - name = c[0].name - args = c[1:] - result_name = "%s{%s}" % (name, ",".join(a.name for a in args)) - if result_name not in self.created_templates: - self.created_templates.add(result_name) - (_n, params, tree, options) ,= (t for t in self.rule_defs if t[0] == name) - assert len(params) == len(args), args - result_tree = deepcopy(tree) - self.replacer.names = dict(zip(params, args)) - self.replacer.transform(result_tree) - self.rule_defs.append((result_name, [], result_tree, deepcopy(options))) - return NonTerminal(result_name) - - -def _rfind(s, choices): - return max(s.rfind(c) for c in choices) - - -def eval_escaping(s): - w = '' - i = iter(s) - for n in i: - w += n - if n == '\\': - try: - n2 = next(i) - except StopIteration: - raise GrammarError("Literal ended unexpectedly (bad escaping): `%r`" % s) - if n2 == '\\': - w += '\\\\' - elif n2 not in 'Uuxnftr': - w += '\\' - w += n2 - w = w.replace('\\"', '"').replace("'", "\\'") - - to_eval = "u'''%s'''" % w - try: - s = literal_eval(to_eval) - except SyntaxError as e: - raise GrammarError(s, e) - - return s - - -def _literal_to_pattern(literal): - assert isinstance(literal, Token) - v = literal.value - flag_start = _rfind(v, '/"')+1 - assert flag_start > 0 - flags = v[flag_start:] - assert all(f in _RE_FLAGS for f in flags), flags - - if literal.type == 'STRING' and '\n' in v: - raise GrammarError('You cannot put newlines in string literals') - - if literal.type == 'REGEXP' and '\n' in v and 'x' not in flags: - raise GrammarError('You can only use newlines in regular expressions ' - 'with the `x` (verbose) flag') - - v = v[:flag_start] - assert v[0] == v[-1] and v[0] in '"/' - x = v[1:-1] - - s = eval_escaping(x) - - if s == "": - raise GrammarError("Empty terminals are not allowed (%s)" % literal) - - if literal.type == 'STRING': - s = s.replace('\\\\', '\\') - return PatternStr(s, flags, raw=literal.value) - elif literal.type == 'REGEXP': - return PatternRE(s, flags, raw=literal.value) - else: - assert False, 'Invariant failed: literal.type not in ["STRING", "REGEXP"]' - - -@inline_args -class PrepareLiterals(Transformer_InPlace): - def literal(self, literal): - return ST('pattern', [_literal_to_pattern(literal)]) - - def range(self, start, end): - assert start.type == end.type == 'STRING' - start = start.value[1:-1] - end = end.value[1:-1] - assert len(eval_escaping(start)) == len(eval_escaping(end)) == 1 - regexp = '[%s-%s]' % (start, end) - return ST('pattern', [PatternRE(regexp)]) - - -def _make_joined_pattern(regexp, flags_set) -> PatternRE: - return PatternRE(regexp, ()) - -class TerminalTreeToPattern(Transformer_NonRecursive): - def pattern(self, ps): - p ,= ps - return p - - def expansion(self, items: List[Pattern]) -> Pattern: - if not items: - return PatternStr('') - - if len(items) == 1: - return items[0] - - pattern = ''.join(i.to_regexp() for i in items) - return _make_joined_pattern(pattern, {i.flags for i in items}) - - def expansions(self, exps: List[Pattern]) -> Pattern: - if len(exps) == 1: - return exps[0] - - # Do a bit of sorting to make sure that the longest option is returned - # (Python's re module otherwise prefers just 'l' when given (l|ll) and both could match) - exps.sort(key=lambda x: (-x.max_width, -x.min_width, -len(x.value))) - - pattern = '(?:%s)' % ('|'.join(i.to_regexp() for i in exps)) - return _make_joined_pattern(pattern, {i.flags for i in exps}) - - def expr(self, args) -> Pattern: - inner: Pattern - inner, op = args[:2] - if op == '~': - if len(args) == 3: - op = "{%d}" % int(args[2]) - else: - mn, mx = map(int, args[2:]) - if mx < mn: - raise GrammarError("Bad Range for %s (%d..%d isn't allowed)" % (inner, mn, mx)) - op = "{%d,%d}" % (mn, mx) - else: - assert len(args) == 2 - return PatternRE('(?:%s)%s' % (inner.to_regexp(), op), inner.flags) - - def maybe(self, expr): - return self.expr(expr + ['?']) - - def alias(self, t): - raise GrammarError("Aliasing not allowed in terminals (You used -> in the wrong place)") - - def value(self, v): - return v[0] - - -class ValidateSymbols(Transformer_InPlace): - def value(self, v): - v ,= v - assert isinstance(v, (Tree, Symbol)) - return v - - -def nr_deepcopy_tree(t): - """Deepcopy tree `t` without recursion""" - return Transformer_NonRecursive(False).transform(t) - - -class Grammar: - - term_defs: List[Tuple[str, Tuple[Tree, int]]] - rule_defs: List[Tuple[str, Tuple[str, ...], Tree, RuleOptions]] - ignore: List[str] - - def __init__(self, rule_defs: List[Tuple[str, Tuple[str, ...], Tree, RuleOptions]], term_defs: List[Tuple[str, Tuple[Tree, int]]], ignore: List[str]) -> None: - self.term_defs = term_defs - self.rule_defs = rule_defs - self.ignore = ignore - - def compile(self, start, terminals_to_keep) -> Tuple[List[TerminalDef], List[Rule], List[str]]: - # We change the trees in-place (to support huge grammars) - # So deepcopy allows calling compile more than once. - term_defs = [(n, (nr_deepcopy_tree(t), p)) for n, (t, p) in self.term_defs] - rule_defs = [(n, p, nr_deepcopy_tree(t), o) for n, p, t, o in self.rule_defs] - - # =================== - # Compile Terminals - # =================== - - # Convert terminal-trees to strings/regexps - - for name, (term_tree, priority) in term_defs: - if term_tree is None: # Terminal added through %declare - continue - expansions = list(term_tree.find_data('expansion')) - if len(expansions) == 1 and not expansions[0].children: - raise GrammarError("Terminals cannot be empty (%s)" % name) - - transformer = PrepareLiterals() * TerminalTreeToPattern() - terminals = [TerminalDef(name, transformer.transform(term_tree), priority) - for name, (term_tree, priority) in term_defs if term_tree] - - # ================= - # Compile Rules - # ================= - - # 1. Pre-process terminals - anon_tokens_transf = PrepareAnonTerminals(terminals) - transformer = PrepareLiterals() * ValidateSymbols() * anon_tokens_transf # Adds to terminals - - # 2. Inline Templates - - transformer *= ApplyTemplates(rule_defs) - - # 3. Convert EBNF to BNF (and apply step 1 & 2) - ebnf_to_bnf = EBNF_to_BNF() - rules = [] - i = 0 - while i < len(rule_defs): # We have to do it like this because rule_defs might grow due to templates - name, params, rule_tree, options = rule_defs[i] - i += 1 - if len(params) != 0: # Dont transform templates - continue - rule_options = RuleOptions(keep_all_tokens=True) if options and options.keep_all_tokens else None - ebnf_to_bnf.rule_options = rule_options - ebnf_to_bnf.prefix = name - anon_tokens_transf.rule_options = rule_options - tree = transformer.transform(rule_tree) - res: Tree = ebnf_to_bnf.transform(tree) - rules.append((name, res, options)) - rules += ebnf_to_bnf.new_rules - - assert len(rules) == len({name for name, _t, _o in rules}), "Whoops, name collision" - - # 4. Compile tree to Rule objects - rule_tree_to_text = RuleTreeToText() - - simplify_rule = SimplifyRule_Visitor() - compiled_rules: List[Rule] = [] - for rule_content in rules: - name, tree, options = rule_content - simplify_rule.visit(tree) - expansions = rule_tree_to_text.transform(tree) - - for i, (expansion, alias) in enumerate(expansions): - if alias and name.startswith('_'): - raise GrammarError("Rule %s is marked for expansion (it starts with an underscore) and isn't allowed to have aliases (alias=%s)"% (name, alias)) - - empty_indices = tuple(x==_EMPTY for x in expansion) - if any(empty_indices): - exp_options = copy(options) or RuleOptions() - exp_options.empty_indices = empty_indices - expansion = [x for x in expansion if x!=_EMPTY] - else: - exp_options = options - - for sym in expansion: - assert isinstance(sym, Symbol) - if sym.is_term and exp_options and exp_options.keep_all_tokens: - assert isinstance(sym, Terminal) - sym.filter_out = False - rule = Rule(NonTerminal(name), expansion, i, alias, exp_options) - compiled_rules.append(rule) - - # Remove duplicates of empty rules, throw error for non-empty duplicates - if len(set(compiled_rules)) != len(compiled_rules): - duplicates = classify(compiled_rules, lambda x: x) - for dups in duplicates.values(): - if len(dups) > 1: - if dups[0].expansion: - raise GrammarError("Rules defined twice: %s\n\n(Might happen due to colliding expansion of optionals: [] or ?)" - % ''.join('\n * %s' % i for i in dups)) - - # Empty rule; assert all other attributes are equal - assert len({(r.alias, r.order, r.options) for r in dups}) == len(dups) - - # Remove duplicates - compiled_rules = list(OrderedSet(compiled_rules)) - - # Filter out unused rules - while True: - c = len(compiled_rules) - used_rules = {s for r in compiled_rules - for s in r.expansion - if isinstance(s, NonTerminal) - and s != r.origin} - used_rules |= {NonTerminal(s) for s in start} - compiled_rules, unused = classify_bool(compiled_rules, lambda r: r.origin in used_rules) - for r in unused: - logger.debug("Unused rule: %s", r) - if len(compiled_rules) == c: - break - - # Filter out unused terminals - if terminals_to_keep != '*': - used_terms = {t.name for r in compiled_rules - for t in r.expansion - if isinstance(t, Terminal)} - terminals, unused = classify_bool(terminals, lambda t: t.name in used_terms or t.name in self.ignore or t.name in terminals_to_keep) - if unused: - logger.debug("Unused terminals: %s", [t.name for t in unused]) - - return terminals, compiled_rules, self.ignore - - -PackageResource = namedtuple('PackageResource', 'pkg_name path') - - -class FromPackageLoader: - """ - Provides a simple way of creating custom import loaders that load from packages via ``pkgutil.get_data`` instead of using `open`. - This allows them to be compatible even from within zip files. - - Relative imports are handled, so you can just freely use them. - - pkg_name: The name of the package. You can probably provide `__name__` most of the time - search_paths: All the path that will be search on absolute imports. - """ - - pkg_name: str - search_paths: Sequence[str] - - def __init__(self, pkg_name: str, search_paths: Sequence[str]=("", )) -> None: - self.pkg_name = pkg_name - self.search_paths = search_paths - - def __repr__(self): - return "%s(%r, %r)" % (type(self).__name__, self.pkg_name, self.search_paths) - - def __call__(self, base_path: Union[None, str, PackageResource], grammar_path: str) -> Tuple[PackageResource, str]: - if base_path is None: - to_try = self.search_paths - else: - # Check whether or not the importing grammar was loaded by this module. - if not isinstance(base_path, PackageResource) or base_path.pkg_name != self.pkg_name: - # Technically false, but FileNotFound doesn't exist in python2.7, and this message should never reach the end user anyway - raise IOError() - to_try = [base_path.path] - - err = None - for path in to_try: - full_path = os.path.join(path, grammar_path) - try: - text: Optional[bytes] = pkgutil.get_data(self.pkg_name, full_path) - except IOError as e: - err = e - continue - else: - return PackageResource(self.pkg_name, full_path), (text.decode() if text else '') - - raise IOError('Cannot find grammar in given paths') from err - - -stdlib_loader = FromPackageLoader('lark', IMPORT_PATHS) - - - -def resolve_term_references(term_dict): - # TODO Solve with transitive closure (maybe) - - while True: - changed = False - for name, token_tree in term_dict.items(): - if token_tree is None: # Terminal added through %declare - continue - for exp in token_tree.find_data('value'): - item ,= exp.children - if isinstance(item, NonTerminal): - raise GrammarError("Rules aren't allowed inside terminals (%s in %s)" % (item, name)) - elif isinstance(item, Terminal): - try: - term_value = term_dict[item.name] - except KeyError: - raise GrammarError("Terminal used but not defined: %s" % item.name) - assert term_value is not None - exp.children[0] = term_value - changed = True - else: - assert isinstance(item, Tree) - if not changed: - break - - for name, term in term_dict.items(): - if term: # Not just declared - for child in term.children: - ids = [id(x) for x in child.iter_subtrees()] - if id(term) in ids: - raise GrammarError("Recursion in terminal '%s' (recursion is only allowed in rules, not terminals)" % name) - - - -def symbol_from_strcase(s): - assert isinstance(s, str) - return Terminal(s, filter_out=s.startswith('_')) if s.isupper() else NonTerminal(s) - -@inline_args -class PrepareGrammar(Transformer_InPlace): - def terminal(self, name): - return Terminal(str(name), filter_out=name.startswith('_')) - - def nonterminal(self, name): - return NonTerminal(name.value) - - -def _find_used_symbols(tree): - assert tree.data == 'expansions' - return {t.name for x in tree.find_data('expansion') - for t in x.scan_values(lambda t: isinstance(t, Symbol))} - - -def _get_parser(): - try: - return _get_parser.cache - except AttributeError: - terminals = [TerminalDef(name, PatternRE(value)) for name, value in TERMINALS.items()] - - rules = [(name.lstrip('?'), x, RuleOptions(expand1=name.startswith('?'))) - for name, x in RULES.items()] - rules = [Rule(NonTerminal(r), [symbol_from_strcase(s) for s in x.split()], i, None, o) - for r, xs, o in rules for i, x in enumerate(xs)] - - callback = ParseTreeBuilder(rules, ST).create_callback() - import re - lexer_conf = LexerConf(terminals, re, ['WS', 'COMMENT', 'BACKSLASH']) - parser_conf = ParserConf(rules, callback, ['start']) - lexer_conf.lexer_type = 'basic' - parser_conf.parser_type = 'lalr' - _get_parser.cache = ParsingFrontend(lexer_conf, parser_conf, None) - return _get_parser.cache - -GRAMMAR_ERRORS = [ - ('Incorrect type of value', ['a: 1\n']), - ('Unclosed parenthesis', ['a: (\n']), - ('Unmatched closing parenthesis', ['a: )\n', 'a: [)\n', 'a: (]\n']), - ('Expecting rule or terminal definition (missing colon)', ['a\n', 'A\n', 'a->\n', 'A->\n', 'a A\n']), - ('Illegal name for rules or terminals', ['Aa:\n']), - ('Alias expects lowercase name', ['a: -> "a"\n']), - ('Unexpected colon', ['a::\n', 'a: b:\n', 'a: B:\n', 'a: "a":\n']), - ('Misplaced operator', ['a: b??', 'a: b(?)', 'a:+\n', 'a:?\n', 'a:*\n', 'a:|*\n']), - ('Expecting option ("|") or a new rule or terminal definition', ['a:a\n()\n']), - ('Terminal names cannot contain dots', ['A.B\n']), - ('Expecting rule or terminal definition', ['"a"\n']), - ('%import expects a name', ['%import "a"\n']), - ('%ignore expects a value', ['%ignore %import\n']), - ] - -def _translate_parser_exception(parse, e): - error = e.match_examples(parse, GRAMMAR_ERRORS, use_accepts=True) - if error: - return error - elif 'STRING' in e.expected: - return "Expecting a value" - -def _parse_grammar(text, name, start='start'): - try: - tree = _get_parser().parse(text + '\n', start) - except UnexpectedCharacters as e: - context = e.get_context(text) - raise GrammarError("Unexpected input at line %d column %d in %s: \n\n%s" % - (e.line, e.column, name, context)) - except UnexpectedToken as e: - context = e.get_context(text) - error = _translate_parser_exception(_get_parser().parse, e) - if error: - raise GrammarError("%s, at line %s column %s\n\n%s" % (error, e.line, e.column, context)) - raise - - return PrepareGrammar().transform(tree) - - -def _error_repr(error): - if isinstance(error, UnexpectedToken): - error2 = _translate_parser_exception(_get_parser().parse, error) - if error2: - return error2 - expected = ', '.join(error.accepts or error.expected) - return "Unexpected token %r. Expected one of: {%s}" % (str(error.token), expected) - else: - return str(error) - -def _search_interactive_parser(interactive_parser, predicate): - def expand(node): - path, p = node - for choice in p.choices(): - t = Token(choice, '') - try: - new_p = p.feed_token(t) - except ParseError: # Illegal - pass - else: - yield path + (choice,), new_p - - for path, p in bfs_all_unique([((), interactive_parser)], expand): - if predicate(p): - return path, p - -def find_grammar_errors(text: str, start: str='start') -> List[Tuple[UnexpectedInput, str]]: - errors = [] - def on_error(e): - errors.append((e, _error_repr(e))) - - # recover to a new line - token_path, _ = _search_interactive_parser(e.interactive_parser.as_immutable(), lambda p: '_NL' in p.choices()) - for token_type in token_path: - e.interactive_parser.feed_token(Token(token_type, '')) - e.interactive_parser.feed_token(Token('_NL', '\n')) - return True - - _tree = _get_parser().parse(text + '\n', start, on_error=on_error) - - errors_by_line = classify(errors, lambda e: e[0].line) - errors = [el[0] for el in errors_by_line.values()] # already sorted - - for e in errors: - e[0].interactive_parser = None - return errors - - -def _get_mangle(prefix, aliases, base_mangle=None): - def mangle(s): - if s in aliases: - s = aliases[s] - else: - if s[0] == '_': - s = '_%s__%s' % (prefix, s[1:]) - else: - s = '%s__%s' % (prefix, s) - if base_mangle is not None: - s = base_mangle(s) - return s - return mangle - -def _mangle_definition_tree(exp, mangle): - if mangle is None: - return exp - exp = deepcopy(exp) # TODO: is this needed? - for t in exp.iter_subtrees(): - for i, c in enumerate(t.children): - if isinstance(c, Symbol): - t.children[i] = c.renamed(mangle) - - return exp - -def _make_rule_tuple(modifiers_tree, name, params, priority_tree, expansions): - if modifiers_tree.children: - m ,= modifiers_tree.children - expand1 = '?' in m - if expand1 and name.startswith('_'): - raise GrammarError("Inlined rules (_rule) cannot use the ?rule modifier.") - keep_all_tokens = '!' in m - else: - keep_all_tokens = False - expand1 = False - - if priority_tree.children: - p ,= priority_tree.children - priority = int(p) - else: - priority = None - - if params is not None: - params = [t.value for t in params.children] # For the grammar parser - - return name, params, expansions, RuleOptions(keep_all_tokens, expand1, priority=priority, - template_source=(name if params else None)) - - -class Definition: - def __init__(self, is_term, tree, params=(), options=None): - self.is_term = is_term - self.tree = tree - self.params = tuple(params) - self.options = options - -class GrammarBuilder: - - global_keep_all_tokens: bool - import_paths: List[Union[str, Callable]] - used_files: Dict[str, str] - - _definitions: Dict[str, Definition] - _ignore_names: List[str] - - def __init__(self, global_keep_all_tokens: bool=False, import_paths: Optional[List[Union[str, Callable]]]=None, used_files: Optional[Dict[str, str]]=None) -> None: - self.global_keep_all_tokens = global_keep_all_tokens - self.import_paths = import_paths or [] - self.used_files = used_files or {} - - self._definitions: Dict[str, Definition] = {} - self._ignore_names: List[str] = [] - - def _grammar_error(self, is_term, msg, *names): - args = {} - for i, name in enumerate(names, start=1): - postfix = '' if i == 1 else str(i) - args['name' + postfix] = name - args['type' + postfix] = lowercase_type = ("rule", "terminal")[is_term] - args['Type' + postfix] = lowercase_type.title() - raise GrammarError(msg.format(**args)) - - def _check_options(self, is_term, options): - if is_term: - if options is None: - options = 1 - elif not isinstance(options, int): - raise GrammarError("Terminal require a single int as 'options' (e.g. priority), got %s" % (type(options),)) - else: - if options is None: - options = RuleOptions() - elif not isinstance(options, RuleOptions): - raise GrammarError("Rules require a RuleOptions instance as 'options'") - if self.global_keep_all_tokens: - options.keep_all_tokens = True - return options - - - def _define(self, name, is_term, exp, params=(), options=None, *, override=False): - if name in self._definitions: - if not override: - self._grammar_error(is_term, "{Type} '{name}' defined more than once", name) - elif override: - self._grammar_error(is_term, "Cannot override a nonexisting {type} {name}", name) - - if name.startswith('__'): - self._grammar_error(is_term, 'Names starting with double-underscore are reserved (Error at {name})', name) - - self._definitions[name] = Definition(is_term, exp, params, self._check_options(is_term, options)) - - def _extend(self, name, is_term, exp, params=(), options=None): - if name not in self._definitions: - self._grammar_error(is_term, "Can't extend {type} {name} as it wasn't defined before", name) - - d = self._definitions[name] - - if is_term != d.is_term: - self._grammar_error(is_term, "Cannot extend {type} {name} - one is a terminal, while the other is not.", name) - if tuple(params) != d.params: - self._grammar_error(is_term, "Cannot extend {type} with different parameters: {name}", name) - - if d.tree is None: - self._grammar_error(is_term, "Can't extend {type} {name} - it is abstract.", name) - - # TODO: think about what to do with 'options' - base = d.tree - - assert isinstance(base, Tree) and base.data == 'expansions' - base.children.insert(0, exp) - - def _ignore(self, exp_or_name): - if isinstance(exp_or_name, str): - self._ignore_names.append(exp_or_name) - else: - assert isinstance(exp_or_name, Tree) - t = exp_or_name - if t.data == 'expansions' and len(t.children) == 1: - t2 ,= t.children - if t2.data=='expansion' and len(t2.children) == 1: - item ,= t2.children - if item.data == 'value': - item ,= item.children - if isinstance(item, Terminal): - # Keep terminal name, no need to create a new definition - self._ignore_names.append(item.name) - return - - name = '__IGNORE_%d'% len(self._ignore_names) - self._ignore_names.append(name) - self._definitions[name] = Definition(True, t, options=TOKEN_DEFAULT_PRIORITY) - - def _unpack_import(self, stmt, grammar_name): - if len(stmt.children) > 1: - path_node, arg1 = stmt.children - else: - path_node, = stmt.children - arg1 = None - - if isinstance(arg1, Tree): # Multi import - dotted_path = tuple(path_node.children) - names = arg1.children - aliases = dict(zip(names, names)) # Can't have aliased multi import, so all aliases will be the same as names - else: # Single import - dotted_path = tuple(path_node.children[:-1]) - if not dotted_path: - name ,= path_node.children - raise GrammarError("Nothing was imported from grammar `%s`" % name) - name = path_node.children[-1] # Get name from dotted path - aliases = {name.value: (arg1 or name).value} # Aliases if exist - - if path_node.data == 'import_lib': # Import from library - base_path = None - else: # Relative import - if grammar_name == '': # Import relative to script file path if grammar is coded in script - try: - base_file = os.path.abspath(sys.modules['__main__'].__file__) - except AttributeError: - base_file = None - else: - base_file = grammar_name # Import relative to grammar file path if external grammar file - if base_file: - if isinstance(base_file, PackageResource): - base_path = PackageResource(base_file.pkg_name, os.path.split(base_file.path)[0]) - else: - base_path = os.path.split(base_file)[0] - else: - base_path = os.path.abspath(os.path.curdir) - - return dotted_path, base_path, aliases - - def _unpack_definition(self, tree, mangle): - - if tree.data == 'rule': - name, params, exp, opts = _make_rule_tuple(*tree.children) - is_term = False - else: - name = tree.children[0].value - params = () # TODO terminal templates - opts = int(tree.children[1]) if len(tree.children) == 3 else TOKEN_DEFAULT_PRIORITY # priority - exp = tree.children[-1] - is_term = True - - if mangle is not None: - params = tuple(mangle(p) for p in params) - name = mangle(name) - - exp = _mangle_definition_tree(exp, mangle) - return name, is_term, exp, params, opts - - - def load_grammar(self, grammar_text: str, grammar_name: str="", mangle: Optional[Callable[[str], str]]=None) -> None: - tree = _parse_grammar(grammar_text, grammar_name) - - imports: Dict[Tuple[str, ...], Tuple[Optional[str], Dict[str, str]]] = {} - - for stmt in tree.children: - if stmt.data == 'import': - dotted_path, base_path, aliases = self._unpack_import(stmt, grammar_name) - try: - import_base_path, import_aliases = imports[dotted_path] - assert base_path == import_base_path, 'Inconsistent base_path for %s.' % '.'.join(dotted_path) - import_aliases.update(aliases) - except KeyError: - imports[dotted_path] = base_path, aliases - - for dotted_path, (base_path, aliases) in imports.items(): - self.do_import(dotted_path, base_path, aliases, mangle) - - for stmt in tree.children: - if stmt.data in ('term', 'rule'): - self._define(*self._unpack_definition(stmt, mangle)) - elif stmt.data == 'override': - r ,= stmt.children - self._define(*self._unpack_definition(r, mangle), override=True) - elif stmt.data == 'extend': - r ,= stmt.children - self._extend(*self._unpack_definition(r, mangle)) - elif stmt.data == 'ignore': - # if mangle is not None, we shouldn't apply ignore, since we aren't in a toplevel grammar - if mangle is None: - self._ignore(*stmt.children) - elif stmt.data == 'declare': - for symbol in stmt.children: - assert isinstance(symbol, Symbol), symbol - is_term = isinstance(symbol, Terminal) - if mangle is None: - name = symbol.name - else: - name = mangle(symbol.name) - self._define(name, is_term, None) - elif stmt.data == 'import': - pass - else: - assert False, stmt - - - term_defs = { name: d.tree - for name, d in self._definitions.items() - if d.is_term - } - resolve_term_references(term_defs) - - - def _remove_unused(self, used): - def rule_dependencies(symbol): - try: - d = self._definitions[symbol] - except KeyError: - return [] - if d.is_term: - return [] - return _find_used_symbols(d.tree) - set(d.params) - - _used = set(bfs(used, rule_dependencies)) - self._definitions = {k: v for k, v in self._definitions.items() if k in _used} - - - def do_import(self, dotted_path: Tuple[str, ...], base_path: Optional[str], aliases: Dict[str, str], base_mangle: Optional[Callable[[str], str]]=None) -> None: - assert dotted_path - mangle = _get_mangle('__'.join(dotted_path), aliases, base_mangle) - grammar_path = os.path.join(*dotted_path) + EXT - to_try = self.import_paths + ([base_path] if base_path is not None else []) + [stdlib_loader] - for source in to_try: - try: - if callable(source): - joined_path, text = source(base_path, grammar_path) - else: - joined_path = os.path.join(source, grammar_path) - with open(joined_path, encoding='utf8') as f: - text = f.read() - except IOError: - continue - else: - h = sha256_digest(text) - if self.used_files.get(joined_path, h) != h: - raise RuntimeError("Grammar file was changed during importing") - self.used_files[joined_path] = h - - gb = GrammarBuilder(self.global_keep_all_tokens, self.import_paths, self.used_files) - gb.load_grammar(text, joined_path, mangle) - gb._remove_unused(map(mangle, aliases)) - for name in gb._definitions: - if name in self._definitions: - raise GrammarError("Cannot import '%s' from '%s': Symbol already defined." % (name, grammar_path)) - - self._definitions.update(**gb._definitions) - break - else: - # Search failed. Make Python throw a nice error. - open(grammar_path, encoding='utf8') - assert False, "Couldn't import grammar %s, but a corresponding file was found at a place where lark doesn't search for it" % (dotted_path,) - - - def validate(self) -> None: - for name, d in self._definitions.items(): - params = d.params - exp = d.tree - - for i, p in enumerate(params): - if p in self._definitions: - raise GrammarError("Template Parameter conflicts with rule %s (in template %s)" % (p, name)) - if p in params[:i]: - raise GrammarError("Duplicate Template Parameter %s (in template %s)" % (p, name)) - - if exp is None: # Remaining checks don't apply to abstract rules/terminals (created with %declare) - continue - - for temp in exp.find_data('template_usage'): - sym = temp.children[0].name - args = temp.children[1:] - if sym not in params: - if sym not in self._definitions: - self._grammar_error(d.is_term, "Template '%s' used but not defined (in {type} {name})" % sym, name) - if len(args) != len(self._definitions[sym].params): - expected, actual = len(self._definitions[sym].params), len(args) - self._grammar_error(d.is_term, "Wrong number of template arguments used for {name} " - "(expected %s, got %s) (in {type2} {name2})" % (expected, actual), sym, name) - - for sym in _find_used_symbols(exp): - if sym not in self._definitions and sym not in params: - self._grammar_error(d.is_term, "{Type} '{name}' used but not defined (in {type2} {name2})", sym, name) - - if not set(self._definitions).issuperset(self._ignore_names): - raise GrammarError("Terminals %s were marked to ignore but were not defined!" % (set(self._ignore_names) - set(self._definitions))) - - def build(self) -> Grammar: - self.validate() - rule_defs = [] - term_defs = [] - for name, d in self._definitions.items(): - (params, exp, options) = d.params, d.tree, d.options - if d.is_term: - assert len(params) == 0 - term_defs.append((name, (exp, options))) - else: - rule_defs.append((name, params, exp, options)) - # resolve_term_references(term_defs) - return Grammar(rule_defs, term_defs, self._ignore_names) - - -def verify_used_files(file_hashes): - for path, old in file_hashes.items(): - text = None - if isinstance(path, str) and os.path.exists(path): - with open(path, encoding='utf8') as f: - text = f.read() - elif isinstance(path, PackageResource): - with suppress(IOError): - text = pkgutil.get_data(*path).decode('utf-8') - if text is None: # We don't know how to load the path. ignore it. - continue - - current = sha256_digest(text) - if old != current: - logger.info("File %r changed, rebuilding Parser" % path) - return False - return True - -def list_grammar_imports(grammar, import_paths=[]): - "Returns a list of paths to the lark grammars imported by the given grammar (recursively)" - builder = GrammarBuilder(False, import_paths) - builder.load_grammar(grammar, '') - return list(builder.used_files.keys()) - -def load_grammar(grammar, source, import_paths, global_keep_all_tokens): - builder = GrammarBuilder(global_keep_all_tokens, import_paths) - builder.load_grammar(grammar, source) - return builder.build(), builder.used_files - - -def sha256_digest(s: str) -> str: - """Get the sha256 digest of a string - - Supports the `usedforsecurity` argument for Python 3.9+ to allow running on - a FIPS-enabled system. - """ - if sys.version_info >= (3, 9): - return hashlib.sha256(s.encode('utf8'), usedforsecurity=False).hexdigest() - else: - return hashlib.sha256(s.encode('utf8')).hexdigest() diff --git a/server/libs/lark/parse_tree_builder.py b/server/libs/lark/parse_tree_builder.py deleted file mode 100644 index e3a4171..0000000 --- a/server/libs/lark/parse_tree_builder.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Provides functions for the automatic building and shaping of the parse-tree.""" - -from typing import List - -from .exceptions import GrammarError, ConfigurationError -from .lexer import Token -from .tree import Tree -from .visitors import Transformer_InPlace -from .visitors import _vargs_meta, _vargs_meta_inline - -###{standalone -from functools import partial, wraps -from itertools import product - - -class ExpandSingleChild: - def __init__(self, node_builder): - self.node_builder = node_builder - - def __call__(self, children): - if len(children) == 1: - return children[0] - else: - return self.node_builder(children) - - - -class PropagatePositions: - def __init__(self, node_builder, node_filter=None): - self.node_builder = node_builder - self.node_filter = node_filter - - def __call__(self, children): - res = self.node_builder(children) - - if isinstance(res, Tree): - # Calculate positions while the tree is streaming, according to the rule: - # - nodes start at the start of their first child's container, - # and end at the end of their last child's container. - # Containers are nodes that take up space in text, but have been inlined in the tree. - - res_meta = res.meta - - first_meta = self._pp_get_meta(children) - if first_meta is not None: - if not hasattr(res_meta, 'line'): - # meta was already set, probably because the rule has been inlined (e.g. `?rule`) - res_meta.line = getattr(first_meta, 'container_line', first_meta.line) - res_meta.column = getattr(first_meta, 'container_column', first_meta.column) - res_meta.start_pos = getattr(first_meta, 'container_start_pos', first_meta.start_pos) - res_meta.empty = False - - res_meta.container_line = getattr(first_meta, 'container_line', first_meta.line) - res_meta.container_column = getattr(first_meta, 'container_column', first_meta.column) - res_meta.container_start_pos = getattr(first_meta, 'container_start_pos', first_meta.start_pos) - - last_meta = self._pp_get_meta(reversed(children)) - if last_meta is not None: - if not hasattr(res_meta, 'end_line'): - res_meta.end_line = getattr(last_meta, 'container_end_line', last_meta.end_line) - res_meta.end_column = getattr(last_meta, 'container_end_column', last_meta.end_column) - res_meta.end_pos = getattr(last_meta, 'container_end_pos', last_meta.end_pos) - res_meta.empty = False - - res_meta.container_end_line = getattr(last_meta, 'container_end_line', last_meta.end_line) - res_meta.container_end_column = getattr(last_meta, 'container_end_column', last_meta.end_column) - res_meta.container_end_pos = getattr(last_meta, 'container_end_pos', last_meta.end_pos) - - return res - - def _pp_get_meta(self, children): - for c in children: - if self.node_filter is not None and not self.node_filter(c): - continue - if isinstance(c, Tree): - if not c.meta.empty: - return c.meta - elif isinstance(c, Token): - return c - elif hasattr(c, '__lark_meta__'): - return c.__lark_meta__() - -def make_propagate_positions(option): - if callable(option): - return partial(PropagatePositions, node_filter=option) - elif option is True: - return PropagatePositions - elif option is False: - return None - - raise ConfigurationError('Invalid option for propagate_positions: %r' % option) - - -class ChildFilter: - def __init__(self, to_include, append_none, node_builder): - self.node_builder = node_builder - self.to_include = to_include - self.append_none = append_none - - def __call__(self, children): - filtered = [] - - for i, to_expand, add_none in self.to_include: - if add_none: - filtered += [None] * add_none - if to_expand: - filtered += children[i].children - else: - filtered.append(children[i]) - - if self.append_none: - filtered += [None] * self.append_none - - return self.node_builder(filtered) - - -class ChildFilterLALR(ChildFilter): - """Optimized childfilter for LALR (assumes no duplication in parse tree, so it's safe to change it)""" - - def __call__(self, children): - filtered = [] - for i, to_expand, add_none in self.to_include: - if add_none: - filtered += [None] * add_none - if to_expand: - if filtered: - filtered += children[i].children - else: # Optimize for left-recursion - filtered = children[i].children - else: - filtered.append(children[i]) - - if self.append_none: - filtered += [None] * self.append_none - - return self.node_builder(filtered) - - -class ChildFilterLALR_NoPlaceholders(ChildFilter): - "Optimized childfilter for LALR (assumes no duplication in parse tree, so it's safe to change it)" - def __init__(self, to_include, node_builder): - self.node_builder = node_builder - self.to_include = to_include - - def __call__(self, children): - filtered = [] - for i, to_expand in self.to_include: - if to_expand: - if filtered: - filtered += children[i].children - else: # Optimize for left-recursion - filtered = children[i].children - else: - filtered.append(children[i]) - return self.node_builder(filtered) - - -def _should_expand(sym): - return not sym.is_term and sym.name.startswith('_') - - -def maybe_create_child_filter(expansion, keep_all_tokens, ambiguous, _empty_indices: List[bool]): - # Prepare empty_indices as: How many Nones to insert at each index? - if _empty_indices: - assert _empty_indices.count(False) == len(expansion) - s = ''.join(str(int(b)) for b in _empty_indices) - empty_indices = [len(ones) for ones in s.split('0')] - assert len(empty_indices) == len(expansion)+1, (empty_indices, len(expansion)) - else: - empty_indices = [0] * (len(expansion)+1) - - to_include = [] - nones_to_add = 0 - for i, sym in enumerate(expansion): - nones_to_add += empty_indices[i] - if keep_all_tokens or not (sym.is_term and sym.filter_out): - to_include.append((i, _should_expand(sym), nones_to_add)) - nones_to_add = 0 - - nones_to_add += empty_indices[len(expansion)] - - if _empty_indices or len(to_include) < len(expansion) or any(to_expand for i, to_expand,_ in to_include): - if _empty_indices or ambiguous: - return partial(ChildFilter if ambiguous else ChildFilterLALR, to_include, nones_to_add) - else: - # LALR without placeholders - return partial(ChildFilterLALR_NoPlaceholders, [(i, x) for i,x,_ in to_include]) - - -class AmbiguousExpander: - """Deal with the case where we're expanding children ('_rule') into a parent but the children - are ambiguous. i.e. (parent->_ambig->_expand_this_rule). In this case, make the parent itself - ambiguous with as many copies as there are ambiguous children, and then copy the ambiguous children - into the right parents in the right places, essentially shifting the ambiguity up the tree.""" - def __init__(self, to_expand, tree_class, node_builder): - self.node_builder = node_builder - self.tree_class = tree_class - self.to_expand = to_expand - - def __call__(self, children): - def _is_ambig_tree(t): - return hasattr(t, 'data') and t.data == '_ambig' - - # -- When we're repeatedly expanding ambiguities we can end up with nested ambiguities. - # All children of an _ambig node should be a derivation of that ambig node, hence - # it is safe to assume that if we see an _ambig node nested within an ambig node - # it is safe to simply expand it into the parent _ambig node as an alternative derivation. - ambiguous = [] - for i, child in enumerate(children): - if _is_ambig_tree(child): - if i in self.to_expand: - ambiguous.append(i) - - child.expand_kids_by_data('_ambig') - - if not ambiguous: - return self.node_builder(children) - - expand = [child.children if i in ambiguous else (child,) for i, child in enumerate(children)] - return self.tree_class('_ambig', [self.node_builder(list(f)) for f in product(*expand)]) - - -def maybe_create_ambiguous_expander(tree_class, expansion, keep_all_tokens): - to_expand = [i for i, sym in enumerate(expansion) - if keep_all_tokens or ((not (sym.is_term and sym.filter_out)) and _should_expand(sym))] - if to_expand: - return partial(AmbiguousExpander, to_expand, tree_class) - - -class AmbiguousIntermediateExpander: - """ - Propagate ambiguous intermediate nodes and their derivations up to the - current rule. - - In general, converts - - rule - _iambig - _inter - someChildren1 - ... - _inter - someChildren2 - ... - someChildren3 - ... - - to - - _ambig - rule - someChildren1 - ... - someChildren3 - ... - rule - someChildren2 - ... - someChildren3 - ... - rule - childrenFromNestedIambigs - ... - someChildren3 - ... - ... - - propagating up any nested '_iambig' nodes along the way. - """ - - def __init__(self, tree_class, node_builder): - self.node_builder = node_builder - self.tree_class = tree_class - - def __call__(self, children): - def _is_iambig_tree(child): - return hasattr(child, 'data') and child.data == '_iambig' - - def _collapse_iambig(children): - """ - Recursively flatten the derivations of the parent of an '_iambig' - node. Returns a list of '_inter' nodes guaranteed not - to contain any nested '_iambig' nodes, or None if children does - not contain an '_iambig' node. - """ - - # Due to the structure of the SPPF, - # an '_iambig' node can only appear as the first child - if children and _is_iambig_tree(children[0]): - iambig_node = children[0] - result = [] - for grandchild in iambig_node.children: - collapsed = _collapse_iambig(grandchild.children) - if collapsed: - for child in collapsed: - child.children += children[1:] - result += collapsed - else: - new_tree = self.tree_class('_inter', grandchild.children + children[1:]) - result.append(new_tree) - return result - - collapsed = _collapse_iambig(children) - if collapsed: - processed_nodes = [self.node_builder(c.children) for c in collapsed] - return self.tree_class('_ambig', processed_nodes) - - return self.node_builder(children) - - - -def inplace_transformer(func): - @wraps(func) - def f(children): - # function name in a Transformer is a rule name. - tree = Tree(func.__name__, children) - return func(tree) - return f - - -def apply_visit_wrapper(func, name, wrapper): - if wrapper is _vargs_meta or wrapper is _vargs_meta_inline: - raise NotImplementedError("Meta args not supported for internal transformer") - - @wraps(func) - def f(children): - return wrapper(func, name, children, None) - return f - - -class ParseTreeBuilder: - def __init__(self, rules, tree_class, propagate_positions=False, ambiguous=False, maybe_placeholders=False): - self.tree_class = tree_class - self.propagate_positions = propagate_positions - self.ambiguous = ambiguous - self.maybe_placeholders = maybe_placeholders - - self.rule_builders = list(self._init_builders(rules)) - - def _init_builders(self, rules): - propagate_positions = make_propagate_positions(self.propagate_positions) - - for rule in rules: - options = rule.options - keep_all_tokens = options.keep_all_tokens - expand_single_child = options.expand1 - - wrapper_chain = list(filter(None, [ - (expand_single_child and not rule.alias) and ExpandSingleChild, - maybe_create_child_filter(rule.expansion, keep_all_tokens, self.ambiguous, options.empty_indices if self.maybe_placeholders else None), - propagate_positions, - self.ambiguous and maybe_create_ambiguous_expander(self.tree_class, rule.expansion, keep_all_tokens), - self.ambiguous and partial(AmbiguousIntermediateExpander, self.tree_class) - ])) - - yield rule, wrapper_chain - - def create_callback(self, transformer=None): - callbacks = {} - - default_handler = getattr(transformer, '__default__', None) - if default_handler: - def default_callback(data, children): - return default_handler(data, children, None) - else: - default_callback = self.tree_class - - for rule, wrapper_chain in self.rule_builders: - - user_callback_name = rule.alias or rule.options.template_source or rule.origin.name - try: - f = getattr(transformer, user_callback_name) - wrapper = getattr(f, 'visit_wrapper', None) - if wrapper is not None: - f = apply_visit_wrapper(f, user_callback_name, wrapper) - elif isinstance(transformer, Transformer_InPlace): - f = inplace_transformer(f) - except AttributeError: - f = partial(default_callback, user_callback_name) - - for w in wrapper_chain: - f = w(f) - - if rule in callbacks: - raise GrammarError("Rule '%s' already exists" % (rule,)) - - callbacks[rule] = f - - return callbacks - -###} diff --git a/server/libs/lark/parser_frontends.py b/server/libs/lark/parser_frontends.py deleted file mode 100644 index 186058a..0000000 --- a/server/libs/lark/parser_frontends.py +++ /dev/null @@ -1,257 +0,0 @@ -from typing import Any, Callable, Dict, Optional, Collection, Union, TYPE_CHECKING - -from .exceptions import ConfigurationError, GrammarError, assert_config -from .utils import get_regexp_width, Serialize -from .lexer import LexerThread, BasicLexer, ContextualLexer, Lexer -from .parsers import earley, xearley, cyk -from .parsers.lalr_parser import LALR_Parser -from .tree import Tree -from .common import LexerConf, ParserConf, _ParserArgType, _LexerArgType - -if TYPE_CHECKING: - from .parsers.lalr_analysis import ParseTableBase - - -###{standalone - -def _wrap_lexer(lexer_class): - future_interface = getattr(lexer_class, '__future_interface__', False) - if future_interface: - return lexer_class - else: - class CustomLexerWrapper(Lexer): - def __init__(self, lexer_conf): - self.lexer = lexer_class(lexer_conf) - def lex(self, lexer_state, parser_state): - return self.lexer.lex(lexer_state.text) - return CustomLexerWrapper - - -def _deserialize_parsing_frontend(data, memo, lexer_conf, callbacks, options): - parser_conf = ParserConf.deserialize(data['parser_conf'], memo) - cls = (options and options._plugins.get('LALR_Parser')) or LALR_Parser - parser = cls.deserialize(data['parser'], memo, callbacks, options.debug) - parser_conf.callbacks = callbacks - return ParsingFrontend(lexer_conf, parser_conf, options, parser=parser) - - -_parser_creators: 'Dict[str, Callable[[LexerConf, Any, Any], Any]]' = {} - - -class ParsingFrontend(Serialize): - __serialize_fields__ = 'lexer_conf', 'parser_conf', 'parser' - - lexer_conf: LexerConf - parser_conf: ParserConf - options: Any - - def __init__(self, lexer_conf: LexerConf, parser_conf: ParserConf, options, parser=None): - self.parser_conf = parser_conf - self.lexer_conf = lexer_conf - self.options = options - - # Set-up parser - if parser: # From cache - self.parser = parser - else: - create_parser = _parser_creators.get(parser_conf.parser_type) - assert create_parser is not None, "{} is not supported in standalone mode".format( - parser_conf.parser_type - ) - self.parser = create_parser(lexer_conf, parser_conf, options) - - # Set-up lexer - lexer_type = lexer_conf.lexer_type - self.skip_lexer = False - if lexer_type in ('dynamic', 'dynamic_complete'): - assert lexer_conf.postlex is None - self.skip_lexer = True - return - - if isinstance(lexer_type, type): - assert issubclass(lexer_type, Lexer) - self.lexer = _wrap_lexer(lexer_type)(lexer_conf) - elif isinstance(lexer_type, str): - create_lexer = { - 'basic': create_basic_lexer, - 'contextual': create_contextual_lexer, - }[lexer_type] - self.lexer = create_lexer(lexer_conf, self.parser, lexer_conf.postlex, options) - else: - raise TypeError("Bad value for lexer_type: {lexer_type}") - - if lexer_conf.postlex: - self.lexer = PostLexConnector(self.lexer, lexer_conf.postlex) - - def _verify_start(self, start=None): - if start is None: - start_decls = self.parser_conf.start - if len(start_decls) > 1: - raise ConfigurationError("Lark initialized with more than 1 possible start rule. Must specify which start rule to parse", start_decls) - start ,= start_decls - elif start not in self.parser_conf.start: - raise ConfigurationError("Unknown start rule %s. Must be one of %r" % (start, self.parser_conf.start)) - return start - - def _make_lexer_thread(self, text: str) -> Union[str, LexerThread]: - cls = (self.options and self.options._plugins.get('LexerThread')) or LexerThread - return text if self.skip_lexer else cls.from_text(self.lexer, text) - - def parse(self, text: str, start=None, on_error=None): - chosen_start = self._verify_start(start) - kw = {} if on_error is None else {'on_error': on_error} - stream = self._make_lexer_thread(text) - return self.parser.parse(stream, chosen_start, **kw) - - def parse_interactive(self, text: Optional[str]=None, start=None): - # TODO BREAK - Change text from Optional[str] to text: str = ''. - # Would break behavior of exhaust_lexer(), which currently raises TypeError, and after the change would just return [] - chosen_start = self._verify_start(start) - if self.parser_conf.parser_type != 'lalr': - raise ConfigurationError("parse_interactive() currently only works with parser='lalr' ") - stream = self._make_lexer_thread(text) # type: ignore[arg-type] - return self.parser.parse_interactive(stream, chosen_start) - - -def _validate_frontend_args(parser, lexer) -> None: - assert_config(parser, ('lalr', 'earley', 'cyk')) - if not isinstance(lexer, type): # not custom lexer? - expected = { - 'lalr': ('basic', 'contextual'), - 'earley': ('basic', 'dynamic', 'dynamic_complete'), - 'cyk': ('basic', ), - }[parser] - assert_config(lexer, expected, 'Parser %r does not support lexer %%r, expected one of %%s' % parser) - - -def _get_lexer_callbacks(transformer, terminals): - result = {} - for terminal in terminals: - callback = getattr(transformer, terminal.name, None) - if callback is not None: - result[terminal.name] = callback - return result - -class PostLexConnector: - def __init__(self, lexer, postlexer): - self.lexer = lexer - self.postlexer = postlexer - - def lex(self, lexer_state, parser_state): - i = self.lexer.lex(lexer_state, parser_state) - return self.postlexer.process(i) - - - -def create_basic_lexer(lexer_conf, parser, postlex, options) -> BasicLexer: - cls = (options and options._plugins.get('BasicLexer')) or BasicLexer - return cls(lexer_conf) - -def create_contextual_lexer(lexer_conf: LexerConf, parser, postlex, options) -> ContextualLexer: - cls = (options and options._plugins.get('ContextualLexer')) or ContextualLexer - parse_table: ParseTableBase[int] = parser._parse_table - states: Dict[int, Collection[str]] = {idx:list(t.keys()) for idx, t in parse_table.states.items()} - always_accept: Collection[str] = postlex.always_accept if postlex else () - return cls(lexer_conf, states, always_accept=always_accept) - -def create_lalr_parser(lexer_conf: LexerConf, parser_conf: ParserConf, options=None) -> LALR_Parser: - debug = options.debug if options else False - strict = options.strict if options else False - cls = (options and options._plugins.get('LALR_Parser')) or LALR_Parser - return cls(parser_conf, debug=debug, strict=strict) - -_parser_creators['lalr'] = create_lalr_parser - -###} - -class EarleyRegexpMatcher: - def __init__(self, lexer_conf): - self.regexps = {} - for t in lexer_conf.terminals: - regexp = t.pattern.to_regexp() - try: - width = get_regexp_width(regexp)[0] - except ValueError: - raise GrammarError("Bad regexp in token %s: %s" % (t.name, regexp)) - else: - if width == 0: - raise GrammarError("Dynamic Earley doesn't allow zero-width regexps", t) - if lexer_conf.use_bytes: - regexp = regexp.encode('utf-8') - - self.regexps[t.name] = lexer_conf.re_module.compile(regexp, lexer_conf.g_regex_flags) - - def match(self, term, text, index=0): - return self.regexps[term.name].match(text, index) - - -def create_earley_parser__dynamic(lexer_conf: LexerConf, parser_conf: ParserConf, **kw): - if lexer_conf.callbacks: - raise GrammarError("Earley's dynamic lexer doesn't support lexer_callbacks.") - - earley_matcher = EarleyRegexpMatcher(lexer_conf) - return xearley.Parser(lexer_conf, parser_conf, earley_matcher.match, **kw) - -def _match_earley_basic(term, token): - return term.name == token.type - -def create_earley_parser__basic(lexer_conf: LexerConf, parser_conf: ParserConf, **kw): - return earley.Parser(lexer_conf, parser_conf, _match_earley_basic, **kw) - -def create_earley_parser(lexer_conf: LexerConf, parser_conf: ParserConf, options) -> earley.Parser: - resolve_ambiguity = options.ambiguity == 'resolve' - debug = options.debug if options else False - tree_class = options.tree_class or Tree if options.ambiguity != 'forest' else None - - extra = {} - if lexer_conf.lexer_type == 'dynamic': - f = create_earley_parser__dynamic - elif lexer_conf.lexer_type == 'dynamic_complete': - extra['complete_lex'] = True - f = create_earley_parser__dynamic - else: - f = create_earley_parser__basic - - return f(lexer_conf, parser_conf, resolve_ambiguity=resolve_ambiguity, - debug=debug, tree_class=tree_class, ordered_sets=options.ordered_sets, **extra) - - - -class CYK_FrontEnd: - def __init__(self, lexer_conf, parser_conf, options=None): - self.parser = cyk.Parser(parser_conf.rules) - - self.callbacks = parser_conf.callbacks - - def parse(self, lexer_thread, start): - tokens = list(lexer_thread.lex(None)) - tree = self.parser.parse(tokens, start) - return self._transform(tree) - - def _transform(self, tree): - subtrees = list(tree.iter_subtrees()) - for subtree in subtrees: - subtree.children = [self._apply_callback(c) if isinstance(c, Tree) else c for c in subtree.children] - - return self._apply_callback(tree) - - def _apply_callback(self, tree): - return self.callbacks[tree.rule](tree.children) - - -_parser_creators['earley'] = create_earley_parser -_parser_creators['cyk'] = CYK_FrontEnd - - -def _construct_parsing_frontend( - parser_type: _ParserArgType, - lexer_type: _LexerArgType, - lexer_conf, - parser_conf, - options -): - assert isinstance(lexer_conf, LexerConf) - assert isinstance(parser_conf, ParserConf) - parser_conf.parser_type = parser_type - lexer_conf.lexer_type = lexer_type - return ParsingFrontend(lexer_conf, parser_conf, options) diff --git a/server/libs/lark/parsers/cyk.py b/server/libs/lark/parsers/cyk.py deleted file mode 100644 index b5334f9..0000000 --- a/server/libs/lark/parsers/cyk.py +++ /dev/null @@ -1,340 +0,0 @@ -"""This module implements a CYK parser.""" - -# Author: https://github.com/ehudt (2018) -# -# Adapted by Erez - - -from collections import defaultdict -import itertools - -from ..exceptions import ParseError -from ..lexer import Token -from ..tree import Tree -from ..grammar import Terminal as T, NonTerminal as NT, Symbol - -def match(t, s): - assert isinstance(t, T) - return t.name == s.type - - -class Rule: - """Context-free grammar rule.""" - - def __init__(self, lhs, rhs, weight, alias): - super(Rule, self).__init__() - assert isinstance(lhs, NT), lhs - assert all(isinstance(x, NT) or isinstance(x, T) for x in rhs), rhs - self.lhs = lhs - self.rhs = rhs - self.weight = weight - self.alias = alias - - def __str__(self): - return '%s -> %s' % (str(self.lhs), ' '.join(str(x) for x in self.rhs)) - - def __repr__(self): - return str(self) - - def __hash__(self): - return hash((self.lhs, tuple(self.rhs))) - - def __eq__(self, other): - return self.lhs == other.lhs and self.rhs == other.rhs - - def __ne__(self, other): - return not (self == other) - - -class Grammar: - """Context-free grammar.""" - - def __init__(self, rules): - self.rules = frozenset(rules) - - def __eq__(self, other): - return self.rules == other.rules - - def __str__(self): - return '\n' + '\n'.join(sorted(repr(x) for x in self.rules)) + '\n' - - def __repr__(self): - return str(self) - - -# Parse tree data structures -class RuleNode: - """A node in the parse tree, which also contains the full rhs rule.""" - - def __init__(self, rule, children, weight=0): - self.rule = rule - self.children = children - self.weight = weight - - def __repr__(self): - return 'RuleNode(%s, [%s])' % (repr(self.rule.lhs), ', '.join(str(x) for x in self.children)) - - - -class Parser: - """Parser wrapper.""" - - def __init__(self, rules): - super(Parser, self).__init__() - self.orig_rules = {rule: rule for rule in rules} - rules = [self._to_rule(rule) for rule in rules] - self.grammar = to_cnf(Grammar(rules)) - - def _to_rule(self, lark_rule): - """Converts a lark rule, (lhs, rhs, callback, options), to a Rule.""" - assert isinstance(lark_rule.origin, NT) - assert all(isinstance(x, Symbol) for x in lark_rule.expansion) - return Rule( - lark_rule.origin, lark_rule.expansion, - weight=lark_rule.options.priority if lark_rule.options.priority else 0, - alias=lark_rule) - - def parse(self, tokenized, start): # pylint: disable=invalid-name - """Parses input, which is a list of tokens.""" - assert start - start = NT(start) - - table, trees = _parse(tokenized, self.grammar) - # Check if the parse succeeded. - if all(r.lhs != start for r in table[(0, len(tokenized) - 1)]): - raise ParseError('Parsing failed.') - parse = trees[(0, len(tokenized) - 1)][start] - return self._to_tree(revert_cnf(parse)) - - def _to_tree(self, rule_node): - """Converts a RuleNode parse tree to a lark Tree.""" - orig_rule = self.orig_rules[rule_node.rule.alias] - children = [] - for child in rule_node.children: - if isinstance(child, RuleNode): - children.append(self._to_tree(child)) - else: - assert isinstance(child.name, Token) - children.append(child.name) - t = Tree(orig_rule.origin, children) - t.rule=orig_rule - return t - - -def print_parse(node, indent=0): - if isinstance(node, RuleNode): - print(' ' * (indent * 2) + str(node.rule.lhs)) - for child in node.children: - print_parse(child, indent + 1) - else: - print(' ' * (indent * 2) + str(node.s)) - - -def _parse(s, g): - """Parses sentence 's' using CNF grammar 'g'.""" - # The CYK table. Indexed with a 2-tuple: (start pos, end pos) - table = defaultdict(set) - # Top-level structure is similar to the CYK table. Each cell is a dict from - # rule name to the best (lightest) tree for that rule. - trees = defaultdict(dict) - # Populate base case with existing terminal production rules - for i, w in enumerate(s): - for terminal, rules in g.terminal_rules.items(): - if match(terminal, w): - for rule in rules: - table[(i, i)].add(rule) - if (rule.lhs not in trees[(i, i)] or - rule.weight < trees[(i, i)][rule.lhs].weight): - trees[(i, i)][rule.lhs] = RuleNode(rule, [T(w)], weight=rule.weight) - - # Iterate over lengths of sub-sentences - for l in range(2, len(s) + 1): - # Iterate over sub-sentences with the given length - for i in range(len(s) - l + 1): - # Choose partition of the sub-sentence in [1, l) - for p in range(i + 1, i + l): - span1 = (i, p - 1) - span2 = (p, i + l - 1) - for r1, r2 in itertools.product(table[span1], table[span2]): - for rule in g.nonterminal_rules.get((r1.lhs, r2.lhs), []): - table[(i, i + l - 1)].add(rule) - r1_tree = trees[span1][r1.lhs] - r2_tree = trees[span2][r2.lhs] - rule_total_weight = rule.weight + r1_tree.weight + r2_tree.weight - if (rule.lhs not in trees[(i, i + l - 1)] - or rule_total_weight < trees[(i, i + l - 1)][rule.lhs].weight): - trees[(i, i + l - 1)][rule.lhs] = RuleNode(rule, [r1_tree, r2_tree], weight=rule_total_weight) - return table, trees - - -# This section implements context-free grammar converter to Chomsky normal form. -# It also implements a conversion of parse trees from its CNF to the original -# grammar. -# Overview: -# Applies the following operations in this order: -# * TERM: Eliminates non-solitary terminals from all rules -# * BIN: Eliminates rules with more than 2 symbols on their right-hand-side. -# * UNIT: Eliminates non-terminal unit rules -# -# The following grammar characteristics aren't featured: -# * Start symbol appears on RHS -# * Empty rules (epsilon rules) - - -class CnfWrapper: - """CNF wrapper for grammar. - - Validates that the input grammar is CNF and provides helper data structures. - """ - - def __init__(self, grammar): - super(CnfWrapper, self).__init__() - self.grammar = grammar - self.rules = grammar.rules - self.terminal_rules = defaultdict(list) - self.nonterminal_rules = defaultdict(list) - for r in self.rules: - # Validate that the grammar is CNF and populate auxiliary data structures. - assert isinstance(r.lhs, NT), r - if len(r.rhs) not in [1, 2]: - raise ParseError("CYK doesn't support empty rules") - if len(r.rhs) == 1 and isinstance(r.rhs[0], T): - self.terminal_rules[r.rhs[0]].append(r) - elif len(r.rhs) == 2 and all(isinstance(x, NT) for x in r.rhs): - self.nonterminal_rules[tuple(r.rhs)].append(r) - else: - assert False, r - - def __eq__(self, other): - return self.grammar == other.grammar - - def __repr__(self): - return repr(self.grammar) - - -class UnitSkipRule(Rule): - """A rule that records NTs that were skipped during transformation.""" - - def __init__(self, lhs, rhs, skipped_rules, weight, alias): - super(UnitSkipRule, self).__init__(lhs, rhs, weight, alias) - self.skipped_rules = skipped_rules - - def __eq__(self, other): - return isinstance(other, type(self)) and self.skipped_rules == other.skipped_rules - - __hash__ = Rule.__hash__ - - -def build_unit_skiprule(unit_rule, target_rule): - skipped_rules = [] - if isinstance(unit_rule, UnitSkipRule): - skipped_rules += unit_rule.skipped_rules - skipped_rules.append(target_rule) - if isinstance(target_rule, UnitSkipRule): - skipped_rules += target_rule.skipped_rules - return UnitSkipRule(unit_rule.lhs, target_rule.rhs, skipped_rules, - weight=unit_rule.weight + target_rule.weight, alias=unit_rule.alias) - - -def get_any_nt_unit_rule(g): - """Returns a non-terminal unit rule from 'g', or None if there is none.""" - for rule in g.rules: - if len(rule.rhs) == 1 and isinstance(rule.rhs[0], NT): - return rule - return None - - -def _remove_unit_rule(g, rule): - """Removes 'rule' from 'g' without changing the language produced by 'g'.""" - new_rules = [x for x in g.rules if x != rule] - refs = [x for x in g.rules if x.lhs == rule.rhs[0]] - new_rules += [build_unit_skiprule(rule, ref) for ref in refs] - return Grammar(new_rules) - - -def _split(rule): - """Splits a rule whose len(rhs) > 2 into shorter rules.""" - rule_str = str(rule.lhs) + '__' + '_'.join(str(x) for x in rule.rhs) - rule_name = '__SP_%s' % (rule_str) + '_%d' - yield Rule(rule.lhs, [rule.rhs[0], NT(rule_name % 1)], weight=rule.weight, alias=rule.alias) - for i in range(1, len(rule.rhs) - 2): - yield Rule(NT(rule_name % i), [rule.rhs[i], NT(rule_name % (i + 1))], weight=0, alias='Split') - yield Rule(NT(rule_name % (len(rule.rhs) - 2)), rule.rhs[-2:], weight=0, alias='Split') - - -def _term(g): - """Applies the TERM rule on 'g' (see top comment).""" - all_t = {x for rule in g.rules for x in rule.rhs if isinstance(x, T)} - t_rules = {t: Rule(NT('__T_%s' % str(t)), [t], weight=0, alias='Term') for t in all_t} - new_rules = [] - for rule in g.rules: - if len(rule.rhs) > 1 and any(isinstance(x, T) for x in rule.rhs): - new_rhs = [t_rules[x].lhs if isinstance(x, T) else x for x in rule.rhs] - new_rules.append(Rule(rule.lhs, new_rhs, weight=rule.weight, alias=rule.alias)) - new_rules.extend(v for k, v in t_rules.items() if k in rule.rhs) - else: - new_rules.append(rule) - return Grammar(new_rules) - - -def _bin(g): - """Applies the BIN rule to 'g' (see top comment).""" - new_rules = [] - for rule in g.rules: - if len(rule.rhs) > 2: - new_rules += _split(rule) - else: - new_rules.append(rule) - return Grammar(new_rules) - - -def _unit(g): - """Applies the UNIT rule to 'g' (see top comment).""" - nt_unit_rule = get_any_nt_unit_rule(g) - while nt_unit_rule: - g = _remove_unit_rule(g, nt_unit_rule) - nt_unit_rule = get_any_nt_unit_rule(g) - return g - - -def to_cnf(g): - """Creates a CNF grammar from a general context-free grammar 'g'.""" - g = _unit(_bin(_term(g))) - return CnfWrapper(g) - - -def unroll_unit_skiprule(lhs, orig_rhs, skipped_rules, children, weight, alias): - if not skipped_rules: - return RuleNode(Rule(lhs, orig_rhs, weight=weight, alias=alias), children, weight=weight) - else: - weight = weight - skipped_rules[0].weight - return RuleNode( - Rule(lhs, [skipped_rules[0].lhs], weight=weight, alias=alias), [ - unroll_unit_skiprule(skipped_rules[0].lhs, orig_rhs, - skipped_rules[1:], children, - skipped_rules[0].weight, skipped_rules[0].alias) - ], weight=weight) - - -def revert_cnf(node): - """Reverts a parse tree (RuleNode) to its original non-CNF form (Node).""" - if isinstance(node, T): - return node - # Reverts TERM rule. - if node.rule.lhs.name.startswith('__T_'): - return node.children[0] - else: - children = [] - for child in map(revert_cnf, node.children): - # Reverts BIN rule. - if isinstance(child, RuleNode) and child.rule.lhs.name.startswith('__SP_'): - children += child.children - else: - children.append(child) - # Reverts UNIT rule. - if isinstance(node.rule, UnitSkipRule): - return unroll_unit_skiprule(node.rule.lhs, node.rule.rhs, - node.rule.skipped_rules, children, - node.rule.weight, node.rule.alias) - else: - return RuleNode(node.rule, children) diff --git a/server/libs/lark/parsers/earley.py b/server/libs/lark/parsers/earley.py deleted file mode 100644 index d173129..0000000 --- a/server/libs/lark/parsers/earley.py +++ /dev/null @@ -1,317 +0,0 @@ -"""This module implements an Earley parser. - -The core Earley algorithm used here is based on Elizabeth Scott's implementation, here: - https://www.sciencedirect.com/science/article/pii/S1571066108001497 - -That is probably the best reference for understanding the algorithm here. - -The Earley parser outputs an SPPF-tree as per that document. The SPPF tree format -is explained here: https://lark-parser.readthedocs.io/en/latest/_static/sppf/sppf.html -""" - -from typing import TYPE_CHECKING, Callable, Optional, List, Any -from collections import deque - -from ..lexer import Token -from ..tree import Tree -from ..exceptions import UnexpectedEOF, UnexpectedToken -from ..utils import logger, OrderedSet, dedup_list -from .grammar_analysis import GrammarAnalyzer -from ..grammar import NonTerminal -from .earley_common import Item -from .earley_forest import ForestSumVisitor, SymbolNode, StableSymbolNode, TokenNode, ForestToParseTree - -if TYPE_CHECKING: - from ..common import LexerConf, ParserConf - -class Parser: - lexer_conf: 'LexerConf' - parser_conf: 'ParserConf' - debug: bool - - def __init__(self, lexer_conf: 'LexerConf', parser_conf: 'ParserConf', term_matcher: Callable, - resolve_ambiguity: bool=True, debug: bool=False, - tree_class: Optional[Callable[[str, List], Any]]=Tree, ordered_sets: bool=True): - analysis = GrammarAnalyzer(parser_conf) - self.lexer_conf = lexer_conf - self.parser_conf = parser_conf - self.resolve_ambiguity = resolve_ambiguity - self.debug = debug - self.Tree = tree_class - self.Set = OrderedSet if ordered_sets else set - self.SymbolNode = StableSymbolNode if ordered_sets else SymbolNode - - self.FIRST = analysis.FIRST - self.NULLABLE = analysis.NULLABLE - self.callbacks = parser_conf.callbacks - # TODO add typing info - self.predictions = {} # type: ignore[var-annotated] - - ## These could be moved to the grammar analyzer. Pre-computing these is *much* faster than - # the slow 'isupper' in is_terminal. - self.TERMINALS = { sym for r in parser_conf.rules for sym in r.expansion if sym.is_term } - self.NON_TERMINALS = { sym for r in parser_conf.rules for sym in r.expansion if not sym.is_term } - - self.forest_sum_visitor = None - for rule in parser_conf.rules: - if rule.origin not in self.predictions: - self.predictions[rule.origin] = [x.rule for x in analysis.expand_rule(rule.origin)] - - ## Detect if any rules/terminals have priorities set. If the user specified priority = None, then - # the priorities will be stripped from all rules/terminals before they reach us, allowing us to - # skip the extra tree walk. We'll also skip this if the user just didn't specify priorities - # on any rules/terminals. - if self.forest_sum_visitor is None and rule.options.priority is not None: - self.forest_sum_visitor = ForestSumVisitor - - # Check terminals for priorities - # Ignore terminal priorities if the basic lexer is used - if self.lexer_conf.lexer_type != 'basic' and self.forest_sum_visitor is None: - for term in self.lexer_conf.terminals: - if term.priority: - self.forest_sum_visitor = ForestSumVisitor - break - - self.term_matcher = term_matcher - - - def predict_and_complete(self, i, to_scan, columns, transitives): - """The core Earley Predictor and Completer. - - At each stage of the input, we handling any completed items (things - that matched on the last cycle) and use those to predict what should - come next in the input stream. The completions and any predicted - non-terminals are recursively processed until we reach a set of, - which can be added to the scan list for the next scanner cycle.""" - # Held Completions (H in E.Scotts paper). - node_cache = {} - held_completions = {} - - column = columns[i] - # R (items) = Ei (column.items) - items = deque(column) - while items: - item = items.pop() # remove an element, A say, from R - - ### The Earley completer - if item.is_complete: ### (item.s == string) - if item.node is None: - label = (item.s, item.start, i) - item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - item.node.add_family(item.s, item.rule, item.start, None, None) - - # create_leo_transitives(item.rule.origin, item.start) - - ###R Joop Leo right recursion Completer - if item.rule.origin in transitives[item.start]: - transitive = transitives[item.start][item.s] - if transitive.previous in transitives[transitive.column]: - root_transitive = transitives[transitive.column][transitive.previous] - else: - root_transitive = transitive - - new_item = Item(transitive.rule, transitive.ptr, transitive.start) - label = (root_transitive.s, root_transitive.start, i) - new_item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - new_item.node.add_path(root_transitive, item.node) - if new_item.expect in self.TERMINALS: - # Add (B :: aC.B, h, y) to Q - to_scan.add(new_item) - elif new_item not in column: - # Add (B :: aC.B, h, y) to Ei and R - column.add(new_item) - items.append(new_item) - ###R Regular Earley completer - else: - # Empty has 0 length. If we complete an empty symbol in a particular - # parse step, we need to be able to use that same empty symbol to complete - # any predictions that result, that themselves require empty. Avoids - # infinite recursion on empty symbols. - # held_completions is 'H' in E.Scott's paper. - is_empty_item = item.start == i - if is_empty_item: - held_completions[item.rule.origin] = item.node - - originators = [originator for originator in columns[item.start] if originator.expect is not None and originator.expect == item.s] - for originator in originators: - new_item = originator.advance() - label = (new_item.s, originator.start, i) - new_item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - new_item.node.add_family(new_item.s, new_item.rule, i, originator.node, item.node) - if new_item.expect in self.TERMINALS: - # Add (B :: aC.B, h, y) to Q - to_scan.add(new_item) - elif new_item not in column: - # Add (B :: aC.B, h, y) to Ei and R - column.add(new_item) - items.append(new_item) - - ### The Earley predictor - elif item.expect in self.NON_TERMINALS: ### (item.s == lr0) - new_items = [] - for rule in self.predictions[item.expect]: - new_item = Item(rule, 0, i) - new_items.append(new_item) - - # Process any held completions (H). - if item.expect in held_completions: - new_item = item.advance() - label = (new_item.s, item.start, i) - new_item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - new_item.node.add_family(new_item.s, new_item.rule, new_item.start, item.node, held_completions[item.expect]) - new_items.append(new_item) - - for new_item in new_items: - if new_item.expect in self.TERMINALS: - to_scan.add(new_item) - elif new_item not in column: - column.add(new_item) - items.append(new_item) - - def _parse(self, lexer, columns, to_scan, start_symbol=None): - - def is_quasi_complete(item): - if item.is_complete: - return True - - quasi = item.advance() - while not quasi.is_complete: - if quasi.expect not in self.NULLABLE: - return False - if quasi.rule.origin == start_symbol and quasi.expect == start_symbol: - return False - quasi = quasi.advance() - return True - - # def create_leo_transitives(origin, start): - # ... # removed at commit 4c1cfb2faf24e8f8bff7112627a00b94d261b420 - - def scan(i, token, to_scan): - """The core Earley Scanner. - - This is a custom implementation of the scanner that uses the - Lark lexer to match tokens. The scan list is built by the - Earley predictor, based on the previously completed tokens. - This ensures that at each phase of the parse we have a custom - lexer context, allowing for more complex ambiguities.""" - next_to_scan = self.Set() - next_set = self.Set() - columns.append(next_set) - transitives.append({}) - node_cache = {} - - for item in self.Set(to_scan): - if match(item.expect, token): - new_item = item.advance() - label = (new_item.s, new_item.start, i) - # 'terminals' may not contain token.type when using %declare - # Additionally, token is not always a Token - # For example, it can be a Tree when using TreeMatcher - term = terminals.get(token.type) if isinstance(token, Token) else None - # Set the priority of the token node to 0 so that the - # terminal priorities do not affect the Tree chosen by - # ForestSumVisitor after the basic lexer has already - # "used up" the terminal priorities - token_node = TokenNode(token, term, priority=0) - new_item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - new_item.node.add_family(new_item.s, item.rule, new_item.start, item.node, token_node) - - if new_item.expect in self.TERMINALS: - # add (B ::= Aai+1.B, h, y) to Q' - next_to_scan.add(new_item) - else: - # add (B ::= Aa+1.B, h, y) to Ei+1 - next_set.add(new_item) - - if not next_set and not next_to_scan: - expect = {i.expect.name for i in to_scan} - raise UnexpectedToken(token, expect, considered_rules=set(to_scan), state=frozenset(i.s for i in to_scan)) - - return next_to_scan - - - # Define parser functions - match = self.term_matcher - - terminals = self.lexer_conf.terminals_by_name - - # Cache for nodes & tokens created in a particular parse step. - transitives = [{}] - - ## The main Earley loop. - # Run the Prediction/Completion cycle for any Items in the current Earley set. - # Completions will be added to the SPPF tree, and predictions will be recursively - # processed down to terminals/empty nodes to be added to the scanner for the next - # step. - expects = {i.expect for i in to_scan} - i = 0 - for token in lexer.lex(expects): - self.predict_and_complete(i, to_scan, columns, transitives) - - to_scan = scan(i, token, to_scan) - i += 1 - - expects.clear() - expects |= {i.expect for i in to_scan} - - self.predict_and_complete(i, to_scan, columns, transitives) - - ## Column is now the final column in the parse. - assert i == len(columns)-1 - return to_scan - - def parse(self, lexer, start): - assert start, start - start_symbol = NonTerminal(start) - - columns = [self.Set()] - to_scan = self.Set() # The scan buffer. 'Q' in E.Scott's paper. - - ## Predict for the start_symbol. - # Add predicted items to the first Earley set (for the predictor) if they - # result in a non-terminal, or the scanner if they result in a terminal. - for rule in self.predictions[start_symbol]: - item = Item(rule, 0, 0) - if item.expect in self.TERMINALS: - to_scan.add(item) - else: - columns[0].add(item) - - to_scan = self._parse(lexer, columns, to_scan, start_symbol) - - # If the parse was successful, the start - # symbol should have been completed in the last step of the Earley cycle, and will be in - # this column. Find the item for the start_symbol, which is the root of the SPPF tree. - solutions = dedup_list(n.node for n in columns[-1] if n.is_complete and n.node is not None and n.s == start_symbol and n.start == 0) - if not solutions: - expected_terminals = [t.expect.name for t in to_scan] - raise UnexpectedEOF(expected_terminals, state=frozenset(i.s for i in to_scan)) - - if self.debug: - from .earley_forest import ForestToPyDotVisitor - try: - debug_walker = ForestToPyDotVisitor() - except ImportError: - logger.warning("Cannot find dependency 'pydot', will not generate sppf debug image") - else: - for i, s in enumerate(solutions): - debug_walker.visit(s, f"sppf{i}.png") - - - if self.Tree is not None: - # Perform our SPPF -> AST conversion - # Disable the ForestToParseTree cache when ambiguity='resolve' - # to prevent a tree construction bug. See issue #1283 - use_cache = not self.resolve_ambiguity - transformer = ForestToParseTree(self.Tree, self.callbacks, self.forest_sum_visitor and self.forest_sum_visitor(), self.resolve_ambiguity, use_cache) - solutions = [transformer.transform(s) for s in solutions] - - if len(solutions) > 1 and not self.resolve_ambiguity: - t: Tree = self.Tree('_ambig', solutions) - t.expand_kids_by_data('_ambig') # solutions may themselves be _ambig nodes - return t - return solutions[0] - - # return the root of the SPPF - # TODO return a list of solutions, or join them together somehow - return solutions[0] diff --git a/server/libs/lark/parsers/earley_common.py b/server/libs/lark/parsers/earley_common.py deleted file mode 100644 index 0ea2d4f..0000000 --- a/server/libs/lark/parsers/earley_common.py +++ /dev/null @@ -1,42 +0,0 @@ -"""This module implements useful building blocks for the Earley parser -""" - - -class Item: - "An Earley Item, the atom of the algorithm." - - __slots__ = ('s', 'rule', 'ptr', 'start', 'is_complete', 'expect', 'previous', 'node', '_hash') - def __init__(self, rule, ptr, start): - self.is_complete = len(rule.expansion) == ptr - self.rule = rule # rule - self.ptr = ptr # ptr - self.start = start # j - self.node = None # w - if self.is_complete: - self.s = rule.origin - self.expect = None - self.previous = rule.expansion[ptr - 1] if ptr > 0 and len(rule.expansion) else None - else: - self.s = (rule, ptr) - self.expect = rule.expansion[ptr] - self.previous = rule.expansion[ptr - 1] if ptr > 0 and len(rule.expansion) else None - self._hash = hash((self.s, self.start, self.rule)) - - def advance(self): - return Item(self.rule, self.ptr + 1, self.start) - - def __eq__(self, other): - return self is other or (self.s == other.s and self.start == other.start and self.rule == other.rule) - - def __hash__(self): - return self._hash - - def __repr__(self): - before = ( expansion.name for expansion in self.rule.expansion[:self.ptr] ) - after = ( expansion.name for expansion in self.rule.expansion[self.ptr:] ) - symbol = "{} ::= {}* {}".format(self.rule.origin.name, ' '.join(before), ' '.join(after)) - return '%s (%d)' % (symbol, self.start) - - -# class TransitiveItem(Item): -# ... # removed at commit 4c1cfb2faf24e8f8bff7112627a00b94d261b420 diff --git a/server/libs/lark/parsers/earley_forest.py b/server/libs/lark/parsers/earley_forest.py deleted file mode 100644 index c60f3a6..0000000 --- a/server/libs/lark/parsers/earley_forest.py +++ /dev/null @@ -1,802 +0,0 @@ -""""This module implements an SPPF implementation - -This is used as the primary output mechanism for the Earley parser -in order to store complex ambiguities. - -Full reference and more details is here: -https://web.archive.org/web/20190616123959/http://www.bramvandersanden.com/post/2014/06/shared-packed-parse-forest/ -""" - -from typing import Type, AbstractSet -from random import randint -from collections import deque -from operator import attrgetter -from importlib import import_module -from functools import partial - -from ..parse_tree_builder import AmbiguousIntermediateExpander -from ..visitors import Discard -from ..utils import logger, OrderedSet -from ..tree import Tree - -class ForestNode: - pass - -class SymbolNode(ForestNode): - """ - A Symbol Node represents a symbol (or Intermediate LR0). - - Symbol nodes are keyed by the symbol (s). For intermediate nodes - s will be an LR0, stored as a tuple of (rule, ptr). For completed symbol - nodes, s will be a string representing the non-terminal origin (i.e. - the left hand side of the rule). - - The children of a Symbol or Intermediate Node will always be Packed Nodes; - with each Packed Node child representing a single derivation of a production. - - Hence a Symbol Node with a single child is unambiguous. - - Parameters: - s: A Symbol, or a tuple of (rule, ptr) for an intermediate node. - start: For dynamic lexers, the index of the start of the substring matched by this symbol (inclusive). - end: For dynamic lexers, the index of the end of the substring matched by this symbol (exclusive). - - Properties: - is_intermediate: True if this node is an intermediate node. - priority: The priority of the node's symbol. - """ - Set: Type[AbstractSet] = set # Overridden by StableSymbolNode - __slots__ = ('s', 'start', 'end', '_children', 'paths', 'paths_loaded', 'priority', 'is_intermediate') - def __init__(self, s, start, end): - self.s = s - self.start = start - self.end = end - self._children = self.Set() - self.paths = self.Set() - self.paths_loaded = False - - ### We use inf here as it can be safely negated without resorting to conditionals, - # unlike None or float('NaN'), and sorts appropriately. - self.priority = float('-inf') - self.is_intermediate = isinstance(s, tuple) - - def add_family(self, lr0, rule, start, left, right): - self._children.add(PackedNode(self, lr0, rule, start, left, right)) - - def add_path(self, transitive, node): - self.paths.add((transitive, node)) - - def load_paths(self): - for transitive, node in self.paths: - if transitive.next_titem is not None: - vn = type(self)(transitive.next_titem.s, transitive.next_titem.start, self.end) - vn.add_path(transitive.next_titem, node) - self.add_family(transitive.reduction.rule.origin, transitive.reduction.rule, transitive.reduction.start, transitive.reduction.node, vn) - else: - self.add_family(transitive.reduction.rule.origin, transitive.reduction.rule, transitive.reduction.start, transitive.reduction.node, node) - self.paths_loaded = True - - @property - def is_ambiguous(self): - """Returns True if this node is ambiguous.""" - return len(self.children) > 1 - - @property - def children(self): - """Returns a list of this node's children sorted from greatest to - least priority.""" - if not self.paths_loaded: - self.load_paths() - return sorted(self._children, key=attrgetter('sort_key')) - - def __iter__(self): - return iter(self._children) - - def __repr__(self): - if self.is_intermediate: - rule = self.s[0] - ptr = self.s[1] - before = ( expansion.name for expansion in rule.expansion[:ptr] ) - after = ( expansion.name for expansion in rule.expansion[ptr:] ) - symbol = "{} ::= {}* {}".format(rule.origin.name, ' '.join(before), ' '.join(after)) - else: - symbol = self.s.name - return "({}, {}, {}, {})".format(symbol, self.start, self.end, self.priority) - -class StableSymbolNode(SymbolNode): - "A version of SymbolNode that uses OrderedSet for output stability" - Set = OrderedSet - -class PackedNode(ForestNode): - """ - A Packed Node represents a single derivation in a symbol node. - - Parameters: - rule: The rule associated with this node. - parent: The parent of this node. - left: The left child of this node. ``None`` if one does not exist. - right: The right child of this node. ``None`` if one does not exist. - priority: The priority of this node. - """ - __slots__ = ('parent', 's', 'rule', 'start', 'left', 'right', 'priority', '_hash') - def __init__(self, parent, s, rule, start, left, right): - self.parent = parent - self.s = s - self.start = start - self.rule = rule - self.left = left - self.right = right - self.priority = float('-inf') - self._hash = hash((self.left, self.right)) - - @property - def is_empty(self): - return self.left is None and self.right is None - - @property - def sort_key(self): - """ - Used to sort PackedNode children of SymbolNodes. - A SymbolNode has multiple PackedNodes if it matched - ambiguously. Hence, we use the sort order to identify - the order in which ambiguous children should be considered. - """ - return self.is_empty, -self.priority, self.rule.order - - @property - def children(self): - """Returns a list of this node's children.""" - return [x for x in [self.left, self.right] if x is not None] - - def __iter__(self): - yield self.left - yield self.right - - def __eq__(self, other): - if not isinstance(other, PackedNode): - return False - return self is other or (self.left == other.left and self.right == other.right) - - def __hash__(self): - return self._hash - - def __repr__(self): - if isinstance(self.s, tuple): - rule = self.s[0] - ptr = self.s[1] - before = ( expansion.name for expansion in rule.expansion[:ptr] ) - after = ( expansion.name for expansion in rule.expansion[ptr:] ) - symbol = "{} ::= {}* {}".format(rule.origin.name, ' '.join(before), ' '.join(after)) - else: - symbol = self.s.name - return "({}, {}, {}, {})".format(symbol, self.start, self.priority, self.rule.order) - -class TokenNode(ForestNode): - """ - A Token Node represents a matched terminal and is always a leaf node. - - Parameters: - token: The Token associated with this node. - term: The TerminalDef matched by the token. - priority: The priority of this node. - """ - __slots__ = ('token', 'term', 'priority', '_hash') - def __init__(self, token, term, priority=None): - self.token = token - self.term = term - if priority is not None: - self.priority = priority - else: - self.priority = term.priority if term is not None else 0 - self._hash = hash(token) - - def __eq__(self, other): - if not isinstance(other, TokenNode): - return False - return self is other or (self.token == other.token) - - def __hash__(self): - return self._hash - - def __repr__(self): - return repr(self.token) - -class ForestVisitor: - """ - An abstract base class for building forest visitors. - - This class performs a controllable depth-first walk of an SPPF. - The visitor will not enter cycles and will backtrack if one is encountered. - Subclasses are notified of cycles through the ``on_cycle`` method. - - Behavior for visit events is defined by overriding the - ``visit*node*`` functions. - - The walk is controlled by the return values of the ``visit*node_in`` - methods. Returning a node(s) will schedule them to be visited. The visitor - will begin to backtrack if no nodes are returned. - - Parameters: - single_visit: If ``True``, non-Token nodes will only be visited once. - """ - - def __init__(self, single_visit=False): - self.single_visit = single_visit - - def visit_token_node(self, node): - """Called when a ``Token`` is visited. ``Token`` nodes are always leaves.""" - pass - - def visit_symbol_node_in(self, node): - """Called when a symbol node is visited. Nodes that are returned - will be scheduled to be visited. If ``visit_intermediate_node_in`` - is not implemented, this function will be called for intermediate - nodes as well.""" - pass - - def visit_symbol_node_out(self, node): - """Called after all nodes returned from a corresponding ``visit_symbol_node_in`` - call have been visited. If ``visit_intermediate_node_out`` - is not implemented, this function will be called for intermediate - nodes as well.""" - pass - - def visit_packed_node_in(self, node): - """Called when a packed node is visited. Nodes that are returned - will be scheduled to be visited. """ - pass - - def visit_packed_node_out(self, node): - """Called after all nodes returned from a corresponding ``visit_packed_node_in`` - call have been visited.""" - pass - - def on_cycle(self, node, path): - """Called when a cycle is encountered. - - Parameters: - node: The node that causes a cycle. - path: The list of nodes being visited: nodes that have been - entered but not exited. The first element is the root in a forest - visit, and the last element is the node visited most recently. - ``path`` should be treated as read-only. - """ - pass - - def get_cycle_in_path(self, node, path): - """A utility function for use in ``on_cycle`` to obtain a slice of - ``path`` that only contains the nodes that make up the cycle.""" - index = len(path) - 1 - while id(path[index]) != id(node): - index -= 1 - return path[index:] - - def visit(self, root): - # Visiting is a list of IDs of all symbol/intermediate nodes currently in - # the stack. It serves two purposes: to detect when we 'recurse' in and out - # of a symbol/intermediate so that we can process both up and down. Also, - # since the SPPF can have cycles it allows us to detect if we're trying - # to recurse into a node that's already on the stack (infinite recursion). - visiting = set() - - # set of all nodes that have been visited - visited = set() - - # a list of nodes that are currently being visited - # used for the `on_cycle` callback - path = [] - - # We do not use recursion here to walk the Forest due to the limited - # stack size in python. Therefore input_stack is essentially our stack. - input_stack = deque([root]) - - # It is much faster to cache these as locals since they are called - # many times in large parses. - vpno = getattr(self, 'visit_packed_node_out') - vpni = getattr(self, 'visit_packed_node_in') - vsno = getattr(self, 'visit_symbol_node_out') - vsni = getattr(self, 'visit_symbol_node_in') - vino = getattr(self, 'visit_intermediate_node_out', vsno) - vini = getattr(self, 'visit_intermediate_node_in', vsni) - vtn = getattr(self, 'visit_token_node') - oc = getattr(self, 'on_cycle') - - while input_stack: - current = next(reversed(input_stack)) - try: - next_node = next(current) - except StopIteration: - input_stack.pop() - continue - except TypeError: - ### If the current object is not an iterator, pass through to Token/SymbolNode - pass - else: - if next_node is None: - continue - - if id(next_node) in visiting: - oc(next_node, path) - continue - - input_stack.append(next_node) - continue - - if isinstance(current, TokenNode): - vtn(current.token) - input_stack.pop() - continue - - current_id = id(current) - if current_id in visiting: - if isinstance(current, PackedNode): - vpno(current) - elif current.is_intermediate: - vino(current) - else: - vsno(current) - input_stack.pop() - path.pop() - visiting.remove(current_id) - visited.add(current_id) - elif self.single_visit and current_id in visited: - input_stack.pop() - else: - visiting.add(current_id) - path.append(current) - if isinstance(current, PackedNode): - next_node = vpni(current) - elif current.is_intermediate: - next_node = vini(current) - else: - next_node = vsni(current) - if next_node is None: - continue - - if not isinstance(next_node, ForestNode): - next_node = iter(next_node) - elif id(next_node) in visiting: - oc(next_node, path) - continue - - input_stack.append(next_node) - -class ForestTransformer(ForestVisitor): - """The base class for a bottom-up forest transformation. Most users will - want to use ``TreeForestTransformer`` instead as it has a friendlier - interface and covers most use cases. - - Transformations are applied via inheritance and overriding of the - ``transform*node`` methods. - - ``transform_token_node`` receives a ``Token`` as an argument. - All other methods receive the node that is being transformed and - a list of the results of the transformations of that node's children. - The return value of these methods are the resulting transformations. - - If ``Discard`` is raised in a node's transformation, no data from that node - will be passed to its parent's transformation. - """ - - def __init__(self): - super(ForestTransformer, self).__init__() - # results of transformations - self.data = dict() - # used to track parent nodes - self.node_stack = deque() - - def transform(self, root): - """Perform a transformation on an SPPF.""" - self.node_stack.append('result') - self.data['result'] = [] - self.visit(root) - assert len(self.data['result']) <= 1 - if self.data['result']: - return self.data['result'][0] - - def transform_symbol_node(self, node, data): - """Transform a symbol node.""" - return node - - def transform_intermediate_node(self, node, data): - """Transform an intermediate node.""" - return node - - def transform_packed_node(self, node, data): - """Transform a packed node.""" - return node - - def transform_token_node(self, node): - """Transform a ``Token``.""" - return node - - def visit_symbol_node_in(self, node): - self.node_stack.append(id(node)) - self.data[id(node)] = [] - return node.children - - def visit_packed_node_in(self, node): - self.node_stack.append(id(node)) - self.data[id(node)] = [] - return node.children - - def visit_token_node(self, node): - transformed = self.transform_token_node(node) - if transformed is not Discard: - self.data[self.node_stack[-1]].append(transformed) - - def _visit_node_out_helper(self, node, method): - self.node_stack.pop() - transformed = method(node, self.data[id(node)]) - if transformed is not Discard: - self.data[self.node_stack[-1]].append(transformed) - del self.data[id(node)] - - def visit_symbol_node_out(self, node): - self._visit_node_out_helper(node, self.transform_symbol_node) - - def visit_intermediate_node_out(self, node): - self._visit_node_out_helper(node, self.transform_intermediate_node) - - def visit_packed_node_out(self, node): - self._visit_node_out_helper(node, self.transform_packed_node) - - -class ForestSumVisitor(ForestVisitor): - """ - A visitor for prioritizing ambiguous parts of the Forest. - - This visitor is used when support for explicit priorities on - rules is requested (whether normal, or invert). It walks the - forest (or subsets thereof) and cascades properties upwards - from the leaves. - - It would be ideal to do this during parsing, however this would - require processing each Earley item multiple times. That's - a big performance drawback; so running a forest walk is the - lesser of two evils: there can be significantly more Earley - items created during parsing than there are SPPF nodes in the - final tree. - """ - def __init__(self): - super(ForestSumVisitor, self).__init__(single_visit=True) - - def visit_packed_node_in(self, node): - yield node.left - yield node.right - - def visit_symbol_node_in(self, node): - return iter(node.children) - - def visit_packed_node_out(self, node): - priority = node.rule.options.priority if not node.parent.is_intermediate and node.rule.options.priority else 0 - priority += getattr(node.right, 'priority', 0) - priority += getattr(node.left, 'priority', 0) - node.priority = priority - - def visit_symbol_node_out(self, node): - node.priority = max(child.priority for child in node.children) - -class PackedData(): - """Used in transformationss of packed nodes to distinguish the data - that comes from the left child and the right child. - """ - - class _NoData(): - pass - - NO_DATA = _NoData() - - def __init__(self, node, data): - self.left = self.NO_DATA - self.right = self.NO_DATA - if data: - if node.left is not None: - self.left = data[0] - if len(data) > 1: - self.right = data[1] - else: - self.right = data[0] - -class ForestToParseTree(ForestTransformer): - """Used by the earley parser when ambiguity equals 'resolve' or - 'explicit'. Transforms an SPPF into an (ambiguous) parse tree. - - Parameters: - tree_class: The tree class to use for construction - callbacks: A dictionary of rules to functions that output a tree - prioritizer: A ``ForestVisitor`` that manipulates the priorities of ForestNodes - resolve_ambiguity: If True, ambiguities will be resolved based on - priorities. Otherwise, `_ambig` nodes will be in the resulting tree. - use_cache: If True, the results of packed node transformations will be cached. - """ - - def __init__(self, tree_class=Tree, callbacks=dict(), prioritizer=ForestSumVisitor(), resolve_ambiguity=True, use_cache=True): - super(ForestToParseTree, self).__init__() - self.tree_class = tree_class - self.callbacks = callbacks - self.prioritizer = prioritizer - self.resolve_ambiguity = resolve_ambiguity - self._use_cache = use_cache - self._cache = {} - self._on_cycle_retreat = False - self._cycle_node = None - self._successful_visits = set() - - def visit(self, root): - if self.prioritizer: - self.prioritizer.visit(root) - super(ForestToParseTree, self).visit(root) - self._cache = {} - - def on_cycle(self, node, path): - logger.debug("Cycle encountered in the SPPF at node: %s. " - "As infinite ambiguities cannot be represented in a tree, " - "this family of derivations will be discarded.", node) - self._cycle_node = node - self._on_cycle_retreat = True - - def _check_cycle(self, node): - if self._on_cycle_retreat: - if id(node) == id(self._cycle_node) or id(node) in self._successful_visits: - self._cycle_node = None - self._on_cycle_retreat = False - else: - return Discard - - def _collapse_ambig(self, children): - new_children = [] - for child in children: - if hasattr(child, 'data') and child.data == '_ambig': - new_children += child.children - else: - new_children.append(child) - return new_children - - def _call_rule_func(self, node, data): - # called when transforming children of symbol nodes - # data is a list of trees or tokens that correspond to the - # symbol's rule expansion - return self.callbacks[node.rule](data) - - def _call_ambig_func(self, node, data): - # called when transforming a symbol node - # data is a list of trees where each tree's data is - # equal to the name of the symbol or one of its aliases. - if len(data) > 1: - return self.tree_class('_ambig', data) - elif data: - return data[0] - return Discard - - def transform_symbol_node(self, node, data): - if id(node) not in self._successful_visits: - return Discard - r = self._check_cycle(node) - if r is Discard: - return r - self._successful_visits.remove(id(node)) - data = self._collapse_ambig(data) - return self._call_ambig_func(node, data) - - def transform_intermediate_node(self, node, data): - if id(node) not in self._successful_visits: - return Discard - r = self._check_cycle(node) - if r is Discard: - return r - self._successful_visits.remove(id(node)) - if len(data) > 1: - children = [self.tree_class('_inter', c) for c in data] - return self.tree_class('_iambig', children) - return data[0] - - def transform_packed_node(self, node, data): - r = self._check_cycle(node) - if r is Discard: - return r - if self.resolve_ambiguity and id(node.parent) in self._successful_visits: - return Discard - if self._use_cache and id(node) in self._cache: - return self._cache[id(node)] - children = [] - assert len(data) <= 2 - data = PackedData(node, data) - if data.left is not PackedData.NO_DATA: - if node.left.is_intermediate and isinstance(data.left, list): - children += data.left - else: - children.append(data.left) - if data.right is not PackedData.NO_DATA: - children.append(data.right) - transformed = children if node.parent.is_intermediate else self._call_rule_func(node, children) - if self._use_cache: - self._cache[id(node)] = transformed - return transformed - - def visit_symbol_node_in(self, node): - super(ForestToParseTree, self).visit_symbol_node_in(node) - if self._on_cycle_retreat: - return - return node.children - - def visit_packed_node_in(self, node): - self._on_cycle_retreat = False - to_visit = super(ForestToParseTree, self).visit_packed_node_in(node) - if not self.resolve_ambiguity or id(node.parent) not in self._successful_visits: - if not self._use_cache or id(node) not in self._cache: - return to_visit - - def visit_packed_node_out(self, node): - super(ForestToParseTree, self).visit_packed_node_out(node) - if not self._on_cycle_retreat: - self._successful_visits.add(id(node.parent)) - -def handles_ambiguity(func): - """Decorator for methods of subclasses of ``TreeForestTransformer``. - Denotes that the method should receive a list of transformed derivations.""" - func.handles_ambiguity = True - return func - -class TreeForestTransformer(ForestToParseTree): - """A ``ForestTransformer`` with a tree ``Transformer``-like interface. - By default, it will construct a tree. - - Methods provided via inheritance are called based on the rule/symbol - names of nodes in the forest. - - Methods that act on rules will receive a list of the results of the - transformations of the rule's children. By default, trees and tokens. - - Methods that act on tokens will receive a token. - - Alternatively, methods that act on rules may be annotated with - ``handles_ambiguity``. In this case, the function will receive a list - of all the transformations of all the derivations of the rule. - By default, a list of trees where each tree.data is equal to the - rule name or one of its aliases. - - Non-tree transformations are made possible by override of - ``__default__``, ``__default_token__``, and ``__default_ambig__``. - - Note: - Tree shaping features such as inlined rules and token filtering are - not built into the transformation. Positions are also not propagated. - - Parameters: - tree_class: The tree class to use for construction - prioritizer: A ``ForestVisitor`` that manipulates the priorities of nodes in the SPPF. - resolve_ambiguity: If True, ambiguities will be resolved based on priorities. - use_cache (bool): If True, caches the results of some transformations, - potentially improving performance when ``resolve_ambiguity==False``. - Only use if you know what you are doing: i.e. All transformation - functions are pure and referentially transparent. - """ - - def __init__(self, tree_class=Tree, prioritizer=ForestSumVisitor(), resolve_ambiguity=True, use_cache=False): - super(TreeForestTransformer, self).__init__(tree_class, dict(), prioritizer, resolve_ambiguity, use_cache) - - def __default__(self, name, data): - """Default operation on tree (for override). - - Returns a tree with name with data as children. - """ - return self.tree_class(name, data) - - def __default_ambig__(self, name, data): - """Default operation on ambiguous rule (for override). - - Wraps data in an '_ambig_' node if it contains more than - one element. - """ - if len(data) > 1: - return self.tree_class('_ambig', data) - elif data: - return data[0] - return Discard - - def __default_token__(self, node): - """Default operation on ``Token`` (for override). - - Returns ``node``. - """ - return node - - def transform_token_node(self, node): - return getattr(self, node.type, self.__default_token__)(node) - - def _call_rule_func(self, node, data): - name = node.rule.alias or node.rule.options.template_source or node.rule.origin.name - user_func = getattr(self, name, self.__default__) - if user_func == self.__default__ or hasattr(user_func, 'handles_ambiguity'): - user_func = partial(self.__default__, name) - if not self.resolve_ambiguity: - wrapper = partial(AmbiguousIntermediateExpander, self.tree_class) - user_func = wrapper(user_func) - return user_func(data) - - def _call_ambig_func(self, node, data): - name = node.s.name - user_func = getattr(self, name, self.__default_ambig__) - if user_func == self.__default_ambig__ or not hasattr(user_func, 'handles_ambiguity'): - user_func = partial(self.__default_ambig__, name) - return user_func(data) - -class ForestToPyDotVisitor(ForestVisitor): - """ - A Forest visitor which writes the SPPF to a PNG. - - The SPPF can get really large, really quickly because - of the amount of meta-data it stores, so this is probably - only useful for trivial trees and learning how the SPPF - is structured. - """ - def __init__(self, rankdir="TB"): - super(ForestToPyDotVisitor, self).__init__(single_visit=True) - self.pydot = import_module('pydot') - self.graph = self.pydot.Dot(graph_type='digraph', rankdir=rankdir) - - def visit(self, root, filename): - super(ForestToPyDotVisitor, self).visit(root) - try: - self.graph.write_png(filename) - except FileNotFoundError as e: - logger.error("Could not write png: ", e) - - def visit_token_node(self, node): - graph_node_id = str(id(node)) - graph_node_label = "\"{}\"".format(node.value.replace('"', '\\"')) - graph_node_color = 0x808080 - graph_node_style = "\"filled,rounded\"" - graph_node_shape = "diamond" - graph_node = self.pydot.Node(graph_node_id, style=graph_node_style, fillcolor="#{:06x}".format(graph_node_color), shape=graph_node_shape, label=graph_node_label) - self.graph.add_node(graph_node) - - def visit_packed_node_in(self, node): - graph_node_id = str(id(node)) - graph_node_label = repr(node) - graph_node_color = 0x808080 - graph_node_style = "filled" - graph_node_shape = "diamond" - graph_node = self.pydot.Node(graph_node_id, style=graph_node_style, fillcolor="#{:06x}".format(graph_node_color), shape=graph_node_shape, label=graph_node_label) - self.graph.add_node(graph_node) - yield node.left - yield node.right - - def visit_packed_node_out(self, node): - graph_node_id = str(id(node)) - graph_node = self.graph.get_node(graph_node_id)[0] - for child in [node.left, node.right]: - if child is not None: - child_graph_node_id = str(id(child.token if isinstance(child, TokenNode) else child)) - child_graph_node = self.graph.get_node(child_graph_node_id)[0] - self.graph.add_edge(self.pydot.Edge(graph_node, child_graph_node)) - else: - #### Try and be above the Python object ID range; probably impl. specific, but maybe this is okay. - child_graph_node_id = str(randint(100000000000000000000000000000,123456789012345678901234567890)) - child_graph_node_style = "invis" - child_graph_node = self.pydot.Node(child_graph_node_id, style=child_graph_node_style, label="None") - child_edge_style = "invis" - self.graph.add_node(child_graph_node) - self.graph.add_edge(self.pydot.Edge(graph_node, child_graph_node, style=child_edge_style)) - - def visit_symbol_node_in(self, node): - graph_node_id = str(id(node)) - graph_node_label = repr(node) - graph_node_color = 0x808080 - graph_node_style = "\"filled\"" - if node.is_intermediate: - graph_node_shape = "ellipse" - else: - graph_node_shape = "rectangle" - graph_node = self.pydot.Node(graph_node_id, style=graph_node_style, fillcolor="#{:06x}".format(graph_node_color), shape=graph_node_shape, label=graph_node_label) - self.graph.add_node(graph_node) - return iter(node.children) - - def visit_symbol_node_out(self, node): - graph_node_id = str(id(node)) - graph_node = self.graph.get_node(graph_node_id)[0] - for child in node.children: - child_graph_node_id = str(id(child)) - child_graph_node = self.graph.get_node(child_graph_node_id)[0] - self.graph.add_edge(self.pydot.Edge(graph_node, child_graph_node)) diff --git a/server/libs/lark/parsers/grammar_analysis.py b/server/libs/lark/parsers/grammar_analysis.py deleted file mode 100644 index 28d3cb6..0000000 --- a/server/libs/lark/parsers/grammar_analysis.py +++ /dev/null @@ -1,203 +0,0 @@ -"Provides for superficial grammar analysis." - -from collections import Counter, defaultdict -from typing import List, Dict, Iterator, FrozenSet, Set - -from ..utils import bfs, fzset, classify, OrderedSet -from ..exceptions import GrammarError -from ..grammar import Rule, Terminal, NonTerminal, Symbol -from ..common import ParserConf - - -class RulePtr: - __slots__ = ('rule', 'index') - rule: Rule - index: int - - def __init__(self, rule: Rule, index: int): - assert isinstance(rule, Rule) - assert index <= len(rule.expansion) - self.rule = rule - self.index = index - - def __repr__(self): - before = [x.name for x in self.rule.expansion[:self.index]] - after = [x.name for x in self.rule.expansion[self.index:]] - return '<%s : %s * %s>' % (self.rule.origin.name, ' '.join(before), ' '.join(after)) - - @property - def next(self) -> Symbol: - return self.rule.expansion[self.index] - - def advance(self, sym: Symbol) -> 'RulePtr': - assert self.next == sym - return RulePtr(self.rule, self.index+1) - - @property - def is_satisfied(self) -> bool: - return self.index == len(self.rule.expansion) - - def __eq__(self, other) -> bool: - if not isinstance(other, RulePtr): - return NotImplemented - return self.rule == other.rule and self.index == other.index - - def __hash__(self) -> int: - return hash((self.rule, self.index)) - - -State = FrozenSet[RulePtr] - -# state generation ensures no duplicate LR0ItemSets -class LR0ItemSet: - __slots__ = ('kernel', 'closure', 'transitions', 'lookaheads') - - kernel: State - closure: State - transitions: Dict[Symbol, 'LR0ItemSet'] - lookaheads: Dict[Symbol, Set[Rule]] - - def __init__(self, kernel, closure): - self.kernel = fzset(kernel) - self.closure = fzset(closure) - self.transitions = {} - self.lookaheads = defaultdict(set) - - def __repr__(self): - return '{%s | %s}' % (', '.join([repr(r) for r in self.kernel]), ', '.join([repr(r) for r in self.closure])) - - -def update_set(set1, set2): - if not set2 or set1 > set2: - return False - - copy = set(set1) - set1 |= set2 - return set1 != copy - -def calculate_sets(rules): - """Calculate FOLLOW sets. - - Adapted from: http://lara.epfl.ch/w/cc09:algorithm_for_first_and_follow_sets""" - symbols = {sym for rule in rules for sym in rule.expansion} | {rule.origin for rule in rules} - - # foreach grammar rule X ::= Y(1) ... Y(k) - # if k=0 or {Y(1),...,Y(k)} subset of NULLABLE then - # NULLABLE = NULLABLE union {X} - # for i = 1 to k - # if i=1 or {Y(1),...,Y(i-1)} subset of NULLABLE then - # FIRST(X) = FIRST(X) union FIRST(Y(i)) - # for j = i+1 to k - # if i=k or {Y(i+1),...Y(k)} subset of NULLABLE then - # FOLLOW(Y(i)) = FOLLOW(Y(i)) union FOLLOW(X) - # if i+1=j or {Y(i+1),...,Y(j-1)} subset of NULLABLE then - # FOLLOW(Y(i)) = FOLLOW(Y(i)) union FIRST(Y(j)) - # until none of NULLABLE,FIRST,FOLLOW changed in last iteration - - NULLABLE = set() - FIRST = {} - FOLLOW = {} - for sym in symbols: - FIRST[sym]={sym} if sym.is_term else set() - FOLLOW[sym]=set() - - # Calculate NULLABLE and FIRST - changed = True - while changed: - changed = False - - for rule in rules: - if set(rule.expansion) <= NULLABLE: - if update_set(NULLABLE, {rule.origin}): - changed = True - - for i, sym in enumerate(rule.expansion): - if set(rule.expansion[:i]) <= NULLABLE: - if update_set(FIRST[rule.origin], FIRST[sym]): - changed = True - else: - break - - # Calculate FOLLOW - changed = True - while changed: - changed = False - - for rule in rules: - for i, sym in enumerate(rule.expansion): - if i==len(rule.expansion)-1 or set(rule.expansion[i+1:]) <= NULLABLE: - if update_set(FOLLOW[sym], FOLLOW[rule.origin]): - changed = True - - for j in range(i+1, len(rule.expansion)): - if set(rule.expansion[i+1:j]) <= NULLABLE: - if update_set(FOLLOW[sym], FIRST[rule.expansion[j]]): - changed = True - - return FIRST, FOLLOW, NULLABLE - - -class GrammarAnalyzer: - def __init__(self, parser_conf: ParserConf, debug: bool=False, strict: bool=False): - self.debug = debug - self.strict = strict - - root_rules = {start: Rule(NonTerminal('$root_' + start), [NonTerminal(start), Terminal('$END')]) - for start in parser_conf.start} - - rules = parser_conf.rules + list(root_rules.values()) - self.rules_by_origin: Dict[NonTerminal, List[Rule]] = classify(rules, lambda r: r.origin) - - if len(rules) != len(set(rules)): - duplicates = [item for item, count in Counter(rules).items() if count > 1] - raise GrammarError("Rules defined twice: %s" % ', '.join(str(i) for i in duplicates)) - - for r in rules: - for sym in r.expansion: - if not (sym.is_term or sym in self.rules_by_origin): - raise GrammarError("Using an undefined rule: %s" % sym) - - self.start_states = {start: self.expand_rule(root_rule.origin) - for start, root_rule in root_rules.items()} - - self.end_states = {start: fzset({RulePtr(root_rule, len(root_rule.expansion))}) - for start, root_rule in root_rules.items()} - - lr0_root_rules = {start: Rule(NonTerminal('$root_' + start), [NonTerminal(start)]) - for start in parser_conf.start} - - lr0_rules = parser_conf.rules + list(lr0_root_rules.values()) - assert(len(lr0_rules) == len(set(lr0_rules))) - - self.lr0_rules_by_origin = classify(lr0_rules, lambda r: r.origin) - - # cache RulePtr(r, 0) in r (no duplicate RulePtr objects) - self.lr0_start_states = {start: LR0ItemSet([RulePtr(root_rule, 0)], self.expand_rule(root_rule.origin, self.lr0_rules_by_origin)) - for start, root_rule in lr0_root_rules.items()} - - self.FIRST, self.FOLLOW, self.NULLABLE = calculate_sets(rules) - - def expand_rule(self, source_rule: NonTerminal, rules_by_origin=None) -> OrderedSet[RulePtr]: - "Returns all init_ptrs accessible by rule (recursive)" - - if rules_by_origin is None: - rules_by_origin = self.rules_by_origin - - init_ptrs = OrderedSet[RulePtr]() - def _expand_rule(rule: NonTerminal) -> Iterator[NonTerminal]: - assert not rule.is_term, rule - - for r in rules_by_origin[rule]: - init_ptr = RulePtr(r, 0) - init_ptrs.add(init_ptr) - - if r.expansion: # if not empty rule - new_r = init_ptr.next - if not new_r.is_term: - assert isinstance(new_r, NonTerminal) - yield new_r - - for _ in bfs([source_rule], _expand_rule): - pass - - return init_ptrs diff --git a/server/libs/lark/parsers/lalr_analysis.py b/server/libs/lark/parsers/lalr_analysis.py deleted file mode 100644 index b7b3fdf..0000000 --- a/server/libs/lark/parsers/lalr_analysis.py +++ /dev/null @@ -1,332 +0,0 @@ -"""This module builds a LALR(1) transition-table for lalr_parser.py - -For now, shift/reduce conflicts are automatically resolved as shifts. -""" - -# Author: Erez Shinan (2017) -# Email : erezshin@gmail.com - -from typing import Dict, Set, Iterator, Tuple, List, TypeVar, Generic -from collections import defaultdict - -from ..utils import classify, classify_bool, bfs, fzset, Enumerator, logger -from ..exceptions import GrammarError - -from .grammar_analysis import GrammarAnalyzer, Terminal, LR0ItemSet, RulePtr, State -from ..grammar import Rule, Symbol -from ..common import ParserConf - -###{standalone - -class Action: - def __init__(self, name): - self.name = name - def __str__(self): - return self.name - def __repr__(self): - return str(self) - -Shift = Action('Shift') -Reduce = Action('Reduce') - -StateT = TypeVar("StateT") - -class ParseTableBase(Generic[StateT]): - states: Dict[StateT, Dict[str, Tuple]] - start_states: Dict[str, StateT] - end_states: Dict[str, StateT] - - def __init__(self, states, start_states, end_states): - self.states = states - self.start_states = start_states - self.end_states = end_states - - def serialize(self, memo): - tokens = Enumerator() - - states = { - state: {tokens.get(token): ((1, arg.serialize(memo)) if action is Reduce else (0, arg)) - for token, (action, arg) in actions.items()} - for state, actions in self.states.items() - } - - return { - 'tokens': tokens.reversed(), - 'states': states, - 'start_states': self.start_states, - 'end_states': self.end_states, - } - - @classmethod - def deserialize(cls, data, memo): - tokens = data['tokens'] - states = { - state: {tokens[token]: ((Reduce, Rule.deserialize(arg, memo)) if action==1 else (Shift, arg)) - for token, (action, arg) in actions.items()} - for state, actions in data['states'].items() - } - return cls(states, data['start_states'], data['end_states']) - -class ParseTable(ParseTableBase['State']): - """Parse-table whose key is State, i.e. set[RulePtr] - - Slower than IntParseTable, but useful for debugging - """ - pass - - -class IntParseTable(ParseTableBase[int]): - """Parse-table whose key is int. Best for performance.""" - - @classmethod - def from_ParseTable(cls, parse_table: ParseTable): - enum = list(parse_table.states) - state_to_idx: Dict['State', int] = {s:i for i,s in enumerate(enum)} - int_states = {} - - for s, la in parse_table.states.items(): - la = {k:(v[0], state_to_idx[v[1]]) if v[0] is Shift else v - for k,v in la.items()} - int_states[ state_to_idx[s] ] = la - - - start_states = {start:state_to_idx[s] for start, s in parse_table.start_states.items()} - end_states = {start:state_to_idx[s] for start, s in parse_table.end_states.items()} - return cls(int_states, start_states, end_states) - -###} - - -# digraph and traverse, see The Theory and Practice of Compiler Writing - -# computes F(x) = G(x) union (union { G(y) | x R y }) -# X: nodes -# R: relation (function mapping node -> list of nodes that satisfy the relation) -# G: set valued function -def digraph(X, R, G): - F = {} - S = [] - N = dict.fromkeys(X, 0) - for x in X: - # this is always true for the first iteration, but N[x] may be updated in traverse below - if N[x] == 0: - traverse(x, S, N, X, R, G, F) - return F - -# x: single node -# S: stack -# N: weights -# X: nodes -# R: relation (see above) -# G: set valued function -# F: set valued function we are computing (map of input -> output) -def traverse(x, S, N, X, R, G, F): - S.append(x) - d = len(S) - N[x] = d - F[x] = G[x] - for y in R[x]: - if N[y] == 0: - traverse(y, S, N, X, R, G, F) - n_x = N[x] - assert(n_x > 0) - n_y = N[y] - assert(n_y != 0) - if (n_y > 0) and (n_y < n_x): - N[x] = n_y - F[x].update(F[y]) - if N[x] == d: - f_x = F[x] - while True: - z = S.pop() - N[z] = -1 - F[z] = f_x - if z == x: - break - - -class LALR_Analyzer(GrammarAnalyzer): - lr0_itemsets: Set[LR0ItemSet] - nonterminal_transitions: List[Tuple[LR0ItemSet, Symbol]] - lookback: Dict[Tuple[LR0ItemSet, Symbol], Set[Tuple[LR0ItemSet, Rule]]] - includes: Dict[Tuple[LR0ItemSet, Symbol], Set[Tuple[LR0ItemSet, Symbol]]] - reads: Dict[Tuple[LR0ItemSet, Symbol], Set[Tuple[LR0ItemSet, Symbol]]] - directly_reads: Dict[Tuple[LR0ItemSet, Symbol], Set[Symbol]] - - - def __init__(self, parser_conf: ParserConf, debug: bool=False, strict: bool=False): - GrammarAnalyzer.__init__(self, parser_conf, debug, strict) - self.nonterminal_transitions = [] - self.directly_reads = defaultdict(set) - self.reads = defaultdict(set) - self.includes = defaultdict(set) - self.lookback = defaultdict(set) - - - def compute_lr0_states(self) -> None: - self.lr0_itemsets = set() - # map of kernels to LR0ItemSets - cache: Dict['State', LR0ItemSet] = {} - - def step(state: LR0ItemSet) -> Iterator[LR0ItemSet]: - _, unsat = classify_bool(state.closure, lambda rp: rp.is_satisfied) - - d = classify(unsat, lambda rp: rp.next) - for sym, rps in d.items(): - kernel = fzset({rp.advance(sym) for rp in rps}) - new_state = cache.get(kernel, None) - if new_state is None: - closure = set(kernel) - for rp in kernel: - if not rp.is_satisfied and not rp.next.is_term: - closure |= self.expand_rule(rp.next, self.lr0_rules_by_origin) - new_state = LR0ItemSet(kernel, closure) - cache[kernel] = new_state - - state.transitions[sym] = new_state - yield new_state - - self.lr0_itemsets.add(state) - - for _ in bfs(self.lr0_start_states.values(), step): - pass - - def compute_reads_relations(self): - # handle start state - for root in self.lr0_start_states.values(): - assert(len(root.kernel) == 1) - for rp in root.kernel: - assert(rp.index == 0) - self.directly_reads[(root, rp.next)] = set([ Terminal('$END') ]) - - for state in self.lr0_itemsets: - seen = set() - for rp in state.closure: - if rp.is_satisfied: - continue - s = rp.next - # if s is a not a nonterminal - if s not in self.lr0_rules_by_origin: - continue - if s in seen: - continue - seen.add(s) - nt = (state, s) - self.nonterminal_transitions.append(nt) - dr = self.directly_reads[nt] - r = self.reads[nt] - next_state = state.transitions[s] - for rp2 in next_state.closure: - if rp2.is_satisfied: - continue - s2 = rp2.next - # if s2 is a terminal - if s2 not in self.lr0_rules_by_origin: - dr.add(s2) - if s2 in self.NULLABLE: - r.add((next_state, s2)) - - def compute_includes_lookback(self): - for nt in self.nonterminal_transitions: - state, nonterminal = nt - includes = [] - lookback = self.lookback[nt] - for rp in state.closure: - if rp.rule.origin != nonterminal: - continue - # traverse the states for rp(.rule) - state2 = state - for i in range(rp.index, len(rp.rule.expansion)): - s = rp.rule.expansion[i] - nt2 = (state2, s) - state2 = state2.transitions[s] - if nt2 not in self.reads: - continue - for j in range(i + 1, len(rp.rule.expansion)): - if rp.rule.expansion[j] not in self.NULLABLE: - break - else: - includes.append(nt2) - # state2 is at the final state for rp.rule - if rp.index == 0: - for rp2 in state2.closure: - if (rp2.rule == rp.rule) and rp2.is_satisfied: - lookback.add((state2, rp2.rule)) - for nt2 in includes: - self.includes[nt2].add(nt) - - def compute_lookaheads(self): - read_sets = digraph(self.nonterminal_transitions, self.reads, self.directly_reads) - follow_sets = digraph(self.nonterminal_transitions, self.includes, read_sets) - - for nt, lookbacks in self.lookback.items(): - for state, rule in lookbacks: - for s in follow_sets[nt]: - state.lookaheads[s].add(rule) - - def compute_lalr1_states(self) -> None: - m: Dict[LR0ItemSet, Dict[str, Tuple]] = {} - reduce_reduce = [] - for itemset in self.lr0_itemsets: - actions: Dict[Symbol, Tuple] = {la: (Shift, next_state.closure) - for la, next_state in itemset.transitions.items()} - for la, rules in itemset.lookaheads.items(): - if len(rules) > 1: - # Try to resolve conflict based on priority - p = [(r.options.priority or 0, r) for r in rules] - p.sort(key=lambda r: r[0], reverse=True) - best, second_best = p[:2] - if best[0] > second_best[0]: - rules = {best[1]} - else: - reduce_reduce.append((itemset, la, rules)) - continue - - rule ,= rules - if la in actions: - if self.strict: - raise GrammarError(f"Shift/Reduce conflict for terminal {la.name}. [strict-mode]\n ") - elif self.debug: - logger.warning('Shift/Reduce conflict for terminal %s: (resolving as shift)', la.name) - logger.warning(' * %s', rule) - else: - logger.debug('Shift/Reduce conflict for terminal %s: (resolving as shift)', la.name) - logger.debug(' * %s', rule) - else: - actions[la] = (Reduce, rule) - m[itemset] = { k.name: v for k, v in actions.items() } - - if reduce_reduce: - msgs = [] - for itemset, la, rules in reduce_reduce: - msg = 'Reduce/Reduce collision in %s between the following rules: %s' % (la, ''.join([ '\n\t- ' + str(r) for r in rules ])) - if self.debug: - msg += '\n collision occurred in state: {%s\n }' % ''.join(['\n\t' + str(x) for x in itemset.closure]) - msgs.append(msg) - raise GrammarError('\n\n'.join(msgs)) - - states = { k.closure: v for k, v in m.items() } - - # compute end states - end_states: Dict[str, 'State'] = {} - for state in states: - for rp in state: - for start in self.lr0_start_states: - if rp.rule.origin.name == ('$root_' + start) and rp.is_satisfied: - assert start not in end_states - end_states[start] = state - - start_states = { start: state.closure for start, state in self.lr0_start_states.items() } - _parse_table = ParseTable(states, start_states, end_states) - - if self.debug: - self.parse_table = _parse_table - else: - self.parse_table = IntParseTable.from_ParseTable(_parse_table) - - def compute_lalr(self): - self.compute_lr0_states() - self.compute_reads_relations() - self.compute_includes_lookback() - self.compute_lookaheads() - self.compute_lalr1_states() diff --git a/server/libs/lark/parsers/lalr_interactive_parser.py b/server/libs/lark/parsers/lalr_interactive_parser.py deleted file mode 100644 index fbf0d1f..0000000 --- a/server/libs/lark/parsers/lalr_interactive_parser.py +++ /dev/null @@ -1,158 +0,0 @@ -# This module provides a LALR interactive parser, which is used for debugging and error handling - -from typing import Iterator, List -from copy import copy -import warnings - -from lark.exceptions import UnexpectedToken -from lark.lexer import Token, LexerThread -from .lalr_parser_state import ParserState - -###{standalone - -class InteractiveParser: - """InteractiveParser gives you advanced control over parsing and error handling when parsing with LALR. - - For a simpler interface, see the ``on_error`` argument to ``Lark.parse()``. - """ - def __init__(self, parser, parser_state: ParserState, lexer_thread: LexerThread): - self.parser = parser - self.parser_state = parser_state - self.lexer_thread = lexer_thread - self.result = None - - @property - def lexer_state(self) -> LexerThread: - warnings.warn("lexer_state will be removed in subsequent releases. Use lexer_thread instead.", DeprecationWarning) - return self.lexer_thread - - def feed_token(self, token: Token): - """Feed the parser with a token, and advance it to the next state, as if it received it from the lexer. - - Note that ``token`` has to be an instance of ``Token``. - """ - return self.parser_state.feed_token(token, token.type == '$END') - - def iter_parse(self) -> Iterator[Token]: - """Step through the different stages of the parse, by reading tokens from the lexer - and feeding them to the parser, one per iteration. - - Returns an iterator of the tokens it encounters. - - When the parse is over, the resulting tree can be found in ``InteractiveParser.result``. - """ - for token in self.lexer_thread.lex(self.parser_state): - yield token - self.result = self.feed_token(token) - - def exhaust_lexer(self) -> List[Token]: - """Try to feed the rest of the lexer state into the interactive parser. - - Note that this modifies the instance in place and does not feed an '$END' Token - """ - return list(self.iter_parse()) - - - def feed_eof(self, last_token=None): - """Feed a '$END' Token. Borrows from 'last_token' if given.""" - eof = Token.new_borrow_pos('$END', '', last_token) if last_token is not None else self.lexer_thread._Token('$END', '', 0, 1, 1) - return self.feed_token(eof) - - - def __copy__(self): - """Create a new interactive parser with a separate state. - - Calls to feed_token() won't affect the old instance, and vice-versa. - """ - return self.copy() - - def copy(self, deepcopy_values=True): - return type(self)( - self.parser, - self.parser_state.copy(deepcopy_values=deepcopy_values), - copy(self.lexer_thread), - ) - - def __eq__(self, other): - if not isinstance(other, InteractiveParser): - return False - - return self.parser_state == other.parser_state and self.lexer_thread == other.lexer_thread - - def as_immutable(self): - """Convert to an ``ImmutableInteractiveParser``.""" - p = copy(self) - return ImmutableInteractiveParser(p.parser, p.parser_state, p.lexer_thread) - - def pretty(self): - """Print the output of ``choices()`` in a way that's easier to read.""" - out = ["Parser choices:"] - for k, v in self.choices().items(): - out.append('\t- %s -> %r' % (k, v)) - out.append('stack size: %s' % len(self.parser_state.state_stack)) - return '\n'.join(out) - - def choices(self): - """Returns a dictionary of token types, matched to their action in the parser. - - Only returns token types that are accepted by the current state. - - Updated by ``feed_token()``. - """ - return self.parser_state.parse_conf.parse_table.states[self.parser_state.position] - - def accepts(self): - """Returns the set of possible tokens that will advance the parser into a new valid state.""" - accepts = set() - conf_no_callbacks = copy(self.parser_state.parse_conf) - # We don't want to call callbacks here since those might have arbitrary side effects - # and are unnecessarily slow. - conf_no_callbacks.callbacks = {} - for t in self.choices(): - if t.isupper(): # is terminal? - new_cursor = self.copy(deepcopy_values=False) - new_cursor.parser_state.parse_conf = conf_no_callbacks - try: - new_cursor.feed_token(self.lexer_thread._Token(t, '')) - except UnexpectedToken: - pass - else: - accepts.add(t) - return accepts - - def resume_parse(self): - """Resume automated parsing from the current state. - """ - return self.parser.parse_from_state(self.parser_state, last_token=self.lexer_thread.state.last_token) - - - -class ImmutableInteractiveParser(InteractiveParser): - """Same as ``InteractiveParser``, but operations create a new instance instead - of changing it in-place. - """ - - result = None - - def __hash__(self): - return hash((self.parser_state, self.lexer_thread)) - - def feed_token(self, token): - c = copy(self) - c.result = InteractiveParser.feed_token(c, token) - return c - - def exhaust_lexer(self): - """Try to feed the rest of the lexer state into the parser. - - Note that this returns a new ImmutableInteractiveParser and does not feed an '$END' Token""" - cursor = self.as_mutable() - cursor.exhaust_lexer() - return cursor.as_immutable() - - def as_mutable(self): - """Convert to an ``InteractiveParser``.""" - p = copy(self) - return InteractiveParser(p.parser, p.parser_state, p.lexer_thread) - -###} diff --git a/server/libs/lark/parsers/lalr_parser.py b/server/libs/lark/parsers/lalr_parser.py deleted file mode 100644 index 6ae2a04..0000000 --- a/server/libs/lark/parsers/lalr_parser.py +++ /dev/null @@ -1,122 +0,0 @@ -"""This module implements a LALR(1) Parser -""" -# Author: Erez Shinan (2017) -# Email : erezshin@gmail.com -from typing import Dict, Any, Optional -from ..lexer import Token, LexerThread -from ..utils import Serialize -from ..common import ParserConf, ParserCallbacks - -from .lalr_analysis import LALR_Analyzer, IntParseTable, ParseTableBase -from .lalr_interactive_parser import InteractiveParser -from lark.exceptions import UnexpectedCharacters, UnexpectedInput, UnexpectedToken -from .lalr_parser_state import ParserState, ParseConf - -###{standalone - -class LALR_Parser(Serialize): - def __init__(self, parser_conf: ParserConf, debug: bool=False, strict: bool=False): - analysis = LALR_Analyzer(parser_conf, debug=debug, strict=strict) - analysis.compute_lalr() - callbacks = parser_conf.callbacks - - self._parse_table = analysis.parse_table - self.parser_conf = parser_conf - self.parser = _Parser(analysis.parse_table, callbacks, debug) - - @classmethod - def deserialize(cls, data, memo, callbacks, debug=False): - inst = cls.__new__(cls) - inst._parse_table = IntParseTable.deserialize(data, memo) - inst.parser = _Parser(inst._parse_table, callbacks, debug) - return inst - - def serialize(self, memo: Any = None) -> Dict[str, Any]: - return self._parse_table.serialize(memo) - - def parse_interactive(self, lexer: LexerThread, start: str): - return self.parser.parse(lexer, start, start_interactive=True) - - def parse(self, lexer, start, on_error=None): - try: - return self.parser.parse(lexer, start) - except UnexpectedInput as e: - if on_error is None: - raise - - while True: - if isinstance(e, UnexpectedCharacters): - s = e.interactive_parser.lexer_thread.state - p = s.line_ctr.char_pos - - if not on_error(e): - raise e - - if isinstance(e, UnexpectedCharacters): - # If user didn't change the character position, then we should - if p == s.line_ctr.char_pos: - s.line_ctr.feed(s.text[p:p+1]) - - try: - return e.interactive_parser.resume_parse() - except UnexpectedToken as e2: - if (isinstance(e, UnexpectedToken) - and e.token.type == e2.token.type == '$END' - and e.interactive_parser == e2.interactive_parser): - # Prevent infinite loop - raise e2 - e = e2 - except UnexpectedCharacters as e2: - e = e2 - - -class _Parser: - parse_table: ParseTableBase - callbacks: ParserCallbacks - debug: bool - - def __init__(self, parse_table: ParseTableBase, callbacks: ParserCallbacks, debug: bool=False): - self.parse_table = parse_table - self.callbacks = callbacks - self.debug = debug - - def parse(self, lexer: LexerThread, start: str, value_stack=None, state_stack=None, start_interactive=False): - parse_conf = ParseConf(self.parse_table, self.callbacks, start) - parser_state = ParserState(parse_conf, lexer, state_stack, value_stack) - if start_interactive: - return InteractiveParser(self, parser_state, parser_state.lexer) - return self.parse_from_state(parser_state) - - - def parse_from_state(self, state: ParserState, last_token: Optional[Token]=None): - """Run the main LALR parser loop - - Parameters: - state - the initial state. Changed in-place. - last_token - Used only for line information in case of an empty lexer. - """ - try: - token = last_token - for token in state.lexer.lex(state): - assert token is not None - state.feed_token(token) - - end_token = Token.new_borrow_pos('$END', '', token) if token else Token('$END', '', 0, 1, 1) - return state.feed_token(end_token, True) - except UnexpectedInput as e: - try: - e.interactive_parser = InteractiveParser(self, state, state.lexer) - except NameError: - pass - raise e - except Exception as e: - if self.debug: - print("") - print("STATE STACK DUMP") - print("----------------") - for i, s in enumerate(state.state_stack): - print('%d)' % i , s) - print("") - - raise -###} diff --git a/server/libs/lark/parsers/lalr_parser_state.py b/server/libs/lark/parsers/lalr_parser_state.py deleted file mode 100644 index 273bc00..0000000 --- a/server/libs/lark/parsers/lalr_parser_state.py +++ /dev/null @@ -1,110 +0,0 @@ -from copy import deepcopy, copy -from typing import Dict, Any, Generic, List -from ..lexer import Token, LexerThread -from ..common import ParserCallbacks - -from .lalr_analysis import Shift, ParseTableBase, StateT -from lark.exceptions import UnexpectedToken - -###{standalone - -class ParseConf(Generic[StateT]): - __slots__ = 'parse_table', 'callbacks', 'start', 'start_state', 'end_state', 'states' - - parse_table: ParseTableBase[StateT] - callbacks: ParserCallbacks - start: str - - start_state: StateT - end_state: StateT - states: Dict[StateT, Dict[str, tuple]] - - def __init__(self, parse_table: ParseTableBase[StateT], callbacks: ParserCallbacks, start: str): - self.parse_table = parse_table - - self.start_state = self.parse_table.start_states[start] - self.end_state = self.parse_table.end_states[start] - self.states = self.parse_table.states - - self.callbacks = callbacks - self.start = start - -class ParserState(Generic[StateT]): - __slots__ = 'parse_conf', 'lexer', 'state_stack', 'value_stack' - - parse_conf: ParseConf[StateT] - lexer: LexerThread - state_stack: List[StateT] - value_stack: list - - def __init__(self, parse_conf: ParseConf[StateT], lexer: LexerThread, state_stack=None, value_stack=None): - self.parse_conf = parse_conf - self.lexer = lexer - self.state_stack = state_stack or [self.parse_conf.start_state] - self.value_stack = value_stack or [] - - @property - def position(self) -> StateT: - return self.state_stack[-1] - - # Necessary for match_examples() to work - def __eq__(self, other) -> bool: - if not isinstance(other, ParserState): - return NotImplemented - return len(self.state_stack) == len(other.state_stack) and self.position == other.position - - def __copy__(self): - return self.copy() - - def copy(self, deepcopy_values=True) -> 'ParserState[StateT]': - return type(self)( - self.parse_conf, - self.lexer, # XXX copy - copy(self.state_stack), - deepcopy(self.value_stack) if deepcopy_values else copy(self.value_stack), - ) - - def feed_token(self, token: Token, is_end=False) -> Any: - state_stack = self.state_stack - value_stack = self.value_stack - states = self.parse_conf.states - end_state = self.parse_conf.end_state - callbacks = self.parse_conf.callbacks - - while True: - state = state_stack[-1] - try: - action, arg = states[state][token.type] - except KeyError: - expected = {s for s in states[state].keys() if s.isupper()} - raise UnexpectedToken(token, expected, state=self, interactive_parser=None) - - assert arg != end_state - - if action is Shift: - # shift once and return - assert not is_end - state_stack.append(arg) - value_stack.append(token if token.type not in callbacks else callbacks[token.type](token)) - return - else: - # reduce+shift as many times as necessary - rule = arg - size = len(rule.expansion) - if size: - s = value_stack[-size:] - del state_stack[-size:] - del value_stack[-size:] - else: - s = [] - - value = callbacks[rule](s) if callbacks else s - - _action, new_state = states[state_stack[-1]][rule.origin.name] - assert _action is Shift - state_stack.append(new_state) - value_stack.append(value) - - if is_end and state_stack[-1] == end_state: - return value_stack[-1] -###} diff --git a/server/libs/lark/parsers/xearley.py b/server/libs/lark/parsers/xearley.py deleted file mode 100644 index a0f43ac..0000000 --- a/server/libs/lark/parsers/xearley.py +++ /dev/null @@ -1,165 +0,0 @@ -"""This module implements an Earley parser with a dynamic lexer - -The core Earley algorithm used here is based on Elizabeth Scott's implementation, here: - https://www.sciencedirect.com/science/article/pii/S1571066108001497 - -That is probably the best reference for understanding the algorithm here. - -The Earley parser outputs an SPPF-tree as per that document. The SPPF tree format -is better documented here: - http://www.bramvandersanden.com/post/2014/06/shared-packed-parse-forest/ - -Instead of running a lexer beforehand, or using a costy char-by-char method, this parser -uses regular expressions by necessity, achieving high-performance while maintaining all of -Earley's power in parsing any CFG. -""" - -from typing import TYPE_CHECKING, Callable, Optional, List, Any -from collections import defaultdict - -from ..tree import Tree -from ..exceptions import UnexpectedCharacters -from ..lexer import Token -from ..grammar import Terminal -from .earley import Parser as BaseParser -from .earley_forest import TokenNode - -if TYPE_CHECKING: - from ..common import LexerConf, ParserConf - -class Parser(BaseParser): - def __init__(self, lexer_conf: 'LexerConf', parser_conf: 'ParserConf', term_matcher: Callable, - resolve_ambiguity: bool=True, complete_lex: bool=False, debug: bool=False, - tree_class: Optional[Callable[[str, List], Any]]=Tree, ordered_sets: bool=True): - BaseParser.__init__(self, lexer_conf, parser_conf, term_matcher, resolve_ambiguity, - debug, tree_class, ordered_sets) - self.ignore = [Terminal(t) for t in lexer_conf.ignore] - self.complete_lex = complete_lex - - def _parse(self, stream, columns, to_scan, start_symbol=None): - - def scan(i, to_scan): - """The core Earley Scanner. - - This is a custom implementation of the scanner that uses the - Lark lexer to match tokens. The scan list is built by the - Earley predictor, based on the previously completed tokens. - This ensures that at each phase of the parse we have a custom - lexer context, allowing for more complex ambiguities.""" - - node_cache = {} - - # 1) Loop the expectations and ask the lexer to match. - # Since regexp is forward looking on the input stream, and we only - # want to process tokens when we hit the point in the stream at which - # they complete, we push all tokens into a buffer (delayed_matches), to - # be held possibly for a later parse step when we reach the point in the - # input stream at which they complete. - for item in self.Set(to_scan): - m = match(item.expect, stream, i) - if m: - t = Token(item.expect.name, m.group(0), i, text_line, text_column) - delayed_matches[m.end()].append( (item, i, t) ) - - if self.complete_lex: - s = m.group(0) - for j in range(1, len(s)): - m = match(item.expect, s[:-j]) - if m: - t = Token(item.expect.name, m.group(0), i, text_line, text_column) - delayed_matches[i+m.end()].append( (item, i, t) ) - - # XXX The following 3 lines were commented out for causing a bug. See issue #768 - # # Remove any items that successfully matched in this pass from the to_scan buffer. - # # This ensures we don't carry over tokens that already matched, if we're ignoring below. - # to_scan.remove(item) - - # 3) Process any ignores. This is typically used for e.g. whitespace. - # We carry over any unmatched items from the to_scan buffer to be matched again after - # the ignore. This should allow us to use ignored symbols in non-terminals to implement - # e.g. mandatory spacing. - for x in self.ignore: - m = match(x, stream, i) - if m: - # Carry over any items still in the scan buffer, to past the end of the ignored items. - delayed_matches[m.end()].extend([(item, i, None) for item in to_scan ]) - - # If we're ignoring up to the end of the file, # carry over the start symbol if it already completed. - delayed_matches[m.end()].extend([(item, i, None) for item in columns[i] if item.is_complete and item.s == start_symbol]) - - next_to_scan = self.Set() - next_set = self.Set() - columns.append(next_set) - transitives.append({}) - - ## 4) Process Tokens from delayed_matches. - # This is the core of the Earley scanner. Create an SPPF node for each Token, - # and create the symbol node in the SPPF tree. Advance the item that completed, - # and add the resulting new item to either the Earley set (for processing by the - # completer/predictor) or the to_scan buffer for the next parse step. - for item, start, token in delayed_matches[i+1]: - if token is not None: - token.end_line = text_line - token.end_column = text_column + 1 - token.end_pos = i + 1 - - new_item = item.advance() - label = (new_item.s, new_item.start, i + 1) - token_node = TokenNode(token, terminals[token.type]) - new_item.node = node_cache[label] if label in node_cache else node_cache.setdefault(label, self.SymbolNode(*label)) - new_item.node.add_family(new_item.s, item.rule, new_item.start, item.node, token_node) - else: - new_item = item - - if new_item.expect in self.TERMINALS: - # add (B ::= Aai+1.B, h, y) to Q' - next_to_scan.add(new_item) - else: - # add (B ::= Aa+1.B, h, y) to Ei+1 - next_set.add(new_item) - - del delayed_matches[i+1] # No longer needed, so unburden memory - - if not next_set and not delayed_matches and not next_to_scan: - considered_rules = list(sorted(to_scan, key=lambda key: key.rule.origin.name)) - raise UnexpectedCharacters(stream, i, text_line, text_column, {item.expect.name for item in to_scan}, - set(to_scan), state=frozenset(i.s for i in to_scan), - considered_rules=considered_rules - ) - - return next_to_scan - - - delayed_matches = defaultdict(list) - match = self.term_matcher - terminals = self.lexer_conf.terminals_by_name - - # Cache for nodes & tokens created in a particular parse step. - transitives = [{}] - - text_line = 1 - text_column = 1 - - ## The main Earley loop. - # Run the Prediction/Completion cycle for any Items in the current Earley set. - # Completions will be added to the SPPF tree, and predictions will be recursively - # processed down to terminals/empty nodes to be added to the scanner for the next - # step. - i = 0 - for token in stream: - self.predict_and_complete(i, to_scan, columns, transitives) - - to_scan = scan(i, to_scan) - - if token == '\n': - text_line += 1 - text_column = 1 - else: - text_column += 1 - i += 1 - - self.predict_and_complete(i, to_scan, columns, transitives) - - ## Column is now the final column in the parse. - assert i == len(columns)-1 - return to_scan diff --git a/server/libs/lark/reconstruct.py b/server/libs/lark/reconstruct.py deleted file mode 100644 index 2d8423a..0000000 --- a/server/libs/lark/reconstruct.py +++ /dev/null @@ -1,107 +0,0 @@ -"""This is an experimental tool for reconstructing text from a shaped tree, based on a Lark grammar. -""" - -from typing import Dict, Callable, Iterable, Optional - -from .lark import Lark -from .tree import Tree, ParseTree -from .visitors import Transformer_InPlace -from .lexer import Token, PatternStr, TerminalDef -from .grammar import Terminal, NonTerminal, Symbol - -from .tree_matcher import TreeMatcher, is_discarded_terminal -from .utils import is_id_continue - -def is_iter_empty(i): - try: - _ = next(i) - return False - except StopIteration: - return True - - -class WriteTokensTransformer(Transformer_InPlace): - "Inserts discarded tokens into their correct place, according to the rules of grammar" - - tokens: Dict[str, TerminalDef] - term_subs: Dict[str, Callable[[Symbol], str]] - - def __init__(self, tokens: Dict[str, TerminalDef], term_subs: Dict[str, Callable[[Symbol], str]]) -> None: - self.tokens = tokens - self.term_subs = term_subs - - def __default__(self, data, children, meta): - if not getattr(meta, 'match_tree', False): - return Tree(data, children) - - iter_args = iter(children) - to_write = [] - for sym in meta.orig_expansion: - if is_discarded_terminal(sym): - try: - v = self.term_subs[sym.name](sym) - except KeyError: - t = self.tokens[sym.name] - if not isinstance(t.pattern, PatternStr): - raise NotImplementedError("Reconstructing regexps not supported yet: %s" % t) - - v = t.pattern.value - to_write.append(v) - else: - x = next(iter_args) - if isinstance(x, list): - to_write += x - else: - if isinstance(x, Token): - assert Terminal(x.type) == sym, x - else: - assert NonTerminal(x.data) == sym, (sym, x) - to_write.append(x) - - assert is_iter_empty(iter_args) - return to_write - - -class Reconstructor(TreeMatcher): - """ - A Reconstructor that will, given a full parse Tree, generate source code. - - Note: - The reconstructor cannot generate values from regexps. If you need to produce discarded - regexes, such as newlines, use `term_subs` and provide default values for them. - - Parameters: - parser: a Lark instance - term_subs: a dictionary of [Terminal name as str] to [output text as str] - """ - - write_tokens: WriteTokensTransformer - - def __init__(self, parser: Lark, term_subs: Optional[Dict[str, Callable[[Symbol], str]]]=None) -> None: - TreeMatcher.__init__(self, parser) - - self.write_tokens = WriteTokensTransformer({t.name:t for t in self.tokens}, term_subs or {}) - - def _reconstruct(self, tree): - unreduced_tree = self.match_tree(tree, tree.data) - - res = self.write_tokens.transform(unreduced_tree) - for item in res: - if isinstance(item, Tree): - # TODO use orig_expansion.rulename to support templates - yield from self._reconstruct(item) - else: - yield item - - def reconstruct(self, tree: ParseTree, postproc: Optional[Callable[[Iterable[str]], Iterable[str]]]=None, insert_spaces: bool=True) -> str: - x = self._reconstruct(tree) - if postproc: - x = postproc(x) - y = [] - prev_item = '' - for item in x: - if insert_spaces and prev_item and item and is_id_continue(prev_item[-1]) and is_id_continue(item[0]): - y.append(' ') - y.append(item) - prev_item = item - return ''.join(y) diff --git a/server/libs/lark/tools/__init__.py b/server/libs/lark/tools/__init__.py deleted file mode 100644 index eeb40e1..0000000 --- a/server/libs/lark/tools/__init__.py +++ /dev/null @@ -1,70 +0,0 @@ -import sys -from argparse import ArgumentParser, FileType -from textwrap import indent -from logging import DEBUG, INFO, WARN, ERROR -from typing import Optional -import warnings - -from lark import Lark, logger -try: - from interegular import logger as interegular_logger - has_interegular = True -except ImportError: - has_interegular = False - -lalr_argparser = ArgumentParser(add_help=False, epilog='Look at the Lark documentation for more info on the options') - -flags = [ - ('d', 'debug'), - 'keep_all_tokens', - 'regex', - 'propagate_positions', - 'maybe_placeholders', - 'use_bytes' -] - -options = ['start', 'lexer'] - -lalr_argparser.add_argument('-v', '--verbose', action='count', default=0, help="Increase Logger output level, up to three times") -lalr_argparser.add_argument('-s', '--start', action='append', default=[]) -lalr_argparser.add_argument('-l', '--lexer', default='contextual', choices=('basic', 'contextual')) -lalr_argparser.add_argument('-o', '--out', type=FileType('w', encoding='utf-8'), default=sys.stdout, help='the output file (default=stdout)') -lalr_argparser.add_argument('grammar_file', type=FileType('r', encoding='utf-8'), help='A valid .lark file') - -for flag in flags: - if isinstance(flag, tuple): - options.append(flag[1]) - lalr_argparser.add_argument('-' + flag[0], '--' + flag[1], action='store_true') - elif isinstance(flag, str): - options.append(flag) - lalr_argparser.add_argument('--' + flag, action='store_true') - else: - raise NotImplementedError("flags must only contain strings or tuples of strings") - - -def build_lalr(namespace): - logger.setLevel((ERROR, WARN, INFO, DEBUG)[min(namespace.verbose, 3)]) - if has_interegular: - interegular_logger.setLevel(logger.getEffectiveLevel()) - if len(namespace.start) == 0: - namespace.start.append('start') - kwargs = {n: getattr(namespace, n) for n in options} - return Lark(namespace.grammar_file, parser='lalr', **kwargs), namespace.out - - -def showwarning_as_comment(message, category, filename, lineno, file=None, line=None): - # Based on warnings._showwarnmsg_impl - text = warnings.formatwarning(message, category, filename, lineno, line) - text = indent(text, '# ') - if file is None: - file = sys.stderr - if file is None: - return - try: - file.write(text) - except OSError: - pass - - -def make_warnings_comments(): - warnings.showwarning = showwarning_as_comment diff --git a/server/libs/lark/tools/nearley.py b/server/libs/lark/tools/nearley.py deleted file mode 100644 index 1fc27d5..0000000 --- a/server/libs/lark/tools/nearley.py +++ /dev/null @@ -1,202 +0,0 @@ -"Converts Nearley grammars to Lark" - -import os.path -import sys -import codecs -import argparse - - -from lark import Lark, Transformer, v_args - -nearley_grammar = r""" - start: (ruledef|directive)+ - - directive: "@" NAME (STRING|NAME) - | "@" JS -> js_code - ruledef: NAME "->" expansions - | NAME REGEXP "->" expansions -> macro - expansions: expansion ("|" expansion)* - - expansion: expr+ js - - ?expr: item (":" /[+*?]/)? - - ?item: rule|string|regexp|null - | "(" expansions ")" - - rule: NAME - string: STRING - regexp: REGEXP - null: "null" - JS: /{%.*?%}/s - js: JS? - - NAME: /[a-zA-Z_$]\w*/ - COMMENT: /#[^\n]*/ - REGEXP: /\[.*?\]/ - - STRING: _STRING "i"? - - %import common.ESCAPED_STRING -> _STRING - %import common.WS - %ignore WS - %ignore COMMENT - - """ - -nearley_grammar_parser = Lark(nearley_grammar, parser='earley', lexer='basic') - -def _get_rulename(name): - name = {'_': '_ws_maybe', '__': '_ws'}.get(name, name) - return 'n_' + name.replace('$', '__DOLLAR__').lower() - -@v_args(inline=True) -class NearleyToLark(Transformer): - def __init__(self): - self._count = 0 - self.extra_rules = {} - self.extra_rules_rev = {} - self.alias_js_code = {} - - def _new_function(self, code): - name = 'alias_%d' % self._count - self._count += 1 - - self.alias_js_code[name] = code - return name - - def _extra_rule(self, rule): - if rule in self.extra_rules_rev: - return self.extra_rules_rev[rule] - - name = 'xrule_%d' % len(self.extra_rules) - assert name not in self.extra_rules - self.extra_rules[name] = rule - self.extra_rules_rev[rule] = name - return name - - def rule(self, name): - return _get_rulename(name) - - def ruledef(self, name, exps): - return '!%s: %s' % (_get_rulename(name), exps) - - def expr(self, item, op): - rule = '(%s)%s' % (item, op) - return self._extra_rule(rule) - - def regexp(self, r): - return '/%s/' % r - - def null(self): - return '' - - def string(self, s): - return self._extra_rule(s) - - def expansion(self, *x): - x, js = x[:-1], x[-1] - if js.children: - js_code ,= js.children - js_code = js_code[2:-2] - alias = '-> ' + self._new_function(js_code) - else: - alias = '' - return ' '.join(x) + alias - - def expansions(self, *x): - return '%s' % ('\n |'.join(x)) - - def start(self, *rules): - return '\n'.join(filter(None, rules)) - -def _nearley_to_lark(g, builtin_path, n2l, js_code, folder_path, includes): - rule_defs = [] - - tree = nearley_grammar_parser.parse(g) - for statement in tree.children: - if statement.data == 'directive': - directive, arg = statement.children - if directive in ('builtin', 'include'): - folder = builtin_path if directive == 'builtin' else folder_path - path = os.path.join(folder, arg[1:-1]) - if path not in includes: - includes.add(path) - with codecs.open(path, encoding='utf8') as f: - text = f.read() - rule_defs += _nearley_to_lark(text, builtin_path, n2l, js_code, os.path.abspath(os.path.dirname(path)), includes) - else: - assert False, directive - elif statement.data == 'js_code': - code ,= statement.children - code = code[2:-2] - js_code.append(code) - elif statement.data == 'macro': - pass # TODO Add support for macros! - elif statement.data == 'ruledef': - rule_defs.append(n2l.transform(statement)) - else: - raise Exception("Unknown statement: %s" % statement) - - return rule_defs - - -def create_code_for_nearley_grammar(g, start, builtin_path, folder_path, es6=False): - import js2py - - emit_code = [] - def emit(x=None): - if x: - emit_code.append(x) - emit_code.append('\n') - - js_code = ['function id(x) {return x[0];}'] - n2l = NearleyToLark() - rule_defs = _nearley_to_lark(g, builtin_path, n2l, js_code, folder_path, set()) - lark_g = '\n'.join(rule_defs) - lark_g += '\n'+'\n'.join('!%s: %s' % item for item in n2l.extra_rules.items()) - - emit('from lark import Lark, Transformer') - emit() - emit('grammar = ' + repr(lark_g)) - emit() - - for alias, code in n2l.alias_js_code.items(): - js_code.append('%s = (%s);' % (alias, code)) - - if es6: - emit(js2py.translate_js6('\n'.join(js_code))) - else: - emit(js2py.translate_js('\n'.join(js_code))) - emit('class TransformNearley(Transformer):') - for alias in n2l.alias_js_code: - emit(" %s = var.get('%s').to_python()" % (alias, alias)) - emit(" __default__ = lambda self, n, c, m: c if c else None") - - emit() - emit('parser = Lark(grammar, start="n_%s", maybe_placeholders=False)' % start) - emit('def parse(text):') - emit(' return TransformNearley().transform(parser.parse(text))') - - return ''.join(emit_code) - -def main(fn, start, nearley_lib, es6=False): - with codecs.open(fn, encoding='utf8') as f: - grammar = f.read() - return create_code_for_nearley_grammar(grammar, start, os.path.join(nearley_lib, 'builtin'), os.path.abspath(os.path.dirname(fn)), es6=es6) - -def get_arg_parser(): - parser = argparse.ArgumentParser(description='Reads a Nearley grammar (with js functions), and outputs an equivalent lark parser.') - parser.add_argument('nearley_grammar', help='Path to the file containing the nearley grammar') - parser.add_argument('start_rule', help='Rule within the nearley grammar to make the base rule') - parser.add_argument('nearley_lib', help='Path to root directory of nearley codebase (used for including builtins)') - parser.add_argument('--es6', help='Enable experimental ES6 support', action='store_true') - return parser - -if __name__ == '__main__': - parser = get_arg_parser() - if len(sys.argv) == 1: - parser.print_help(sys.stderr) - sys.exit(1) - args = parser.parse_args() - print(main(fn=args.nearley_grammar, start=args.start_rule, nearley_lib=args.nearley_lib, es6=args.es6)) diff --git a/server/libs/lark/tools/serialize.py b/server/libs/lark/tools/serialize.py deleted file mode 100644 index eb28824..0000000 --- a/server/libs/lark/tools/serialize.py +++ /dev/null @@ -1,32 +0,0 @@ -import sys -import json - -from lark.grammar import Rule -from lark.lexer import TerminalDef -from lark.tools import lalr_argparser, build_lalr - -import argparse - -argparser = argparse.ArgumentParser(prog='python -m lark.tools.serialize', parents=[lalr_argparser], - description="Lark Serialization Tool - Stores Lark's internal state & LALR analysis as a JSON file", - epilog='Look at the Lark documentation for more info on the options') - - -def serialize(lark_inst, outfile): - data, memo = lark_inst.memo_serialize([TerminalDef, Rule]) - outfile.write('{\n') - outfile.write(' "data": %s,\n' % json.dumps(data)) - outfile.write(' "memo": %s\n' % json.dumps(memo)) - outfile.write('}\n') - - -def main(): - if len(sys.argv)==1: - argparser.print_help(sys.stderr) - sys.exit(1) - ns = argparser.parse_args() - serialize(*build_lalr(ns)) - - -if __name__ == '__main__': - main() diff --git a/server/libs/lark/tools/standalone.py b/server/libs/lark/tools/standalone.py deleted file mode 100644 index 9940ccb..0000000 --- a/server/libs/lark/tools/standalone.py +++ /dev/null @@ -1,196 +0,0 @@ -###{standalone -# -# -# Lark Stand-alone Generator Tool -# ---------------------------------- -# Generates a stand-alone LALR(1) parser -# -# Git: https://github.com/erezsh/lark -# Author: Erez Shinan (erezshin@gmail.com) -# -# -# >>> LICENSE -# -# This tool and its generated code use a separate license from Lark, -# and are subject to the terms of the Mozilla Public License, v. 2.0. -# If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. -# -# If you wish to purchase a commercial license for this tool and its -# generated code, you may contact me via email or otherwise. -# -# If MPL2 is incompatible with your free or open-source project, -# contact me and we'll work it out. -# -# - -from copy import deepcopy -from abc import ABC, abstractmethod -from types import ModuleType -from typing import ( - TypeVar, Generic, Type, Tuple, List, Dict, Iterator, Collection, Callable, Optional, FrozenSet, Any, - Union, Iterable, IO, TYPE_CHECKING, overload, Sequence, - Pattern as REPattern, ClassVar, Set, Mapping -) -###} - -import sys -import token, tokenize -import os -from os import path -from collections import defaultdict -from functools import partial -from argparse import ArgumentParser - -import lark -from lark.tools import lalr_argparser, build_lalr, make_warnings_comments - - -from lark.grammar import Rule -from lark.lexer import TerminalDef - -_dir = path.dirname(__file__) -_larkdir = path.join(_dir, path.pardir) - - -EXTRACT_STANDALONE_FILES = [ - 'tools/standalone.py', - 'exceptions.py', - 'utils.py', - 'tree.py', - 'visitors.py', - 'grammar.py', - 'lexer.py', - 'common.py', - 'parse_tree_builder.py', - 'parsers/lalr_analysis.py', - 'parsers/lalr_parser_state.py', - 'parsers/lalr_parser.py', - 'parsers/lalr_interactive_parser.py', - 'parser_frontends.py', - 'lark.py', - 'indenter.py', -] - -def extract_sections(lines): - section = None - text = [] - sections = defaultdict(list) - for line in lines: - if line.startswith('###'): - if line[3] == '{': - section = line[4:].strip() - elif line[3] == '}': - sections[section] += text - section = None - text = [] - else: - raise ValueError(line) - elif section: - text.append(line) - - return {name: ''.join(text) for name, text in sections.items()} - - -def strip_docstrings(line_gen): - """ Strip comments and docstrings from a file. - Based on code from: https://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings - """ - res = [] - - prev_toktype = token.INDENT - last_lineno = -1 - last_col = 0 - - tokgen = tokenize.generate_tokens(line_gen) - for toktype, ttext, (slineno, scol), (elineno, ecol), ltext in tokgen: - if slineno > last_lineno: - last_col = 0 - if scol > last_col: - res.append(" " * (scol - last_col)) - if toktype == token.STRING and prev_toktype == token.INDENT: - # Docstring - res.append("#--") - elif toktype == tokenize.COMMENT: - # Comment - res.append("##\n") - else: - res.append(ttext) - prev_toktype = toktype - last_col = ecol - last_lineno = elineno - - return ''.join(res) - - -def gen_standalone(lark_inst, output=None, out=sys.stdout, compress=False): - if output is None: - output = partial(print, file=out) - - import pickle, zlib, base64 - def compressed_output(obj): - s = pickle.dumps(obj, pickle.HIGHEST_PROTOCOL) - c = zlib.compress(s) - output(repr(base64.b64encode(c))) - - def output_decompress(name): - output('%(name)s = pickle.loads(zlib.decompress(base64.b64decode(%(name)s)))' % locals()) - - output('# The file was automatically generated by Lark v%s' % lark.__version__) - output('__version__ = "%s"' % lark.__version__) - output() - - for i, pyfile in enumerate(EXTRACT_STANDALONE_FILES): - with open(os.path.join(_larkdir, pyfile)) as f: - code = extract_sections(f)['standalone'] - if i: # if not this file - code = strip_docstrings(partial(next, iter(code.splitlines(True)))) - output(code) - - data, m = lark_inst.memo_serialize([TerminalDef, Rule]) - output('import pickle, zlib, base64') - if compress: - output('DATA = (') - compressed_output(data) - output(')') - output_decompress('DATA') - output('MEMO = (') - compressed_output(m) - output(')') - output_decompress('MEMO') - else: - output('DATA = (') - output(data) - output(')') - output('MEMO = (') - output(m) - output(')') - - - output('Shift = 0') - output('Reduce = 1') - output("def Lark_StandAlone(**kwargs):") - output(" return Lark._load_from_dict(DATA, MEMO, **kwargs)") - - - - -def main(): - make_warnings_comments() - parser = ArgumentParser(prog="prog='python -m lark.tools.standalone'", description="Lark Stand-alone Generator Tool", - parents=[lalr_argparser], epilog='Look at the Lark documentation for more info on the options') - parser.add_argument('-c', '--compress', action='store_true', default=0, help="Enable compression") - if len(sys.argv) == 1: - parser.print_help(sys.stderr) - sys.exit(1) - ns = parser.parse_args() - - lark_inst, out = build_lalr(ns) - gen_standalone(lark_inst, out=out, compress=ns.compress) - - ns.out.close() - ns.grammar_file.close() - - -if __name__ == '__main__': - main() diff --git a/server/libs/lark/tree.py b/server/libs/lark/tree.py deleted file mode 100644 index 76f8738..0000000 --- a/server/libs/lark/tree.py +++ /dev/null @@ -1,267 +0,0 @@ -import sys -from copy import deepcopy - -from typing import List, Callable, Iterator, Union, Optional, Generic, TypeVar, TYPE_CHECKING - -if TYPE_CHECKING: - from .lexer import TerminalDef, Token - try: - import rich - except ImportError: - pass - from typing import Literal - -###{standalone - -class Meta: - - empty: bool - line: int - column: int - start_pos: int - end_line: int - end_column: int - end_pos: int - orig_expansion: 'List[TerminalDef]' - match_tree: bool - - def __init__(self): - self.empty = True - - -_Leaf_T = TypeVar("_Leaf_T") -Branch = Union[_Leaf_T, 'Tree[_Leaf_T]'] - - -class Tree(Generic[_Leaf_T]): - """The main tree class. - - Creates a new tree, and stores "data" and "children" in attributes of the same name. - Trees can be hashed and compared. - - Parameters: - data: The name of the rule or alias - children: List of matched sub-rules and terminals - meta: Line & Column numbers (if ``propagate_positions`` is enabled). - meta attributes: (line, column, end_line, end_column, start_pos, end_pos, - container_line, container_column, container_end_line, container_end_column) - container_* attributes consider all symbols, including those that have been inlined in the tree. - For example, in the rule 'a: _A B _C', the regular attributes will mark the start and end of B, - but the container_* attributes will also include _A and _C in the range. However, rules that - contain 'a' will consider it in full, including _A and _C for all attributes. - """ - - data: str - children: 'List[Branch[_Leaf_T]]' - - def __init__(self, data: str, children: 'List[Branch[_Leaf_T]]', meta: Optional[Meta]=None) -> None: - self.data = data - self.children = children - self._meta = meta - - @property - def meta(self) -> Meta: - if self._meta is None: - self._meta = Meta() - return self._meta - - def __repr__(self): - return 'Tree(%r, %r)' % (self.data, self.children) - - def _pretty_label(self): - return self.data - - def _pretty(self, level, indent_str): - yield f'{indent_str*level}{self._pretty_label()}' - if len(self.children) == 1 and not isinstance(self.children[0], Tree): - yield f'\t{self.children[0]}\n' - else: - yield '\n' - for n in self.children: - if isinstance(n, Tree): - yield from n._pretty(level+1, indent_str) - else: - yield f'{indent_str*(level+1)}{n}\n' - - def pretty(self, indent_str: str=' ') -> str: - """Returns an indented string representation of the tree. - - Great for debugging. - """ - return ''.join(self._pretty(0, indent_str)) - - def __rich__(self, parent:Optional['rich.tree.Tree']=None) -> 'rich.tree.Tree': - """Returns a tree widget for the 'rich' library. - - Example: - :: - from rich import print - from lark import Tree - - tree = Tree('root', ['node1', 'node2']) - print(tree) - """ - return self._rich(parent) - - def _rich(self, parent): - if parent: - tree = parent.add(f'[bold]{self.data}[/bold]') - else: - import rich.tree - tree = rich.tree.Tree(self.data) - - for c in self.children: - if isinstance(c, Tree): - c._rich(tree) - else: - tree.add(f'[green]{c}[/green]') - - return tree - - def __eq__(self, other): - try: - return self.data == other.data and self.children == other.children - except AttributeError: - return False - - def __ne__(self, other): - return not (self == other) - - def __hash__(self) -> int: - return hash((self.data, tuple(self.children))) - - def iter_subtrees(self) -> 'Iterator[Tree[_Leaf_T]]': - """Depth-first iteration. - - Iterates over all the subtrees, never returning to the same node twice (Lark's parse-tree is actually a DAG). - """ - queue = [self] - subtrees = dict() - for subtree in queue: - subtrees[id(subtree)] = subtree - queue += [c for c in reversed(subtree.children) - if isinstance(c, Tree) and id(c) not in subtrees] - - del queue - return reversed(list(subtrees.values())) - - def iter_subtrees_topdown(self): - """Breadth-first iteration. - - Iterates over all the subtrees, return nodes in order like pretty() does. - """ - stack = [self] - stack_append = stack.append - stack_pop = stack.pop - while stack: - node = stack_pop() - if not isinstance(node, Tree): - continue - yield node - for child in reversed(node.children): - stack_append(child) - - def find_pred(self, pred: 'Callable[[Tree[_Leaf_T]], bool]') -> 'Iterator[Tree[_Leaf_T]]': - """Returns all nodes of the tree that evaluate pred(node) as true.""" - return filter(pred, self.iter_subtrees()) - - def find_data(self, data: str) -> 'Iterator[Tree[_Leaf_T]]': - """Returns all nodes of the tree whose data equals the given data.""" - return self.find_pred(lambda t: t.data == data) - -###} - - def expand_kids_by_data(self, *data_values): - """Expand (inline) children with any of the given data values. Returns True if anything changed""" - changed = False - for i in range(len(self.children)-1, -1, -1): - child = self.children[i] - if isinstance(child, Tree) and child.data in data_values: - self.children[i:i+1] = child.children - changed = True - return changed - - - def scan_values(self, pred: 'Callable[[Branch[_Leaf_T]], bool]') -> Iterator[_Leaf_T]: - """Return all values in the tree that evaluate pred(value) as true. - - This can be used to find all the tokens in the tree. - - Example: - >>> all_tokens = tree.scan_values(lambda v: isinstance(v, Token)) - """ - for c in self.children: - if isinstance(c, Tree): - for t in c.scan_values(pred): - yield t - else: - if pred(c): - yield c - - def __deepcopy__(self, memo): - return type(self)(self.data, deepcopy(self.children, memo), meta=self._meta) - - def copy(self) -> 'Tree[_Leaf_T]': - return type(self)(self.data, self.children) - - def set(self, data: str, children: 'List[Branch[_Leaf_T]]') -> None: - self.data = data - self.children = children - - -ParseTree = Tree['Token'] - - -class SlottedTree(Tree): - __slots__ = 'data', 'children', 'rule', '_meta' - - -def pydot__tree_to_png(tree: Tree, filename: str, rankdir: 'Literal["TB", "LR", "BT", "RL"]'="LR", **kwargs) -> None: - graph = pydot__tree_to_graph(tree, rankdir, **kwargs) - graph.write_png(filename) - - -def pydot__tree_to_dot(tree: Tree, filename, rankdir="LR", **kwargs): - graph = pydot__tree_to_graph(tree, rankdir, **kwargs) - graph.write(filename) - - -def pydot__tree_to_graph(tree: Tree, rankdir="LR", **kwargs): - """Creates a colorful image that represents the tree (data+children, without meta) - - Possible values for `rankdir` are "TB", "LR", "BT", "RL", corresponding to - directed graphs drawn from top to bottom, from left to right, from bottom to - top, and from right to left, respectively. - - `kwargs` can be any graph attribute (e. g. `dpi=200`). For a list of - possible attributes, see https://www.graphviz.org/doc/info/attrs.html. - """ - - import pydot # type: ignore[import-not-found] - graph = pydot.Dot(graph_type='digraph', rankdir=rankdir, **kwargs) - - i = [0] - - def new_leaf(leaf): - node = pydot.Node(i[0], label=repr(leaf)) - i[0] += 1 - graph.add_node(node) - return node - - def _to_pydot(subtree): - color = hash(subtree.data) & 0xffffff - color |= 0x808080 - - subnodes = [_to_pydot(child) if isinstance(child, Tree) else new_leaf(child) - for child in subtree.children] - node = pydot.Node(i[0], style="filled", fillcolor="#%x" % color, label=subtree.data) - i[0] += 1 - graph.add_node(node) - - for subnode in subnodes: - graph.add_edge(pydot.Edge(node, subnode)) - - return node - - _to_pydot(tree) - return graph diff --git a/server/libs/lark/tree_matcher.py b/server/libs/lark/tree_matcher.py deleted file mode 100644 index 0f42652..0000000 --- a/server/libs/lark/tree_matcher.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tree matcher based on Lark grammar""" - -import re -from collections import defaultdict - -from . import Tree, Token -from .common import ParserConf -from .parsers import earley -from .grammar import Rule, Terminal, NonTerminal - - -def is_discarded_terminal(t): - return t.is_term and t.filter_out - - -class _MakeTreeMatch: - def __init__(self, name, expansion): - self.name = name - self.expansion = expansion - - def __call__(self, args): - t = Tree(self.name, args) - t.meta.match_tree = True - t.meta.orig_expansion = self.expansion - return t - - -def _best_from_group(seq, group_key, cmp_key): - d = {} - for item in seq: - key = group_key(item) - if key in d: - v1 = cmp_key(item) - v2 = cmp_key(d[key]) - if v2 > v1: - d[key] = item - else: - d[key] = item - return list(d.values()) - - -def _best_rules_from_group(rules): - rules = _best_from_group(rules, lambda r: r, lambda r: -len(r.expansion)) - rules.sort(key=lambda r: len(r.expansion)) - return rules - - -def _match(term, token): - if isinstance(token, Tree): - name, _args = parse_rulename(term.name) - return token.data == name - elif isinstance(token, Token): - return term == Terminal(token.type) - assert False, (term, token) - - -def make_recons_rule(origin, expansion, old_expansion): - return Rule(origin, expansion, alias=_MakeTreeMatch(origin.name, old_expansion)) - - -def make_recons_rule_to_term(origin, term): - return make_recons_rule(origin, [Terminal(term.name)], [term]) - - -def parse_rulename(s): - "Parse rule names that may contain a template syntax (like rule{a, b, ...})" - name, args_str = re.match(r'(\w+)(?:{(.+)})?', s).groups() - args = args_str and [a.strip() for a in args_str.split(',')] - return name, args - - - -class ChildrenLexer: - def __init__(self, children): - self.children = children - - def lex(self, parser_state): - return self.children - -class TreeMatcher: - """Match the elements of a tree node, based on an ontology - provided by a Lark grammar. - - Supports templates and inlined rules (`rule{a, b,..}` and `_rule`) - - Initialize with an instance of Lark. - """ - - def __init__(self, parser): - # XXX TODO calling compile twice returns different results! - assert not parser.options.maybe_placeholders - # XXX TODO: we just ignore the potential existence of a postlexer - self.tokens, rules, _extra = parser.grammar.compile(parser.options.start, set()) - - self.rules_for_root = defaultdict(list) - - self.rules = list(self._build_recons_rules(rules)) - self.rules.reverse() - - # Choose the best rule from each group of {rule => [rule.alias]}, since we only really need one derivation. - self.rules = _best_rules_from_group(self.rules) - - self.parser = parser - self._parser_cache = {} - - def _build_recons_rules(self, rules): - "Convert tree-parsing/construction rules to tree-matching rules" - expand1s = {r.origin for r in rules if r.options.expand1} - - aliases = defaultdict(list) - for r in rules: - if r.alias: - aliases[r.origin].append(r.alias) - - rule_names = {r.origin for r in rules} - nonterminals = {sym for sym in rule_names - if sym.name.startswith('_') or sym in expand1s or sym in aliases} - - seen = set() - for r in rules: - recons_exp = [sym if sym in nonterminals else Terminal(sym.name) - for sym in r.expansion if not is_discarded_terminal(sym)] - - # Skip self-recursive constructs - if recons_exp == [r.origin] and r.alias is None: - continue - - sym = NonTerminal(r.alias) if r.alias else r.origin - rule = make_recons_rule(sym, recons_exp, r.expansion) - - if sym in expand1s and len(recons_exp) != 1: - self.rules_for_root[sym.name].append(rule) - - if sym.name not in seen: - yield make_recons_rule_to_term(sym, sym) - seen.add(sym.name) - else: - if sym.name.startswith('_') or sym in expand1s: - yield rule - else: - self.rules_for_root[sym.name].append(rule) - - for origin, rule_aliases in aliases.items(): - for alias in rule_aliases: - yield make_recons_rule_to_term(origin, NonTerminal(alias)) - yield make_recons_rule_to_term(origin, origin) - - def match_tree(self, tree, rulename): - """Match the elements of `tree` to the symbols of rule `rulename`. - - Parameters: - tree (Tree): the tree node to match - rulename (str): The expected full rule name (including template args) - - Returns: - Tree: an unreduced tree that matches `rulename` - - Raises: - UnexpectedToken: If no match was found. - - Note: - It's the callers' responsibility match the tree recursively. - """ - if rulename: - # validate - name, _args = parse_rulename(rulename) - assert tree.data == name - else: - rulename = tree.data - - # TODO: ambiguity? - try: - parser = self._parser_cache[rulename] - except KeyError: - rules = self.rules + _best_rules_from_group(self.rules_for_root[rulename]) - - # TODO pass callbacks through dict, instead of alias? - callbacks = {rule: rule.alias for rule in rules} - conf = ParserConf(rules, callbacks, [rulename]) - parser = earley.Parser(self.parser.lexer_conf, conf, _match, resolve_ambiguity=True) - self._parser_cache[rulename] = parser - - # find a full derivation - unreduced_tree = parser.parse(ChildrenLexer(tree.children), rulename) - assert unreduced_tree.data == rulename - return unreduced_tree diff --git a/server/libs/lark/tree_templates.py b/server/libs/lark/tree_templates.py deleted file mode 100644 index 6ec7323..0000000 --- a/server/libs/lark/tree_templates.py +++ /dev/null @@ -1,180 +0,0 @@ -"""This module defines utilities for matching and translation tree templates. - -A tree templates is a tree that contains nodes that are template variables. - -""" - -from typing import Union, Optional, Mapping, Dict, Tuple, Iterator - -from lark import Tree, Transformer -from lark.exceptions import MissingVariableError - -Branch = Union[Tree[str], str] -TreeOrCode = Union[Tree[str], str] -MatchResult = Dict[str, Tree] -_TEMPLATE_MARKER = '$' - - -class TemplateConf: - """Template Configuration - - Allows customization for different uses of Template - - parse() must return a Tree instance. - """ - - def __init__(self, parse=None): - self._parse = parse - - def test_var(self, var: Union[Tree[str], str]) -> Optional[str]: - """Given a tree node, if it is a template variable return its name. Otherwise, return None. - - This method may be overridden for customization - - Parameters: - var: Tree | str - The tree node to test - - """ - if isinstance(var, str): - return _get_template_name(var) - - if ( - isinstance(var, Tree) - and var.data == "var" - and len(var.children) > 0 - and isinstance(var.children[0], str) - ): - return _get_template_name(var.children[0]) - - return None - - def _get_tree(self, template: TreeOrCode) -> Tree[str]: - if isinstance(template, str): - assert self._parse - template = self._parse(template) - - if not isinstance(template, Tree): - raise TypeError("template parser must return a Tree instance") - - return template - - def __call__(self, template: Tree[str]) -> 'Template': - return Template(template, conf=self) - - def _match_tree_template(self, template: TreeOrCode, tree: Branch) -> Optional[MatchResult]: - """Returns dict of {var: match} if found a match, else None - """ - template_var = self.test_var(template) - if template_var: - if not isinstance(tree, Tree): - raise TypeError(f"Template variables can only match Tree instances. Not {tree!r}") - return {template_var: tree} - - if isinstance(template, str): - if template == tree: - return {} - return None - - assert isinstance(template, Tree) and isinstance(tree, Tree), f"template={template} tree={tree}" - - if template.data == tree.data and len(template.children) == len(tree.children): - res = {} - for t1, t2 in zip(template.children, tree.children): - matches = self._match_tree_template(t1, t2) - if matches is None: - return None - - res.update(matches) - - return res - - return None - - -class _ReplaceVars(Transformer[str, Tree[str]]): - def __init__(self, conf: TemplateConf, vars: Mapping[str, Tree[str]]) -> None: - super().__init__() - self._conf = conf - self._vars = vars - - def __default__(self, data, children, meta) -> Tree[str]: - tree = super().__default__(data, children, meta) - - var = self._conf.test_var(tree) - if var: - try: - return self._vars[var] - except KeyError: - raise MissingVariableError(f"No mapping for template variable ({var})") - return tree - - -class Template: - """Represents a tree template, tied to a specific configuration - - A tree template is a tree that contains nodes that are template variables. - Those variables will match any tree. - (future versions may support annotations on the variables, to allow more complex templates) - """ - - def __init__(self, tree: Tree[str], conf: TemplateConf = TemplateConf()): - self.conf = conf - self.tree = conf._get_tree(tree) - - def match(self, tree: TreeOrCode) -> Optional[MatchResult]: - """Match a tree template to a tree. - - A tree template without variables will only match ``tree`` if it is equal to the template. - - Parameters: - tree (Tree): The tree to match to the template - - Returns: - Optional[Dict[str, Tree]]: If match is found, returns a dictionary mapping - template variable names to their matching tree nodes. - If no match was found, returns None. - """ - tree = self.conf._get_tree(tree) - return self.conf._match_tree_template(self.tree, tree) - - def search(self, tree: TreeOrCode) -> Iterator[Tuple[Tree[str], MatchResult]]: - """Search for all occurrences of the tree template inside ``tree``. - """ - tree = self.conf._get_tree(tree) - for subtree in tree.iter_subtrees(): - res = self.match(subtree) - if res: - yield subtree, res - - def apply_vars(self, vars: Mapping[str, Tree[str]]) -> Tree[str]: - """Apply vars to the template tree - """ - return _ReplaceVars(self.conf, vars).transform(self.tree) - - -def translate(t1: Template, t2: Template, tree: TreeOrCode): - """Search tree and translate each occurrence of t1 into t2. - """ - tree = t1.conf._get_tree(tree) # ensure it's a tree, parse if necessary and possible - for subtree, vars in t1.search(tree): - res = t2.apply_vars(vars) - subtree.set(res.data, res.children) - return tree - - -class TemplateTranslator: - """Utility class for translating a collection of patterns - """ - - def __init__(self, translations: Mapping[Template, Template]): - assert all(isinstance(k, Template) and isinstance(v, Template) for k, v in translations.items()) - self.translations = translations - - def translate(self, tree: Tree[str]): - for k, v in self.translations.items(): - tree = translate(k, v, tree) - return tree - - -def _get_template_name(value: str) -> Optional[str]: - return value.lstrip(_TEMPLATE_MARKER) if value.startswith(_TEMPLATE_MARKER) else None diff --git a/server/libs/lark/utils.py b/server/libs/lark/utils.py deleted file mode 100644 index 3767a66..0000000 --- a/server/libs/lark/utils.py +++ /dev/null @@ -1,346 +0,0 @@ -import unicodedata -import os -from itertools import product -from collections import deque -from typing import Callable, Iterator, List, Optional, Tuple, Type, TypeVar, Union, Dict, Any, Sequence, Iterable, AbstractSet - -###{standalone -import sys, re -import logging - -logger: logging.Logger = logging.getLogger("lark") -logger.addHandler(logging.StreamHandler()) -# Set to highest level, since we have some warnings amongst the code -# By default, we should not output any log messages -logger.setLevel(logging.CRITICAL) - - -NO_VALUE = object() - -T = TypeVar("T") - - -def classify(seq: Iterable, key: Optional[Callable] = None, value: Optional[Callable] = None) -> Dict: - d: Dict[Any, Any] = {} - for item in seq: - k = key(item) if (key is not None) else item - v = value(item) if (value is not None) else item - try: - d[k].append(v) - except KeyError: - d[k] = [v] - return d - - -def _deserialize(data: Any, namespace: Dict[str, Any], memo: Dict) -> Any: - if isinstance(data, dict): - if '__type__' in data: # Object - class_ = namespace[data['__type__']] - return class_.deserialize(data, memo) - elif '@' in data: - return memo[data['@']] - return {key:_deserialize(value, namespace, memo) for key, value in data.items()} - elif isinstance(data, list): - return [_deserialize(value, namespace, memo) for value in data] - return data - - -_T = TypeVar("_T", bound="Serialize") - -class Serialize: - """Safe-ish serialization interface that doesn't rely on Pickle - - Attributes: - __serialize_fields__ (List[str]): Fields (aka attributes) to serialize. - __serialize_namespace__ (list): List of classes that deserialization is allowed to instantiate. - Should include all field types that aren't builtin types. - """ - - def memo_serialize(self, types_to_memoize: List) -> Any: - memo = SerializeMemoizer(types_to_memoize) - return self.serialize(memo), memo.serialize() - - def serialize(self, memo = None) -> Dict[str, Any]: - if memo and memo.in_types(self): - return {'@': memo.memoized.get(self)} - - fields = getattr(self, '__serialize_fields__') - res = {f: _serialize(getattr(self, f), memo) for f in fields} - res['__type__'] = type(self).__name__ - if hasattr(self, '_serialize'): - self._serialize(res, memo) - return res - - @classmethod - def deserialize(cls: Type[_T], data: Dict[str, Any], memo: Dict[int, Any]) -> _T: - namespace = getattr(cls, '__serialize_namespace__', []) - namespace = {c.__name__:c for c in namespace} - - fields = getattr(cls, '__serialize_fields__') - - if '@' in data: - return memo[data['@']] - - inst = cls.__new__(cls) - for f in fields: - try: - setattr(inst, f, _deserialize(data[f], namespace, memo)) - except KeyError as e: - raise KeyError("Cannot find key for class", cls, e) - - if hasattr(inst, '_deserialize'): - inst._deserialize() - - return inst - - -class SerializeMemoizer(Serialize): - "A version of serialize that memoizes objects to reduce space" - - __serialize_fields__ = 'memoized', - - def __init__(self, types_to_memoize: List) -> None: - self.types_to_memoize = tuple(types_to_memoize) - self.memoized = Enumerator() - - def in_types(self, value: Serialize) -> bool: - return isinstance(value, self.types_to_memoize) - - def serialize(self) -> Dict[int, Any]: # type: ignore[override] - return _serialize(self.memoized.reversed(), None) - - @classmethod - def deserialize(cls, data: Dict[int, Any], namespace: Dict[str, Any], memo: Dict[Any, Any]) -> Dict[int, Any]: # type: ignore[override] - return _deserialize(data, namespace, memo) - - -try: - import regex - _has_regex = True -except ImportError: - _has_regex = False - -if sys.version_info >= (3, 11): - import re._parser as sre_parse - import re._constants as sre_constants -else: - import sre_parse - import sre_constants - -categ_pattern = re.compile(r'\\p{[A-Za-z_]+}') - -def get_regexp_width(expr: str) -> Union[Tuple[int, int], List[int]]: - if _has_regex: - # Since `sre_parse` cannot deal with Unicode categories of the form `\p{Mn}`, we replace these with - # a simple letter, which makes no difference as we are only trying to get the possible lengths of the regex - # match here below. - regexp_final = re.sub(categ_pattern, 'A', expr) - else: - if re.search(categ_pattern, expr): - raise ImportError('`regex` module must be installed in order to use Unicode categories.', expr) - regexp_final = expr - try: - # Fixed in next version (past 0.960) of typeshed - return [int(x) for x in sre_parse.parse(regexp_final).getwidth()] - except sre_constants.error: - if not _has_regex: - raise ValueError(expr) - else: - # sre_parse does not support the new features in regex. To not completely fail in that case, - # we manually test for the most important info (whether the empty string is matched) - c = regex.compile(regexp_final) - # Python 3.11.7 introducded sre_parse.MAXWIDTH that is used instead of MAXREPEAT - # See lark-parser/lark#1376 and python/cpython#109859 - MAXWIDTH = getattr(sre_parse, "MAXWIDTH", sre_constants.MAXREPEAT) - if c.match('') is None: - # MAXREPEAT is a none pickable subclass of int, therefore needs to be converted to enable caching - return 1, int(MAXWIDTH) - else: - return 0, int(MAXWIDTH) - -###} - - -_ID_START = 'Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Mn', 'Mc', 'Pc' -_ID_CONTINUE = _ID_START + ('Nd', 'Nl',) - -def _test_unicode_category(s: str, categories: Sequence[str]) -> bool: - if len(s) != 1: - return all(_test_unicode_category(char, categories) for char in s) - return s == '_' or unicodedata.category(s) in categories - -def is_id_continue(s: str) -> bool: - """ - Checks if all characters in `s` are alphanumeric characters (Unicode standard, so diacritics, indian vowels, non-latin - numbers, etc. all pass). Synonymous with a Python `ID_CONTINUE` identifier. See PEP 3131 for details. - """ - return _test_unicode_category(s, _ID_CONTINUE) - -def is_id_start(s: str) -> bool: - """ - Checks if all characters in `s` are alphabetic characters (Unicode standard, so diacritics, indian vowels, non-latin - numbers, etc. all pass). Synonymous with a Python `ID_START` identifier. See PEP 3131 for details. - """ - return _test_unicode_category(s, _ID_START) - - -def dedup_list(l: Sequence[T]) -> List[T]: - """Given a list (l) will removing duplicates from the list, - preserving the original order of the list. Assumes that - the list entries are hashable.""" - return list(dict.fromkeys(l)) - - -class Enumerator(Serialize): - def __init__(self) -> None: - self.enums: Dict[Any, int] = {} - - def get(self, item) -> int: - if item not in self.enums: - self.enums[item] = len(self.enums) - return self.enums[item] - - def __len__(self): - return len(self.enums) - - def reversed(self) -> Dict[int, Any]: - r = {v: k for k, v in self.enums.items()} - assert len(r) == len(self.enums) - return r - - - -def combine_alternatives(lists): - """ - Accepts a list of alternatives, and enumerates all their possible concatenations. - - Examples: - >>> combine_alternatives([range(2), [4,5]]) - [[0, 4], [0, 5], [1, 4], [1, 5]] - - >>> combine_alternatives(["abc", "xy", '$']) - [['a', 'x', '$'], ['a', 'y', '$'], ['b', 'x', '$'], ['b', 'y', '$'], ['c', 'x', '$'], ['c', 'y', '$']] - - >>> combine_alternatives([]) - [[]] - """ - if not lists: - return [[]] - assert all(l for l in lists), lists - return list(product(*lists)) - -try: - import atomicwrites - _has_atomicwrites = True -except ImportError: - _has_atomicwrites = False - -class FS: - exists = staticmethod(os.path.exists) - - @staticmethod - def open(name, mode="r", **kwargs): - if _has_atomicwrites and "w" in mode: - return atomicwrites.atomic_write(name, mode=mode, overwrite=True, **kwargs) - else: - return open(name, mode, **kwargs) - - -class fzset(frozenset): - def __repr__(self): - return '{%s}' % ', '.join(map(repr, self)) - - -def classify_bool(seq: Iterable, pred: Callable) -> Any: - false_elems = [] - true_elems = [elem for elem in seq if pred(elem) or false_elems.append(elem)] # type: ignore[func-returns-value] - return true_elems, false_elems - - -def bfs(initial: Iterable, expand: Callable) -> Iterator: - open_q = deque(list(initial)) - visited = set(open_q) - while open_q: - node = open_q.popleft() - yield node - for next_node in expand(node): - if next_node not in visited: - visited.add(next_node) - open_q.append(next_node) - -def bfs_all_unique(initial, expand): - "bfs, but doesn't keep track of visited (aka seen), because there can be no repetitions" - open_q = deque(list(initial)) - while open_q: - node = open_q.popleft() - yield node - open_q += expand(node) - - -def _serialize(value: Any, memo: Optional[SerializeMemoizer]) -> Any: - if isinstance(value, Serialize): - return value.serialize(memo) - elif isinstance(value, list): - return [_serialize(elem, memo) for elem in value] - elif isinstance(value, frozenset): - return list(value) # TODO reversible? - elif isinstance(value, dict): - return {key:_serialize(elem, memo) for key, elem in value.items()} - # assert value is None or isinstance(value, (int, float, str, tuple)), value - return value - - - - -def small_factors(n: int, max_factor: int) -> List[Tuple[int, int]]: - """ - Splits n up into smaller factors and summands <= max_factor. - Returns a list of [(a, b), ...] - so that the following code returns n: - - n = 1 - for a, b in values: - n = n * a + b - - Currently, we also keep a + b <= max_factor, but that might change - """ - assert n >= 0 - assert max_factor > 2 - if n <= max_factor: - return [(n, 0)] - - for a in range(max_factor, 1, -1): - r, b = divmod(n, a) - if a + b <= max_factor: - return small_factors(r, max_factor) + [(a, b)] - assert False, "Failed to factorize %s" % n - - -class OrderedSet(AbstractSet[T]): - """A minimal OrderedSet implementation, using a dictionary. - - (relies on the dictionary being ordered) - """ - def __init__(self, items: Iterable[T] =()): - self.d = dict.fromkeys(items) - - def __contains__(self, item: Any) -> bool: - return item in self.d - - def add(self, item: T): - self.d[item] = None - - def __iter__(self) -> Iterator[T]: - return iter(self.d) - - def remove(self, item: T): - del self.d[item] - - def __bool__(self): - return bool(self.d) - - def __len__(self) -> int: - return len(self.d) - - def __repr__(self): - return f"{type(self).__name__}({', '.join(map(repr,self))})" diff --git a/server/libs/lark/visitors.py b/server/libs/lark/visitors.py deleted file mode 100644 index 18455d9..0000000 --- a/server/libs/lark/visitors.py +++ /dev/null @@ -1,596 +0,0 @@ -from typing import TypeVar, Tuple, List, Callable, Generic, Type, Union, Optional, Any, cast -from abc import ABC - -from .utils import combine_alternatives -from .tree import Tree, Branch -from .exceptions import VisitError, GrammarError -from .lexer import Token - -###{standalone -from functools import wraps, update_wrapper -from inspect import getmembers, getmro - -_Return_T = TypeVar('_Return_T') -_Return_V = TypeVar('_Return_V') -_Leaf_T = TypeVar('_Leaf_T') -_Leaf_U = TypeVar('_Leaf_U') -_R = TypeVar('_R') -_FUNC = Callable[..., _Return_T] -_DECORATED = Union[_FUNC, type] - -class _DiscardType: - """When the Discard value is returned from a transformer callback, - that node is discarded and won't appear in the parent. - - Note: - This feature is disabled when the transformer is provided to Lark - using the ``transformer`` keyword (aka Tree-less LALR mode). - - Example: - :: - - class T(Transformer): - def ignore_tree(self, children): - return Discard - - def IGNORE_TOKEN(self, token): - return Discard - """ - - def __repr__(self): - return "lark.visitors.Discard" - -Discard = _DiscardType() - -# Transformers - -class _Decoratable: - "Provides support for decorating methods with @v_args" - - @classmethod - def _apply_v_args(cls, visit_wrapper): - mro = getmro(cls) - assert mro[0] is cls - libmembers = {name for _cls in mro[1:] for name, _ in getmembers(_cls)} - for name, value in getmembers(cls): - - # Make sure the function isn't inherited (unless it's overwritten) - if name.startswith('_') or (name in libmembers and name not in cls.__dict__): - continue - if not callable(value): - continue - - # Skip if v_args already applied (at the function level) - if isinstance(cls.__dict__[name], _VArgsWrapper): - continue - - setattr(cls, name, _VArgsWrapper(cls.__dict__[name], visit_wrapper)) - return cls - - def __class_getitem__(cls, _): - return cls - - -class Transformer(_Decoratable, ABC, Generic[_Leaf_T, _Return_T]): - """Transformers work bottom-up (or depth-first), starting with visiting the leaves and working - their way up until ending at the root of the tree. - - For each node visited, the transformer will call the appropriate method (callbacks), according to the - node's ``data``, and use the returned value to replace the node, thereby creating a new tree structure. - - Transformers can be used to implement map & reduce patterns. Because nodes are reduced from leaf to root, - at any point the callbacks may assume the children have already been transformed (if applicable). - - If the transformer cannot find a method with the right name, it will instead call ``__default__``, which by - default creates a copy of the node. - - To discard a node, return Discard (``lark.visitors.Discard``). - - ``Transformer`` can do anything ``Visitor`` can do, but because it reconstructs the tree, - it is slightly less efficient. - - A transformer without methods essentially performs a non-memoized partial deepcopy. - - All these classes implement the transformer interface: - - - ``Transformer`` - Recursively transforms the tree. This is the one you probably want. - - ``Transformer_InPlace`` - Non-recursive. Changes the tree in-place instead of returning new instances - - ``Transformer_InPlaceRecursive`` - Recursive. Changes the tree in-place instead of returning new instances - - Parameters: - visit_tokens (bool, optional): Should the transformer visit tokens in addition to rules. - Setting this to ``False`` is slightly faster. Defaults to ``True``. - (For processing ignored tokens, use the ``lexer_callbacks`` options) - - """ - __visit_tokens__ = True # For backwards compatibility - - def __init__(self, visit_tokens: bool=True) -> None: - self.__visit_tokens__ = visit_tokens - - def _call_userfunc(self, tree, new_children=None): - # Assumes tree is already transformed - children = new_children if new_children is not None else tree.children - try: - f = getattr(self, tree.data) - except AttributeError: - return self.__default__(tree.data, children, tree.meta) - else: - try: - wrapper = getattr(f, 'visit_wrapper', None) - if wrapper is not None: - return f.visit_wrapper(f, tree.data, children, tree.meta) - else: - return f(children) - except GrammarError: - raise - except Exception as e: - raise VisitError(tree.data, tree, e) - - def _call_userfunc_token(self, token): - try: - f = getattr(self, token.type) - except AttributeError: - return self.__default_token__(token) - else: - try: - return f(token) - except GrammarError: - raise - except Exception as e: - raise VisitError(token.type, token, e) - - def _transform_children(self, children): - for c in children: - if isinstance(c, Tree): - res = self._transform_tree(c) - elif self.__visit_tokens__ and isinstance(c, Token): - res = self._call_userfunc_token(c) - else: - res = c - - if res is not Discard: - yield res - - def _transform_tree(self, tree): - children = list(self._transform_children(tree.children)) - return self._call_userfunc(tree, children) - - def transform(self, tree: Tree[_Leaf_T]) -> _Return_T: - "Transform the given tree, and return the final result" - res = list(self._transform_children([tree])) - if not res: - return None # type: ignore[return-value] - assert len(res) == 1 - return res[0] - - def __mul__( - self: 'Transformer[_Leaf_T, Tree[_Leaf_U]]', - other: 'Union[Transformer[_Leaf_U, _Return_V], TransformerChain[_Leaf_U, _Return_V,]]' - ) -> 'TransformerChain[_Leaf_T, _Return_V]': - """Chain two transformers together, returning a new transformer. - """ - return TransformerChain(self, other) - - def __default__(self, data, children, meta): - """Default function that is called if there is no attribute matching ``data`` - - Can be overridden. Defaults to creating a new copy of the tree node (i.e. ``return Tree(data, children, meta)``) - """ - return Tree(data, children, meta) - - def __default_token__(self, token): - """Default function that is called if there is no attribute matching ``token.type`` - - Can be overridden. Defaults to returning the token as-is. - """ - return token - - -def merge_transformers(base_transformer=None, **transformers_to_merge): - """Merge a collection of transformers into the base_transformer, each into its own 'namespace'. - - When called, it will collect the methods from each transformer, and assign them to base_transformer, - with their name prefixed with the given keyword, as ``prefix__methodname``. - - This function is especially useful for processing grammars that import other grammars, - thereby creating some of their rules in a 'namespace'. (i.e with a consistent name prefix). - In this case, the key for the transformer should match the name of the imported grammar. - - Parameters: - base_transformer (Transformer, optional): The transformer that all other transformers will be added to. - **transformers_to_merge: Keyword arguments, in the form of ``name_prefix = transformer``. - - Raises: - AttributeError: In case of a name collision in the merged methods - - Example: - :: - - class TBase(Transformer): - def start(self, children): - return children[0] + 'bar' - - class TImportedGrammar(Transformer): - def foo(self, children): - return "foo" - - composed_transformer = merge_transformers(TBase(), imported=TImportedGrammar()) - - t = Tree('start', [ Tree('imported__foo', []) ]) - - assert composed_transformer.transform(t) == 'foobar' - - """ - if base_transformer is None: - base_transformer = Transformer() - for prefix, transformer in transformers_to_merge.items(): - for method_name in dir(transformer): - method = getattr(transformer, method_name) - if not callable(method): - continue - if method_name.startswith("_") or method_name == "transform": - continue - prefixed_method = prefix + "__" + method_name - if hasattr(base_transformer, prefixed_method): - raise AttributeError("Cannot merge: method '%s' appears more than once" % prefixed_method) - - setattr(base_transformer, prefixed_method, method) - - return base_transformer - - -class InlineTransformer(Transformer): # XXX Deprecated - def _call_userfunc(self, tree, new_children=None): - # Assumes tree is already transformed - children = new_children if new_children is not None else tree.children - try: - f = getattr(self, tree.data) - except AttributeError: - return self.__default__(tree.data, children, tree.meta) - else: - return f(*children) - - -class TransformerChain(Generic[_Leaf_T, _Return_T]): - - transformers: 'Tuple[Union[Transformer, TransformerChain], ...]' - - def __init__(self, *transformers: 'Union[Transformer, TransformerChain]') -> None: - self.transformers = transformers - - def transform(self, tree: Tree[_Leaf_T]) -> _Return_T: - for t in self.transformers: - tree = t.transform(tree) - return cast(_Return_T, tree) - - def __mul__( - self: 'TransformerChain[_Leaf_T, Tree[_Leaf_U]]', - other: 'Union[Transformer[_Leaf_U, _Return_V], TransformerChain[_Leaf_U, _Return_V]]' - ) -> 'TransformerChain[_Leaf_T, _Return_V]': - return TransformerChain(*self.transformers + (other,)) - - -class Transformer_InPlace(Transformer[_Leaf_T, _Return_T]): - """Same as Transformer, but non-recursive, and changes the tree in-place instead of returning new instances - - Useful for huge trees. Conservative in memory. - """ - def _transform_tree(self, tree): # Cancel recursion - return self._call_userfunc(tree) - - def transform(self, tree: Tree[_Leaf_T]) -> _Return_T: - for subtree in tree.iter_subtrees(): - subtree.children = list(self._transform_children(subtree.children)) - - return self._transform_tree(tree) - - -class Transformer_NonRecursive(Transformer[_Leaf_T, _Return_T]): - """Same as Transformer but non-recursive. - - Like Transformer, it doesn't change the original tree. - - Useful for huge trees. - """ - - def transform(self, tree: Tree[_Leaf_T]) -> _Return_T: - # Tree to postfix - rev_postfix = [] - q: List[Branch[_Leaf_T]] = [tree] - while q: - t = q.pop() - rev_postfix.append(t) - if isinstance(t, Tree): - q += t.children - - # Postfix to tree - stack: List = [] - for x in reversed(rev_postfix): - if isinstance(x, Tree): - size = len(x.children) - if size: - args = stack[-size:] - del stack[-size:] - else: - args = [] - - res = self._call_userfunc(x, args) - if res is not Discard: - stack.append(res) - - elif self.__visit_tokens__ and isinstance(x, Token): - res = self._call_userfunc_token(x) - if res is not Discard: - stack.append(res) - else: - stack.append(x) - - result, = stack # We should have only one tree remaining - # There are no guarantees on the type of the value produced by calling a user func for a - # child will produce. This means type system can't statically know that the final result is - # _Return_T. As a result a cast is required. - return cast(_Return_T, result) - - -class Transformer_InPlaceRecursive(Transformer): - "Same as Transformer, recursive, but changes the tree in-place instead of returning new instances" - def _transform_tree(self, tree): - tree.children = list(self._transform_children(tree.children)) - return self._call_userfunc(tree) - - -# Visitors - -class VisitorBase: - def _call_userfunc(self, tree): - return getattr(self, tree.data, self.__default__)(tree) - - def __default__(self, tree): - """Default function that is called if there is no attribute matching ``tree.data`` - - Can be overridden. Defaults to doing nothing. - """ - return tree - - def __class_getitem__(cls, _): - return cls - - -class Visitor(VisitorBase, ABC, Generic[_Leaf_T]): - """Tree visitor, non-recursive (can handle huge trees). - - Visiting a node calls its methods (provided by the user via inheritance) according to ``tree.data`` - """ - - def visit(self, tree: Tree[_Leaf_T]) -> Tree[_Leaf_T]: - "Visits the tree, starting with the leaves and finally the root (bottom-up)" - for subtree in tree.iter_subtrees(): - self._call_userfunc(subtree) - return tree - - def visit_topdown(self, tree: Tree[_Leaf_T]) -> Tree[_Leaf_T]: - "Visit the tree, starting at the root, and ending at the leaves (top-down)" - for subtree in tree.iter_subtrees_topdown(): - self._call_userfunc(subtree) - return tree - - -class Visitor_Recursive(VisitorBase, Generic[_Leaf_T]): - """Bottom-up visitor, recursive. - - Visiting a node calls its methods (provided by the user via inheritance) according to ``tree.data`` - - Slightly faster than the non-recursive version. - """ - - def visit(self, tree: Tree[_Leaf_T]) -> Tree[_Leaf_T]: - "Visits the tree, starting with the leaves and finally the root (bottom-up)" - for child in tree.children: - if isinstance(child, Tree): - self.visit(child) - - self._call_userfunc(tree) - return tree - - def visit_topdown(self,tree: Tree[_Leaf_T]) -> Tree[_Leaf_T]: - "Visit the tree, starting at the root, and ending at the leaves (top-down)" - self._call_userfunc(tree) - - for child in tree.children: - if isinstance(child, Tree): - self.visit_topdown(child) - - return tree - - -class Interpreter(_Decoratable, ABC, Generic[_Leaf_T, _Return_T]): - """Interpreter walks the tree starting at the root. - - Visits the tree, starting with the root and finally the leaves (top-down) - - For each tree node, it calls its methods (provided by user via inheritance) according to ``tree.data``. - - Unlike ``Transformer`` and ``Visitor``, the Interpreter doesn't automatically visit its sub-branches. - The user has to explicitly call ``visit``, ``visit_children``, or use the ``@visit_children_decor``. - This allows the user to implement branching and loops. - """ - - def visit(self, tree: Tree[_Leaf_T]) -> _Return_T: - # There are no guarantees on the type of the value produced by calling a user func for a - # child will produce. So only annotate the public method and use an internal method when - # visiting child trees. - return self._visit_tree(tree) - - def _visit_tree(self, tree: Tree[_Leaf_T]): - f = getattr(self, tree.data) - wrapper = getattr(f, 'visit_wrapper', None) - if wrapper is not None: - return f.visit_wrapper(f, tree.data, tree.children, tree.meta) - else: - return f(tree) - - def visit_children(self, tree: Tree[_Leaf_T]) -> List: - return [self._visit_tree(child) if isinstance(child, Tree) else child - for child in tree.children] - - def __getattr__(self, name): - return self.__default__ - - def __default__(self, tree): - return self.visit_children(tree) - - -_InterMethod = Callable[[Type[Interpreter], _Return_T], _R] - -def visit_children_decor(func: _InterMethod) -> _InterMethod: - "See Interpreter" - @wraps(func) - def inner(cls, tree): - values = cls.visit_children(tree) - return func(cls, values) - return inner - -# Decorators - -def _apply_v_args(obj, visit_wrapper): - try: - _apply = obj._apply_v_args - except AttributeError: - return _VArgsWrapper(obj, visit_wrapper) - else: - return _apply(visit_wrapper) - - -class _VArgsWrapper: - """ - A wrapper around a Callable. It delegates `__call__` to the Callable. - If the Callable has a `__get__`, that is also delegate and the resulting function is wrapped. - Otherwise, we use the original function mirroring the behaviour without a __get__. - We also have the visit_wrapper attribute to be used by Transformers. - """ - base_func: Callable - - def __init__(self, func: Callable, visit_wrapper: Callable[[Callable, str, list, Any], Any]): - if isinstance(func, _VArgsWrapper): - func = func.base_func - self.base_func = func - self.visit_wrapper = visit_wrapper - update_wrapper(self, func) - - def __call__(self, *args, **kwargs): - return self.base_func(*args, **kwargs) - - def __get__(self, instance, owner=None): - try: - # Use the __get__ attribute of the type instead of the instance - # to fully mirror the behavior of getattr - g = type(self.base_func).__get__ - except AttributeError: - return self - else: - return _VArgsWrapper(g(self.base_func, instance, owner), self.visit_wrapper) - - def __set_name__(self, owner, name): - try: - f = type(self.base_func).__set_name__ - except AttributeError: - return - else: - f(self.base_func, owner, name) - - -def _vargs_inline(f, _data, children, _meta): - return f(*children) -def _vargs_meta_inline(f, _data, children, meta): - return f(meta, *children) -def _vargs_meta(f, _data, children, meta): - return f(meta, children) -def _vargs_tree(f, data, children, meta): - return f(Tree(data, children, meta)) - - -def v_args(inline: bool = False, meta: bool = False, tree: bool = False, wrapper: Optional[Callable] = None) -> Callable[[_DECORATED], _DECORATED]: - """A convenience decorator factory for modifying the behavior of user-supplied visitor methods. - - By default, callback methods of transformers/visitors accept one argument - a list of the node's children. - - ``v_args`` can modify this behavior. When used on a transformer/visitor class definition, - it applies to all the callback methods inside it. - - ``v_args`` can be applied to a single method, or to an entire class. When applied to both, - the options given to the method take precedence. - - Parameters: - inline (bool, optional): Children are provided as ``*args`` instead of a list argument (not recommended for very long lists). - meta (bool, optional): Provides two arguments: ``meta`` and ``children`` (instead of just the latter) - tree (bool, optional): Provides the entire tree as the argument, instead of the children. - wrapper (function, optional): Provide a function to decorate all methods. - - Example: - :: - - @v_args(inline=True) - class SolveArith(Transformer): - def add(self, left, right): - return left + right - - @v_args(meta=True) - def mul(self, meta, children): - logger.info(f'mul at line {meta.line}') - left, right = children - return left * right - - - class ReverseNotation(Transformer_InPlace): - @v_args(tree=True) - def tree_node(self, tree): - tree.children = tree.children[::-1] - """ - if tree and (meta or inline): - raise ValueError("Visitor functions cannot combine 'tree' with 'meta' or 'inline'.") - - func = None - if meta: - if inline: - func = _vargs_meta_inline - else: - func = _vargs_meta - elif inline: - func = _vargs_inline - elif tree: - func = _vargs_tree - - if wrapper is not None: - if func is not None: - raise ValueError("Cannot use 'wrapper' along with 'tree', 'meta' or 'inline'.") - func = wrapper - - def _visitor_args_dec(obj): - return _apply_v_args(obj, func) - return _visitor_args_dec - - -###} - - -# --- Visitor Utilities --- - -class CollapseAmbiguities(Transformer): - """ - Transforms a tree that contains any number of _ambig nodes into a list of trees, - each one containing an unambiguous tree. - - The length of the resulting list is the product of the length of all _ambig nodes. - - Warning: This may quickly explode for highly ambiguous trees. - - """ - def _ambig(self, options): - return sum(options, []) - - def __default__(self, data, children_lists, meta): - return [Tree(data, children, meta) for children in combine_alternatives(children_lists)] - - def __default_token__(self, t): - return [t] diff --git a/server/libs/pathspec-0.11.2.dist-info/INSTALLER b/server/libs/pathspec-0.11.2.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/libs/pathspec-0.11.2.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/libs/pathspec-0.11.2.dist-info/LICENSE b/server/libs/pathspec-0.11.2.dist-info/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/server/libs/pathspec-0.11.2.dist-info/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/server/libs/pathspec-0.11.2.dist-info/METADATA b/server/libs/pathspec-0.11.2.dist-info/METADATA new file mode 100644 index 0000000..5652f2e --- /dev/null +++ b/server/libs/pathspec-0.11.2.dist-info/METADATA @@ -0,0 +1,601 @@ +Metadata-Version: 2.1 +Name: pathspec +Version: 0.11.2 +Summary: Utility library for gitignore style pattern matching of file paths. +Author-email: "Caleb P. Burns" +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Utilities +Project-URL: Documentation, https://python-path-specification.readthedocs.io/en/latest/index.html +Project-URL: Issue Tracker, https://github.com/cpburnz/python-pathspec/issues +Project-URL: Source Code, https://github.com/cpburnz/python-pathspec + + +PathSpec +======== + +*pathspec* is a utility library for pattern matching of file paths. So +far this only includes Git's wildmatch pattern matching which itself is +derived from Rsync's wildmatch. Git uses wildmatch for its `gitignore`_ +files. + +.. _`gitignore`: http://git-scm.com/docs/gitignore + + +Tutorial +-------- + +Say you have a "Projects" directory and you want to back it up, but only +certain files, and ignore others depending on certain conditions:: + + >>> import pathspec + >>> # The gitignore-style patterns for files to select, but we're including + >>> # instead of ignoring. + >>> spec_text = """ + ... + ... # This is a comment because the line begins with a hash: "#" + ... + ... # Include several project directories (and all descendants) relative to + ... # the current directory. To reference a directory you must end with a + ... # slash: "/" + ... /project-a/ + ... /project-b/ + ... /project-c/ + ... + ... # Patterns can be negated by prefixing with exclamation mark: "!" + ... + ... # Ignore temporary files beginning or ending with "~" and ending with + ... # ".swp". + ... !~* + ... !*~ + ... !*.swp + ... + ... # These are python projects so ignore compiled python files from + ... # testing. + ... !*.pyc + ... + ... # Ignore the build directories but only directly under the project + ... # directories. + ... !/*/build/ + ... + ... """ + +We want to use the ``GitWildMatchPattern`` class to compile our patterns. The +``PathSpec`` class provides an interface around pattern implementations:: + + >>> spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines()) + +That may be a mouthful but it allows for additional patterns to be implemented +in the future without them having to deal with anything but matching the paths +sent to them. ``GitWildMatchPattern`` is the implementation of the actual +pattern which internally gets converted into a regular expression. ``PathSpec`` +is a simple wrapper around a list of compiled patterns. + +To make things simpler, we can use the registered name for a pattern class +instead of always having to provide a reference to the class itself. The +``GitWildMatchPattern`` class is registered as **gitwildmatch**:: + + >>> spec = pathspec.PathSpec.from_lines('gitwildmatch', spec_text.splitlines()) + +If we wanted to manually compile the patterns we can just do the following:: + + >>> patterns = map(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines()) + >>> spec = PathSpec(patterns) + +``PathSpec.from_lines()`` is simply a class method which does just that. + +If you want to load the patterns from file, you can pass the file instance +directly as well:: + + >>> with open('patterns.list', 'r') as fh: + >>> spec = pathspec.PathSpec.from_lines('gitwildmatch', fh) + +You can perform matching on a whole directory tree with:: + + >>> matches = spec.match_tree('path/to/directory') + +Or you can perform matching on a specific set of file paths with:: + + >>> matches = spec.match_files(file_paths) + +Or check to see if an individual file matches:: + + >>> is_matched = spec.match_file(file_path) + +There is a specialized class, ``pathspec.GitIgnoreSpec``, which more closely +implements the behavior of **gitignore**. This uses ``GitWildMatchPattern`` +pattern by default and handles some edge cases differently from the generic +``PathSpec`` class. ``GitIgnoreSpec`` can be used without specifying the pattern +factory:: + + >>> spec = pathspec.GitIgnoreSpec.from_lines(spec_text.splitlines()) + + +License +------- + +*pathspec* is licensed under the `Mozilla Public License Version 2.0`_. See +`LICENSE`_ or the `FAQ`_ for more information. + +In summary, you may use *pathspec* with any closed or open source project +without affecting the license of the larger work so long as you: + +- give credit where credit is due, + +- and release any custom changes made to *pathspec*. + +.. _`Mozilla Public License Version 2.0`: http://www.mozilla.org/MPL/2.0 +.. _`LICENSE`: LICENSE +.. _`FAQ`: http://www.mozilla.org/MPL/2.0/FAQ.html + + +Source +------ + +The source code for *pathspec* is available from the GitHub repo +`cpburnz/python-pathspec`_. + +.. _`cpburnz/python-pathspec`: https://github.com/cpburnz/python-pathspec + + +Installation +------------ + +*pathspec* is available for install through `PyPI`_:: + + pip install pathspec + +*pathspec* can also be built from source. The following packages will be +required: + +- `build`_ (>=0.6.0) + +*pathspec* can then be built and installed with:: + + python -m build + pip install dist/pathspec-*-py3-none-any.whl + +.. _`PyPI`: http://pypi.python.org/pypi/pathspec +.. _`build`: https://pypi.org/project/build/ + + +Documentation +------------- + +Documentation for *pathspec* is available on `Read the Docs`_. + +.. _`Read the Docs`: https://python-path-specification.readthedocs.io + + +Other Languages +--------------- + +The related project `pathspec-ruby`_ (by *highb*) provides a similar library as +a `Ruby gem`_. + +.. _`pathspec-ruby`: https://github.com/highb/pathspec-ruby +.. _`Ruby gem`: https://rubygems.org/gems/pathspec + + + +Change History +============== + + +0.11.2 (2023-07-28) +------------------- + +New features: + +- `Issue #80`_: match_files with negated path spec. `pathspec.PathSpec.match_*()` now have a `negate` parameter to make using *.gitignore* logic easier and more efficient. + +Bug fixes: + +- `Pull #76`_: Add edge case: patterns that end with an escaped space +- `Issue #77`_/`Pull #78`_: Negate with caret symbol as with the exclamation mark. + + +.. _`Pull #76`: https://github.com/cpburnz/python-pathspec/pull/76 +.. _`Issue #77`: https://github.com/cpburnz/python-pathspec/issues/77 +.. _`Pull #78`: https://github.com/cpburnz/python-pathspec/pull/78/ +.. _`Issue #80`: https://github.com/cpburnz/python-pathspec/issues/80 + + +0.11.1 (2023-03-14) +------------------- + +Bug fixes: + +- `Issue #74`_: Include directory should override exclude file. + +Improvements: + +- `Pull #75`_: Fix partially unknown PathLike type. +- Convert `os.PathLike` to a string properly using `os.fspath`. + + +.. _`Issue #74`: https://github.com/cpburnz/python-pathspec/issues/74 +.. _`Pull #75`: https://github.com/cpburnz/python-pathspec/pull/75 + + +0.11.0 (2023-01-24) +------------------- + +Major changes: + +- Changed build backend to `flit_core.buildapi`_ from `setuptools.build_meta`_. Building with `setuptools` through `setup.py` is still supported for distributions that need it. See `Issue #72`_. + +Improvements: + +- `Issue #72`_/`Pull #73`_: Please consider switching the build-system to flit_core to ease setuptools bootstrap. + + +.. _`flit_core.buildapi`: https://flit.pypa.io/en/latest/index.html +.. _`Issue #72`: https://github.com/cpburnz/python-pathspec/issues/72 +.. _`Pull #73`: https://github.com/cpburnz/python-pathspec/pull/73 + + +0.10.3 (2022-12-09) +------------------- + +New features: + +- Added utility function `pathspec.util.append_dir_sep()` to aid in distinguishing between directories and files on the file-system. See `Issue #65`_. + +Bug fixes: + +- `Issue #66`_/`Pull #67`_: Package not marked as py.typed. +- `Issue #68`_: Exports are considered private. +- `Issue #70`_/`Pull #71`_: 'Self' string literal type is Unknown in pyright. + +Improvements: + +- `Issue #65`_: Checking directories via match_file() does not work on Path objects. + + +.. _`Issue #65`: https://github.com/cpburnz/python-pathspec/issues/65 +.. _`Issue #66`: https://github.com/cpburnz/python-pathspec/issues/66 +.. _`Pull #67`: https://github.com/cpburnz/python-pathspec/pull/67 +.. _`Issue #68`: https://github.com/cpburnz/python-pathspec/issues/68 +.. _`Issue #70`: https://github.com/cpburnz/python-pathspec/issues/70 +.. _`Pull #71`: https://github.com/cpburnz/python-pathspec/pull/71 + + +0.10.2 (2022-11-12) +------------------- + +Bug fixes: + +- Fix failing tests on Windows. +- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`. +- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_files()`. +- Type hint on *root* parameter on `pathspec.util.iter_tree_entries()`. +- Type hint on *root* parameter on `pathspec.util.iter_tree_files()`. +- `Issue #64`_: IndexError with my .gitignore file when trying to build a Python package. + +Improvements: + +- `Pull #58`_: CI: add GitHub Actions test workflow. + + +.. _`Pull #58`: https://github.com/cpburnz/python-pathspec/pull/58 +.. _`Issue #64`: https://github.com/cpburnz/python-pathspec/issues/64 + + +0.10.1 (2022-09-02) +------------------- + +Bug fixes: + +- Fix documentation on `pathspec.pattern.RegexPattern.match_file()`. +- `Pull #60`_: Remove redundant wheel dep from pyproject.toml. +- `Issue #61`_: Dist failure for Fedora, CentOS, EPEL. +- `Issue #62`_: Since version 0.10.0 pure wildcard does not work in some cases. + +Improvements: + +- Restore support for legacy installations using `setup.py`. See `Issue #61`_. + + +.. _`Pull #60`: https://github.com/cpburnz/python-pathspec/pull/60 +.. _`Issue #61`: https://github.com/cpburnz/python-pathspec/issues/61 +.. _`Issue #62`: https://github.com/cpburnz/python-pathspec/issues/62 + + +0.10.0 (2022-08-30) +------------------- + +Major changes: + +- Dropped support of EOL Python 2.7, 3.5, 3.6. See `Issue #47`_. +- The *gitwildmatch* pattern `dir/*` is now handled the same as `dir/`. This means `dir/*` will now match all descendants rather than only direct children. See `Issue #19`_. +- Added `pathspec.GitIgnoreSpec` class (see new features). +- Changed build system to `pyproject.toml`_ and build backend to `setuptools.build_meta`_ which may have unforeseen consequences. +- Renamed GitHub project from `python-path-specification`_ to `python-pathspec`_. See `Issue #35`_. + +API changes: + +- Deprecated: `pathspec.util.match_files()` is an old function no longer used. +- Deprecated: `pathspec.match_files()` is an old function no longer used. +- Deprecated: `pathspec.util.normalize_files()` is no longer used. +- Deprecated: `pathspec.util.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`. +- Deprecated: `pathspec.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`. +- Deprecated: `pathspec.pattern.Pattern.match()` is no longer used. Use or implement + `pathspec.pattern.Pattern.match_file()`. + +New features: + +- Added class `pathspec.gitignore.GitIgnoreSpec` (with alias `pathspec.GitIgnoreSpec`) to implement *gitignore* behavior not possible with standard `PathSpec` class. The particular *gitignore* behavior implemented is prioritizing patterns matching the file directly over matching an ancestor directory. + +Bug fixes: + +- `Issue #19`_: Files inside an ignored sub-directory are not matched. +- `Issue #41`_: Incorrectly (?) matches files inside directories that do match. +- `Pull #51`_: Refactor deprecated unittest aliases for Python 3.11 compatibility. +- `Issue #53`_: Symlink pathspec_meta.py breaks Windows. +- `Issue #54`_: test_util.py uses os.symlink which can fail on Windows. +- `Issue #55`_: Backslashes at start of pattern not handled correctly. +- `Pull #56`_: pyproject.toml: include subpackages in setuptools config +- `Issue #57`_: `!` doesn't exclude files in directories if the pattern doesn't have a trailing slash. + +Improvements: + +- Support Python 3.10, 3.11. +- Modernize code to Python 3.7. +- `Issue #52`_: match_files() is not a pure generator function, and it impacts tree_*() gravely. + + +.. _`python-path-specification`: https://github.com/cpburnz/python-path-specification +.. _`python-pathspec`: https://github.com/cpburnz/python-pathspec +.. _`pyproject.toml`: https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/ +.. _`setuptools.build_meta`: https://setuptools.pypa.io/en/latest/build_meta.html +.. _`Issue #19`: https://github.com/cpburnz/python-pathspec/issues/19 +.. _`Issue #35`: https://github.com/cpburnz/python-pathspec/issues/35 +.. _`Issue #41`: https://github.com/cpburnz/python-pathspec/issues/41 +.. _`Issue #47`: https://github.com/cpburnz/python-pathspec/issues/47 +.. _`Pull #51`: https://github.com/cpburnz/python-pathspec/pull/51 +.. _`Issue #52`: https://github.com/cpburnz/python-pathspec/issues/52 +.. _`Issue #53`: https://github.com/cpburnz/python-pathspec/issues/53 +.. _`Issue #54`: https://github.com/cpburnz/python-pathspec/issues/54 +.. _`Issue #55`: https://github.com/cpburnz/python-pathspec/issues/55 +.. _`Pull #56`: https://github.com/cpburnz/python-pathspec/pull/56 +.. _`Issue #57`: https://github.com/cpburnz/python-pathspec/issues/57 + + +0.9.0 (2021-07-17) +------------------ + +- `Issue #44`_/`Pull #50`_: Raise `GitWildMatchPatternError` for invalid git patterns. +- `Pull #45`_: Fix for duplicate leading double-asterisk, and edge cases. +- `Issue #46`_: Fix matching absolute paths. +- API change: `util.normalize_files()` now returns a `Dict[str, List[pathlike]]` instead of a `Dict[str, pathlike]`. +- Added type hinting. + +.. _`Issue #44`: https://github.com/cpburnz/python-pathspec/issues/44 +.. _`Pull #45`: https://github.com/cpburnz/python-pathspec/pull/45 +.. _`Issue #46`: https://github.com/cpburnz/python-pathspec/issues/46 +.. _`Pull #50`: https://github.com/cpburnz/python-pathspec/pull/50 + + +0.8.1 (2020-11-07) +------------------ + +- `Pull #43`_: Add support for addition operator. + +.. _`Pull #43`: https://github.com/cpburnz/python-pathspec/pull/43 + + +0.8.0 (2020-04-09) +------------------ + +- `Issue #30`_: Expose what patterns matched paths. Added `util.detailed_match_files()`. +- `Issue #31`_: `match_tree()` doesn't return symlinks. +- `Issue #34`_: Support `pathlib.Path`\ s. +- Add `PathSpec.match_tree_entries` and `util.iter_tree_entries()` to support directories and symlinks. +- API change: `match_tree()` has been renamed to `match_tree_files()`. The old name `match_tree()` is still available as an alias. +- API change: `match_tree_files()` now returns symlinks. This is a bug fix but it will change the returned results. + +.. _`Issue #30`: https://github.com/cpburnz/python-pathspec/issues/30 +.. _`Issue #31`: https://github.com/cpburnz/python-pathspec/issues/31 +.. _`Issue #34`: https://github.com/cpburnz/python-pathspec/issues/34 + + +0.7.0 (2019-12-27) +------------------ + +- `Pull #28`_: Add support for Python 3.8, and drop Python 3.4. +- `Pull #29`_: Publish bdist wheel. + +.. _`Pull #28`: https://github.com/cpburnz/python-pathspec/pull/28 +.. _`Pull #29`: https://github.com/cpburnz/python-pathspec/pull/29 + + +0.6.0 (2019-10-03) +------------------ + +- `Pull #24`_: Drop support for Python 2.6, 3.2, and 3.3. +- `Pull #25`_: Update README.rst. +- `Pull #26`_: Method to escape gitwildmatch. + +.. _`Pull #24`: https://github.com/cpburnz/python-pathspec/pull/24 +.. _`Pull #25`: https://github.com/cpburnz/python-pathspec/pull/25 +.. _`Pull #26`: https://github.com/cpburnz/python-pathspec/pull/26 + + +0.5.9 (2018-09-15) +------------------ + +- Fixed file system error handling. + + +0.5.8 (2018-09-15) +------------------ + +- Improved type checking. +- Created scripts to test Python 2.6 because Tox removed support for it. +- Improved byte string handling in Python 3. +- `Issue #22`_: Handle dangling symlinks. + +.. _`Issue #22`: https://github.com/cpburnz/python-pathspec/issues/22 + + +0.5.7 (2018-08-14) +------------------ + +- `Issue #21`_: Fix collections deprecation warning. + +.. _`Issue #21`: https://github.com/cpburnz/python-pathspec/issues/21 + + +0.5.6 (2018-04-06) +------------------ + +- Improved unit tests. +- Improved type checking. +- `Issue #20`_: Support current directory prefix. + +.. _`Issue #20`: https://github.com/cpburnz/python-pathspec/issues/20 + + +0.5.5 (2017-09-09) +------------------ + +- Add documentation link to README. + + +0.5.4 (2017-09-09) +------------------ + +- `Pull #17`_: Add link to Ruby implementation of *pathspec*. +- Add sphinx documentation. + +.. _`Pull #17`: https://github.com/cpburnz/python-pathspec/pull/17 + + +0.5.3 (2017-07-01) +------------------ + +- `Issue #14`_: Fix byte strings for Python 3. +- `Pull #15`_: Include "LICENSE" in source package. +- `Issue #16`_: Support Python 2.6. + +.. _`Issue #14`: https://github.com/cpburnz/python-pathspec/issues/14 +.. _`Pull #15`: https://github.com/cpburnz/python-pathspec/pull/15 +.. _`Issue #16`: https://github.com/cpburnz/python-pathspec/issues/16 + + +0.5.2 (2017-04-04) +------------------ + +- Fixed change log. + + +0.5.1 (2017-04-04) +------------------ + +- `Pull #13`_: Add equality methods to `PathSpec` and `RegexPattern`. + +.. _`Pull #13`: https://github.com/cpburnz/python-pathspec/pull/13 + + +0.5.0 (2016-08-22) +------------------ + +- `Issue #12`_: Add `PathSpec.match_file()`. +- Renamed `gitignore.GitIgnorePattern` to `patterns.gitwildmatch.GitWildMatchPattern`. +- Deprecated `gitignore.GitIgnorePattern`. + +.. _`Issue #12`: https://github.com/cpburnz/python-pathspec/issues/12 + + +0.4.0 (2016-07-15) +------------------ + +- `Issue #11`_: Support converting patterns into regular expressions without compiling them. +- API change: Subclasses of `RegexPattern` should implement `pattern_to_regex()`. + +.. _`Issue #11`: https://github.com/cpburnz/python-pathspec/issues/11 + + +0.3.4 (2015-08-24) +------------------ + +- `Pull #7`_: Fixed non-recursive links. +- `Pull #8`_: Fixed edge cases in gitignore patterns. +- `Pull #9`_: Fixed minor usage documentation. +- Fixed recursion detection. +- Fixed trivial incompatibility with Python 3.2. + +.. _`Pull #7`: https://github.com/cpburnz/python-pathspec/pull/7 +.. _`Pull #8`: https://github.com/cpburnz/python-pathspec/pull/8 +.. _`Pull #9`: https://github.com/cpburnz/python-pathspec/pull/9 + + +0.3.3 (2014-11-21) +------------------ + +- Improved documentation. + + +0.3.2 (2014-11-08) +------------------ + +- `Pull #5`_: Use tox for testing. +- `Issue #6`_: Fixed matching Windows paths. +- Improved documentation. +- API change: `spec.match_tree()` and `spec.match_files()` now return iterators instead of sets. + +.. _`Pull #5`: https://github.com/cpburnz/python-pathspec/pull/5 +.. _`Issue #6`: https://github.com/cpburnz/python-pathspec/issues/6 + + +0.3.1 (2014-09-17) +------------------ + +- Updated README. + + +0.3.0 (2014-09-17) +------------------ + +- `Pull #3`_: Fixed trailing slash in gitignore patterns. +- `Pull #4`_: Fixed test for trailing slash in gitignore patterns. +- Added registered patterns. + +.. _`Pull #3`: https://github.com/cpburnz/python-pathspec/pull/3 +.. _`Pull #4`: https://github.com/cpburnz/python-pathspec/pull/4 + + +0.2.2 (2013-12-17) +------------------ + +- Fixed setup.py. + + +0.2.1 (2013-12-17) +------------------ + +- Added tests. +- Fixed comment gitignore patterns. +- Fixed relative path gitignore patterns. + + +0.2.0 (2013-12-07) +------------------ + +- Initial release. + diff --git a/server/libs/pathspec-0.11.2.dist-info/RECORD b/server/libs/pathspec-0.11.2.dist-info/RECORD new file mode 100644 index 0000000..65483bf --- /dev/null +++ b/server/libs/pathspec-0.11.2.dist-info/RECORD @@ -0,0 +1,23 @@ +pathspec-0.11.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +pathspec-0.11.2.dist-info/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726 +pathspec-0.11.2.dist-info/METADATA,sha256=SxnZo-5WRH5npmxwSmYWT1DThQTSIfanQM8_-j8ye1g,19563 +pathspec-0.11.2.dist-info/RECORD,, +pathspec-0.11.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +pathspec-0.11.2.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81 +pathspec/__init__.py,sha256=7SXysmS-FbGnfonqXtaSm6aUKdepQCXdvd4ArWAMJak,1630 +pathspec/__pycache__/__init__.cpython-311.pyc,, +pathspec/__pycache__/_meta.cpython-311.pyc,, +pathspec/__pycache__/gitignore.cpython-311.pyc,, +pathspec/__pycache__/pathspec.cpython-311.pyc,, +pathspec/__pycache__/pattern.cpython-311.pyc,, +pathspec/__pycache__/util.cpython-311.pyc,, +pathspec/_meta.py,sha256=KkXyQhYw9KfMMlZeEuw6TV5Ar7qn_Y9yF4jTBeiJ-pQ,2223 +pathspec/gitignore.py,sha256=nHZA92AltTIfCLf1i4uwvvXeEdfvbNBjetogn0ueGJM,3895 +pathspec/pathspec.py,sha256=O8oFAbo71uvwFWZZm7c2iFy0nzClyEcxOFJHg_EL8WQ,9530 +pathspec/pattern.py,sha256=HVpwUuGMAW7WOtgPOkZUmJY-lg84BtWVvkVXcc_ha28,5784 +pathspec/patterns/__init__.py,sha256=vAzIEqBc2KsvWsiszsLCeYQwQVWXIHzbHNgq5TNrPdk,302 +pathspec/patterns/__pycache__/__init__.cpython-311.pyc,, +pathspec/patterns/__pycache__/gitwildmatch.cpython-311.pyc,, +pathspec/patterns/gitwildmatch.py,sha256=7f8zEBMvySzznrm7oo_geFV0NnmxUucZaYQxtsnMBQ8,12438 +pathspec/py.typed,sha256=wq7wwDeyBungK6DsiV4O-IujgKzARwHz94uQshdpdEU,68 +pathspec/util.py,sha256=8w65a_vDtw3eCyIY5LVQ8EgUGekLwdIBne5yORdIoOQ,20273 diff --git a/server/libs/lark/grammars/__init__.py b/server/libs/pathspec-0.11.2.dist-info/REQUESTED similarity index 100% rename from server/libs/lark/grammars/__init__.py rename to server/libs/pathspec-0.11.2.dist-info/REQUESTED diff --git a/server/libs/pathspec-0.11.2.dist-info/WHEEL b/server/libs/pathspec-0.11.2.dist-info/WHEEL new file mode 100644 index 0000000..3b5e64b --- /dev/null +++ b/server/libs/pathspec-0.11.2.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.9.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/libs/pathspec/__init__.py b/server/libs/pathspec/__init__.py new file mode 100644 index 0000000..32e03f7 --- /dev/null +++ b/server/libs/pathspec/__init__.py @@ -0,0 +1,76 @@ +""" +The *pathspec* package provides pattern matching for file paths. So far +this only includes Git's wildmatch pattern matching (the style used for +".gitignore" files). + +The following classes are imported and made available from the root of +the `pathspec` package: + +- :class:`pathspec.gitignore.GitIgnoreSpec` + +- :class:`pathspec.pathspec.PathSpec` + +- :class:`pathspec.pattern.Pattern` + +- :class:`pathspec.pattern.RegexPattern` + +- :class:`pathspec.util.RecursionError` + +The following functions are also imported: + +- :func:`pathspec.util.lookup_pattern` + +The following deprecated functions are also imported to maintain +backward compatibility: + +- :func:`pathspec.util.iter_tree` which is an alias for + :func:`pathspec.util.iter_tree_files`. + +- :func:`pathspec.util.match_files` +""" + +from .gitignore import ( + GitIgnoreSpec) +from .pathspec import ( + PathSpec) +from .pattern import ( + Pattern, + RegexPattern) +from .util import ( + RecursionError, + iter_tree, + lookup_pattern, + match_files) + +from ._meta import ( + __author__, + __copyright__, + __credits__, + __license__, + __version__, +) + +# Load pattern implementations. +from . import patterns + +# DEPRECATED: Expose the `GitIgnorePattern` class in the root module for +# backward compatibility with v0.4. +from .patterns.gitwildmatch import GitIgnorePattern + +# Declare private imports as part of the public interface. Deprecated +# imports are deliberately excluded. +__all__ = [ + 'GitIgnoreSpec', + 'PathSpec', + 'Pattern', + 'RecursionError', + 'RegexPattern', + '__author__', + '__copyright__', + '__credits__', + '__license__', + '__version__', + 'iter_tree', + 'lookup_pattern', + 'match_files', +] diff --git a/server/libs/pathspec/_meta.py b/server/libs/pathspec/_meta.py new file mode 100644 index 0000000..6cba91d --- /dev/null +++ b/server/libs/pathspec/_meta.py @@ -0,0 +1,57 @@ +""" +This module contains the project meta-data. +""" + +__author__ = "Caleb P. Burns" +__copyright__ = "Copyright © 2013-2023 Caleb P. Burns" +__credits__ = [ + "dahlia ", + "highb ", + "029xue ", + "mikexstudios ", + "nhumrich ", + "davidfraser ", + "demurgos ", + "ghickman ", + "nvie ", + "adrienverge ", + "AndersBlomdell ", + "thmxv ", + "wimglenn ", + "hugovk ", + "dcecile ", + "mroutis ", + "jdufresne ", + "groodt ", + "ftrofin ", + "pykong ", + "nhhollander ", + "KOLANICH ", + "JonjonHays ", + "Isaac0616 ", + "SebastiaanZ ", + "RoelAdriaans ", + "raviselker ", + "johanvergeer ", + "danjer ", + "jhbuhrman ", + "WPDOrdina ", + "tirkarthi ", + "jayvdb ", + "jwodder ", + "kloczek ", + "orens ", + "spMohanty ", + "ichard26 ", + "jack1142 ", + "mgorny ", + "bzakdd ", + "haimat ", + "Avasam ", + "yschroeder ", + "axesider ", + "tomruk ", + "oprypin ", +] +__license__ = "MPL 2.0" +__version__ = "0.11.2" diff --git a/server/libs/pathspec/gitignore.py b/server/libs/pathspec/gitignore.py new file mode 100644 index 0000000..a939225 --- /dev/null +++ b/server/libs/pathspec/gitignore.py @@ -0,0 +1,138 @@ +""" +This module provides :class:`.GitIgnoreSpec` which replicates +*.gitignore* behavior. +""" + +from typing import ( + AnyStr, + Callable, + Collection, + Iterable, + Type, + TypeVar, + Union) + +from .pathspec import ( + PathSpec) +from .pattern import ( + Pattern) +from .patterns.gitwildmatch import ( + GitWildMatchPattern, + GitWildMatchPatternError, + _DIR_MARK) +from .util import ( + _is_iterable) + +Self = TypeVar("Self", bound="GitIgnoreSpec") +""" +:class:`GitIgnoreSpec` self type hint to support Python v<3.11 using PEP +673 recommendation. +""" + + +class GitIgnoreSpec(PathSpec): + """ + The :class:`GitIgnoreSpec` class extends :class:`PathSpec` to + replicate *.gitignore* behavior. + """ + + def __eq__(self, other: object) -> bool: + """ + Tests the equality of this gitignore-spec with *other* + (:class:`GitIgnoreSpec`) by comparing their :attr:`~PathSpec.patterns` + attributes. A non-:class:`GitIgnoreSpec` will not compare equal. + """ + if isinstance(other, GitIgnoreSpec): + return super().__eq__(other) + elif isinstance(other, PathSpec): + return False + else: + return NotImplemented + + @classmethod + def from_lines( + cls: Type[Self], + lines: Iterable[AnyStr], + pattern_factory: Union[str, Callable[[AnyStr], Pattern], None] = None, + ) -> Self: + """ + Compiles the pattern lines. + + *lines* (:class:`~collections.abc.Iterable`) yields each uncompiled + pattern (:class:`str`). This simply has to yield each line so it can + be a :class:`io.TextIOBase` (e.g., from :func:`open` or + :class:`io.StringIO`) or the result from :meth:`str.splitlines`. + + *pattern_factory* can be :data:`None`, the name of a registered + pattern factory (:class:`str`), or a :class:`~collections.abc.Callable` + used to compile patterns. The callable must accept an uncompiled + pattern (:class:`str`) and return the compiled pattern (:class:`.Pattern`). + Default is :data:`None` for :class:`.GitWildMatchPattern`). + + Returns the :class:`GitIgnoreSpec` instance. + """ + if pattern_factory is None: + pattern_factory = GitWildMatchPattern + + elif (isinstance(lines, str) or callable(lines)) and _is_iterable(pattern_factory): + # Support reversed order of arguments from PathSpec. + pattern_factory, lines = lines, pattern_factory + + self = super().from_lines(pattern_factory, lines) + return self # type: ignore + + @staticmethod + def _match_file( + patterns: Collection[GitWildMatchPattern], + file: str, + ) -> bool: + """ + Matches the file to the patterns. + + .. NOTE:: Subclasses of :class:`.PathSpec` may override this + method as an instance method. It does not have to be a static + method. + + *patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`) + contains the patterns to use. + + *file* (:class:`str`) is the normalized file path to be matched + against *patterns*. + + Returns :data:`True` if *file* matched; otherwise, :data:`False`. + """ + out_matched = False + out_priority = 0 + for pattern in patterns: + if pattern.include is not None: + match = pattern.match_file(file) + if match is not None: + # Pattern matched. + + # Check for directory marker. + try: + dir_mark = match.match.group(_DIR_MARK) + except IndexError as e: + # NOTICE: The exact content of this error message is subject + # to change. + raise GitWildMatchPatternError(( + f"Invalid git pattern: directory marker regex group is missing. " + f"Debug: file={file!r} regex={pattern.regex!r} " + f"group={_DIR_MARK!r} match={match.match!r}." + )) from e + + if dir_mark: + # Pattern matched by a directory pattern. + priority = 1 + else: + # Pattern matched by a file pattern. + priority = 2 + + if pattern.include and dir_mark: + out_matched = pattern.include + out_priority = priority + elif priority >= out_priority: + out_matched = pattern.include + out_priority = priority + + return out_matched diff --git a/server/libs/pathspec/pathspec.py b/server/libs/pathspec/pathspec.py new file mode 100644 index 0000000..93f2f60 --- /dev/null +++ b/server/libs/pathspec/pathspec.py @@ -0,0 +1,304 @@ +""" +This module provides an object oriented interface for pattern matching of files. +""" + +from collections.abc import ( + Collection as CollectionType) +from itertools import ( + zip_longest) +from typing import ( + AnyStr, + Callable, + Collection, + Iterable, + Iterator, + Optional, + Type, + TypeVar, + Union) + +from . import util +from .pattern import ( + Pattern) +from .util import ( + StrPath, + TreeEntry, + _filter_patterns, + _is_iterable, + match_file, + normalize_file) + +Self = TypeVar("Self", bound="PathSpec") +""" +:class:`PathSpec` self type hint to support Python v<3.11 using PEP 673 +recommendation. +""" + + +class PathSpec(object): + """ + The :class:`PathSpec` class is a wrapper around a list of compiled + :class:`.Pattern` instances. + """ + + def __init__(self, patterns: Iterable[Pattern]) -> None: + """ + Initializes the :class:`PathSpec` instance. + + *patterns* (:class:`~collections.abc.Collection` or :class:`~collections.abc.Iterable`) + yields each compiled pattern (:class:`.Pattern`). + """ + + self.patterns = patterns if isinstance(patterns, CollectionType) else list(patterns) + """ + *patterns* (:class:`~collections.abc.Collection` of :class:`.Pattern`) + contains the compiled patterns. + """ + + def __eq__(self, other: object) -> bool: + """ + Tests the equality of this path-spec with *other* (:class:`PathSpec`) + by comparing their :attr:`~PathSpec.patterns` attributes. + """ + if isinstance(other, PathSpec): + paired_patterns = zip_longest(self.patterns, other.patterns) + return all(a == b for a, b in paired_patterns) + else: + return NotImplemented + + def __len__(self) -> int: + """ + Returns the number of compiled patterns this path-spec contains + (:class:`int`). + """ + return len(self.patterns) + + def __add__(self: Self, other: "PathSpec") -> Self: + """ + Combines the :attr:`Pathspec.patterns` patterns from two + :class:`PathSpec` instances. + """ + if isinstance(other, PathSpec): + return self.__class__(self.patterns + other.patterns) + else: + return NotImplemented + + def __iadd__(self: Self, other: "PathSpec") -> Self: + """ + Adds the :attr:`Pathspec.patterns` patterns from one :class:`PathSpec` + instance to this instance. + """ + if isinstance(other, PathSpec): + self.patterns += other.patterns + return self + else: + return NotImplemented + + @classmethod + def from_lines( + cls: Type[Self], + pattern_factory: Union[str, Callable[[AnyStr], Pattern]], + lines: Iterable[AnyStr], + ) -> Self: + """ + Compiles the pattern lines. + + *pattern_factory* can be either the name of a registered pattern factory + (:class:`str`), or a :class:`~collections.abc.Callable` used to compile + patterns. It must accept an uncompiled pattern (:class:`str`) and return the + compiled pattern (:class:`.Pattern`). + + *lines* (:class:`~collections.abc.Iterable`) yields each uncompiled pattern + (:class:`str`). This simply has to yield each line so that it can be a + :class:`io.TextIOBase` (e.g., from :func:`open` or :class:`io.StringIO`) or + the result from :meth:`str.splitlines`. + + Returns the :class:`PathSpec` instance. + """ + if isinstance(pattern_factory, str): + pattern_factory = util.lookup_pattern(pattern_factory) + + if not callable(pattern_factory): + raise TypeError(f"pattern_factory:{pattern_factory!r} is not callable.") + + if not _is_iterable(lines): + raise TypeError(f"lines:{lines!r} is not an iterable.") + + patterns = [pattern_factory(line) for line in lines if line] + return cls(patterns) + + def match_entries( + self, + entries: Iterable[TreeEntry], + separators: Optional[Collection[str]] = None, + *, + negate: Optional[bool] = None, + ) -> Iterator[TreeEntry]: + """ + Matches the entries to this path-spec. + + *entries* (:class:`~collections.abc.Iterable` of :class:`~util.TreeEntry`) + contains the entries to be matched against :attr:`self.patterns `. + + *separators* (:class:`~collections.abc.Collection` of :class:`str`; or + :data:`None`) optionally contains the path separators to normalize. See + :func:`~pathspec.util.normalize_file` for more information. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + + Returns the matched entries (:class:`~collections.abc.Iterator` of + :class:`~util.TreeEntry`). + """ + if not _is_iterable(entries): + raise TypeError(f"entries:{entries!r} is not an iterable.") + + use_patterns = _filter_patterns(self.patterns) + for entry in entries: + norm_file = normalize_file(entry.path, separators) + is_match = self._match_file(use_patterns, norm_file) + + if negate: + is_match = not is_match + + if is_match: + yield entry + + # Match files using the `match_file()` utility function. Subclasses may + # override this method as an instance method. It does not have to be a static + # method. + _match_file = staticmethod(match_file) + + def match_file( + self, + file: StrPath, + separators: Optional[Collection[str]] = None, + ) -> bool: + """ + Matches the file to this path-spec. + + *file* (:class:`str` or :class:`os.PathLike[str]`) is the file path to be + matched against :attr:`self.patterns `. + + *separators* (:class:`~collections.abc.Collection` of :class:`str`) + optionally contains the path separators to normalize. See + :func:`~pathspec.util.normalize_file` for more information. + + Returns :data:`True` if *file* matched; otherwise, :data:`False`. + """ + norm_file = util.normalize_file(file, separators=separators) + return self._match_file(self.patterns, norm_file) + + def match_files( + self, + files: Iterable[StrPath], + separators: Optional[Collection[str]] = None, + *, + negate: Optional[bool] = None, + ) -> Iterator[StrPath]: + """ + Matches the files to this path-spec. + + *files* (:class:`~collections.abc.Iterable` of :class:`str` or + :class:`os.PathLike[str]`) contains the file paths to be matched against + :attr:`self.patterns `. + + *separators* (:class:`~collections.abc.Collection` of :class:`str`; or + :data:`None`) optionally contains the path separators to normalize. See + :func:`~pathspec.util.normalize_file` for more information. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + + Returns the matched files (:class:`~collections.abc.Iterator` of + :class:`str` or :class:`os.PathLike[str]`). + """ + if not _is_iterable(files): + raise TypeError(f"files:{files!r} is not an iterable.") + + use_patterns = _filter_patterns(self.patterns) + for orig_file in files: + norm_file = normalize_file(orig_file, separators) + is_match = self._match_file(use_patterns, norm_file) + + if negate: + is_match = not is_match + + if is_match: + yield orig_file + + def match_tree_entries( + self, + root: StrPath, + on_error: Optional[Callable] = None, + follow_links: Optional[bool] = None, + *, + negate: Optional[bool] = None, + ) -> Iterator[TreeEntry]: + """ + Walks the specified root path for all files and matches them to this + path-spec. + + *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory to + search. + + *on_error* (:class:`~collections.abc.Callable` or :data:`None`) optionally + is the error handler for file-system exceptions. See + :func:`~pathspec.util.iter_tree_entries` for more information. + + *follow_links* (:class:`bool` or :data:`None`) optionally is whether to walk + symbolic links that resolve to directories. See + :func:`~pathspec.util.iter_tree_files` for more information. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + + Returns the matched files (:class:`~collections.abc.Iterator` of + :class:`.TreeEntry`). + """ + entries = util.iter_tree_entries(root, on_error=on_error, follow_links=follow_links) + yield from self.match_entries(entries, negate=negate) + + def match_tree_files( + self, + root: StrPath, + on_error: Optional[Callable] = None, + follow_links: Optional[bool] = None, + *, + negate: Optional[bool] = None, + ) -> Iterator[str]: + """ + Walks the specified root path for all files and matches them to this + path-spec. + + *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory to + search for files. + + *on_error* (:class:`~collections.abc.Callable` or :data:`None`) optionally + is the error handler for file-system exceptions. See + :func:`~pathspec.util.iter_tree_files` for more information. + + *follow_links* (:class:`bool` or :data:`None`) optionally is whether to walk + symbolic links that resolve to directories. See + :func:`~pathspec.util.iter_tree_files` for more information. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + + Returns the matched files (:class:`~collections.abc.Iterable` of + :class:`str`). + """ + files = util.iter_tree_files(root, on_error=on_error, follow_links=follow_links) + yield from self.match_files(files, negate=negate) + + # Alias `match_tree_files()` as `match_tree()` for backward compatibility + # before v0.3.2. + match_tree = match_tree_files diff --git a/server/libs/pathspec/pattern.py b/server/libs/pathspec/pattern.py new file mode 100644 index 0000000..5222ec0 --- /dev/null +++ b/server/libs/pathspec/pattern.py @@ -0,0 +1,206 @@ +""" +This module provides the base definition for patterns. +""" + +import dataclasses +import re +import warnings +from typing import ( + Any, + AnyStr, + Iterable, + Iterator, + Match as MatchHint, + Optional, + Pattern as PatternHint, + Tuple, + Union) + + +class Pattern(object): + """ + The :class:`Pattern` class is the abstract definition of a pattern. + """ + + # Make the class dict-less. + __slots__ = ('include',) + + def __init__(self, include: Optional[bool]) -> None: + """ + Initializes the :class:`Pattern` instance. + + *include* (:class:`bool` or :data:`None`) is whether the matched + files should be included (:data:`True`), excluded (:data:`False`), + or is a null-operation (:data:`None`). + """ + + self.include = include + """ + *include* (:class:`bool` or :data:`None`) is whether the matched + files should be included (:data:`True`), excluded (:data:`False`), + or is a null-operation (:data:`None`). + """ + + def match(self, files: Iterable[str]) -> Iterator[str]: + """ + DEPRECATED: This method is no longer used and has been replaced by + :meth:`.match_file`. Use the :meth:`.match_file` method with a loop + for similar results. + + Matches this pattern against the specified files. + + *files* (:class:`~collections.abc.Iterable` of :class:`str`) + contains each file relative to the root directory (e.g., + :data:`"relative/path/to/file"`). + + Returns an :class:`~collections.abc.Iterable` yielding each matched + file path (:class:`str`). + """ + warnings.warn(( + "{0.__module__}.{0.__qualname__}.match() is deprecated. Use " + "{0.__module__}.{0.__qualname__}.match_file() with a loop for " + "similar results." + ).format(self.__class__), DeprecationWarning, stacklevel=2) + + for file in files: + if self.match_file(file) is not None: + yield file + + def match_file(self, file: str) -> Optional[Any]: + """ + Matches this pattern against the specified file. + + *file* (:class:`str`) is the normalized file path to match against. + + Returns the match result if *file* matched; otherwise, :data:`None`. + """ + raise NotImplementedError(( + "{0.__module__}.{0.__qualname__} must override match_file()." + ).format(self.__class__)) + + +class RegexPattern(Pattern): + """ + The :class:`RegexPattern` class is an implementation of a pattern + using regular expressions. + """ + + # Keep the class dict-less. + __slots__ = ('regex',) + + def __init__( + self, + pattern: Union[AnyStr, PatternHint], + include: Optional[bool] = None, + ) -> None: + """ + Initializes the :class:`RegexPattern` instance. + + *pattern* (:class:`str`, :class:`bytes`, :class:`re.Pattern`, or + :data:`None`) is the pattern to compile into a regular expression. + + *include* (:class:`bool` or :data:`None`) must be :data:`None` + unless *pattern* is a precompiled regular expression (:class:`re.Pattern`) + in which case it is whether matched files should be included + (:data:`True`), excluded (:data:`False`), or is a null operation + (:data:`None`). + + .. NOTE:: Subclasses do not need to support the *include* + parameter. + """ + + if isinstance(pattern, (str, bytes)): + assert include is None, ( + "include:{!r} must be null when pattern:{!r} is a string." + ).format(include, pattern) + regex, include = self.pattern_to_regex(pattern) + # NOTE: Make sure to allow a null regular expression to be + # returned for a null-operation. + if include is not None: + regex = re.compile(regex) + + elif pattern is not None and hasattr(pattern, 'match'): + # Assume pattern is a precompiled regular expression. + # - NOTE: Used specified *include*. + regex = pattern + + elif pattern is None: + # NOTE: Make sure to allow a null pattern to be passed for a + # null-operation. + assert include is None, ( + "include:{!r} must be null when pattern:{!r} is null." + ).format(include, pattern) + + else: + raise TypeError("pattern:{!r} is not a string, re.Pattern, or None.".format(pattern)) + + super(RegexPattern, self).__init__(include) + + self.regex: PatternHint = regex + """ + *regex* (:class:`re.Pattern`) is the regular expression for the + pattern. + """ + + def __eq__(self, other: 'RegexPattern') -> bool: + """ + Tests the equality of this regex pattern with *other* (:class:`RegexPattern`) + by comparing their :attr:`~Pattern.include` and :attr:`~RegexPattern.regex` + attributes. + """ + if isinstance(other, RegexPattern): + return self.include == other.include and self.regex == other.regex + else: + return NotImplemented + + def match_file(self, file: str) -> Optional['RegexMatchResult']: + """ + Matches this pattern against the specified file. + + *file* (:class:`str`) + contains each file relative to the root directory (e.g., "relative/path/to/file"). + + Returns the match result (:class:`RegexMatchResult`) if *file* + matched; otherwise, :data:`None`. + """ + if self.include is not None: + match = self.regex.match(file) + if match is not None: + return RegexMatchResult(match) + + return None + + @classmethod + def pattern_to_regex(cls, pattern: str) -> Tuple[str, bool]: + """ + Convert the pattern into an uncompiled regular expression. + + *pattern* (:class:`str`) is the pattern to convert into a regular + expression. + + Returns the uncompiled regular expression (:class:`str` or :data:`None`), + and whether matched files should be included (:data:`True`), + excluded (:data:`False`), or is a null-operation (:data:`None`). + + .. NOTE:: The default implementation simply returns *pattern* and + :data:`True`. + """ + return pattern, True + + +@dataclasses.dataclass() +class RegexMatchResult(object): + """ + The :class:`RegexMatchResult` data class is used to return information + about the matched regular expression. + """ + + # Keep the class dict-less. + __slots__ = ( + 'match', + ) + + match: MatchHint + """ + *match* (:class:`re.Match`) is the regex match result. + """ diff --git a/server/libs/pathspec/patterns/__init__.py b/server/libs/pathspec/patterns/__init__.py new file mode 100644 index 0000000..7360e9c --- /dev/null +++ b/server/libs/pathspec/patterns/__init__.py @@ -0,0 +1,11 @@ +""" +The *pathspec.patterns* package contains the pattern matching +implementations. +""" + +# Load pattern implementations. +from . import gitwildmatch + +# DEPRECATED: Expose the `GitWildMatchPattern` class in this module for +# backward compatibility with v0.5. +from .gitwildmatch import GitWildMatchPattern diff --git a/server/libs/pathspec/patterns/gitwildmatch.py b/server/libs/pathspec/patterns/gitwildmatch.py new file mode 100644 index 0000000..5c00086 --- /dev/null +++ b/server/libs/pathspec/patterns/gitwildmatch.py @@ -0,0 +1,421 @@ +""" +This module implements Git's wildmatch pattern matching which itself is +derived from Rsync's wildmatch. Git uses wildmatch for its ".gitignore" +files. +""" + +import re +import warnings +from typing import ( + AnyStr, + Optional, + Tuple) + +from .. import util +from ..pattern import RegexPattern + +_BYTES_ENCODING = 'latin1' +""" +The encoding to use when parsing a byte string pattern. +""" + +_DIR_MARK = 'ps_d' +""" +The regex group name for the directory marker. This is only used by +:class:`GitIgnoreSpec`. +""" + + +class GitWildMatchPatternError(ValueError): + """ + The :class:`GitWildMatchPatternError` indicates an invalid git wild match + pattern. + """ + pass + + +class GitWildMatchPattern(RegexPattern): + """ + The :class:`GitWildMatchPattern` class represents a compiled Git + wildmatch pattern. + """ + + # Keep the dict-less class hierarchy. + __slots__ = () + + @classmethod + def pattern_to_regex( + cls, + pattern: AnyStr, + ) -> Tuple[Optional[AnyStr], Optional[bool]]: + """ + Convert the pattern into a regular expression. + + *pattern* (:class:`str` or :class:`bytes`) is the pattern to convert + into a regular expression. + + Returns the uncompiled regular expression (:class:`str`, :class:`bytes`, + or :data:`None`); and whether matched files should be included + (:data:`True`), excluded (:data:`False`), or if it is a + null-operation (:data:`None`). + """ + if isinstance(pattern, str): + return_type = str + elif isinstance(pattern, bytes): + return_type = bytes + pattern = pattern.decode(_BYTES_ENCODING) + else: + raise TypeError(f"pattern:{pattern!r} is not a unicode or byte string.") + + original_pattern = pattern + + if pattern.endswith('\\ '): + # EDGE CASE: Spaces can be escaped with backslash. + # If a pattern that ends with backslash followed by a space, + # only strip from left. + pattern = pattern.lstrip() + else: + pattern = pattern.strip() + + if pattern.startswith('#'): + # A pattern starting with a hash ('#') serves as a comment + # (neither includes nor excludes files). Escape the hash with a + # back-slash to match a literal hash (i.e., '\#'). + regex = None + include = None + + elif pattern == '/': + # EDGE CASE: According to `git check-ignore` (v2.4.1), a single + # '/' does not match any file. + regex = None + include = None + + elif pattern: + if pattern.startswith('!'): + # A pattern starting with an exclamation mark ('!') negates the + # pattern (exclude instead of include). Escape the exclamation + # mark with a back-slash to match a literal exclamation mark + # (i.e., '\!'). + include = False + # Remove leading exclamation mark. + pattern = pattern[1:] + else: + include = True + + # Allow a regex override for edge cases that cannot be handled + # through normalization. + override_regex = None + + # Split pattern into segments. + pattern_segs = pattern.split('/') + + # Normalize pattern to make processing easier. + + # EDGE CASE: Deal with duplicate double-asterisk sequences. + # Collapse each sequence down to one double-asterisk. Iterate over + # the segments in reverse and remove the duplicate double + # asterisks as we go. + for i in range(len(pattern_segs) - 1, 0, -1): + prev = pattern_segs[i-1] + seg = pattern_segs[i] + if prev == '**' and seg == '**': + del pattern_segs[i] + + if len(pattern_segs) == 2 and pattern_segs[0] == '**' and not pattern_segs[1]: + # EDGE CASE: The '**/' pattern should match everything except + # individual files in the root directory. This case cannot be + # adequately handled through normalization. Use the override. + override_regex = f'^.+(?P<{_DIR_MARK}>/).*$' + + if not pattern_segs[0]: + # A pattern beginning with a slash ('/') will only match paths + # directly on the root directory instead of any descendant + # paths. So, remove empty first segment to make pattern relative + # to root. + del pattern_segs[0] + + elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]): + # A single pattern without a beginning slash ('/') will match + # any descendant path. This is equivalent to "**/{pattern}". So, + # prepend with double-asterisks to make pattern relative to + # root. + # EDGE CASE: This also holds for a single pattern with a + # trailing slash (e.g. dir/). + if pattern_segs[0] != '**': + pattern_segs.insert(0, '**') + + else: + # EDGE CASE: A pattern without a beginning slash ('/') but + # contains at least one prepended directory (e.g. + # "dir/{pattern}") should not match "**/dir/{pattern}", + # according to `git check-ignore` (v2.4.1). + pass + + if not pattern_segs: + # After resolving the edge cases, we end up with no pattern at + # all. This must be because the pattern is invalid. + raise GitWildMatchPatternError(f"Invalid git pattern: {original_pattern!r}") + + if not pattern_segs[-1] and len(pattern_segs) > 1: + # A pattern ending with a slash ('/') will match all descendant + # paths if it is a directory but not if it is a regular file. + # This is equivalent to "{pattern}/**". So, set last segment to + # a double-asterisk to include all descendants. + pattern_segs[-1] = '**' + + if override_regex is None: + # Build regular expression from pattern. + output = ['^'] + need_slash = False + end = len(pattern_segs) - 1 + for i, seg in enumerate(pattern_segs): + if seg == '**': + if i == 0 and i == end: + # A pattern consisting solely of double-asterisks ('**') + # will match every path. + output.append(f'[^/]+(?:(?P<{_DIR_MARK}>/).*)?') + elif i == 0: + # A normalized pattern beginning with double-asterisks + # ('**') will match any leading path segments. + output.append('(?:.+/)?') + need_slash = False + elif i == end: + # A normalized pattern ending with double-asterisks ('**') + # will match any trailing path segments. + output.append(f'(?P<{_DIR_MARK}>/).*') + else: + # A pattern with inner double-asterisks ('**') will match + # multiple (or zero) inner path segments. + output.append('(?:/.+)?') + need_slash = True + + elif seg == '*': + # Match single path segment. + if need_slash: + output.append('/') + + output.append('[^/]+') + + if i == end: + # A pattern ending without a slash ('/') will match a file + # or a directory (with paths underneath it). E.g., "foo" + # matches "foo", "foo/bar", "foo/bar/baz", etc. + output.append(f'(?:(?P<{_DIR_MARK}>/).*)?') + + need_slash = True + + else: + # Match segment glob pattern. + if need_slash: + output.append('/') + + try: + output.append(cls._translate_segment_glob(seg)) + except ValueError as e: + raise GitWildMatchPatternError(f"Invalid git pattern: {original_pattern!r}") from e + + if i == end: + # A pattern ending without a slash ('/') will match a file + # or a directory (with paths underneath it). E.g., "foo" + # matches "foo", "foo/bar", "foo/bar/baz", etc. + output.append(f'(?:(?P<{_DIR_MARK}>/).*)?') + + need_slash = True + + output.append('$') + regex = ''.join(output) + + else: + # Use regex override. + regex = override_regex + + else: + # A blank pattern is a null-operation (neither includes nor + # excludes files). + regex = None + include = None + + if regex is not None and return_type is bytes: + regex = regex.encode(_BYTES_ENCODING) + + return regex, include + + @staticmethod + def _translate_segment_glob(pattern: str) -> str: + """ + Translates the glob pattern to a regular expression. This is used in + the constructor to translate a path segment glob pattern to its + corresponding regular expression. + + *pattern* (:class:`str`) is the glob pattern. + + Returns the regular expression (:class:`str`). + """ + # NOTE: This is derived from `fnmatch.translate()` and is similar to + # the POSIX function `fnmatch()` with the `FNM_PATHNAME` flag set. + + escape = False + regex = '' + i, end = 0, len(pattern) + while i < end: + # Get next character. + char = pattern[i] + i += 1 + + if escape: + # Escape the character. + escape = False + regex += re.escape(char) + + elif char == '\\': + # Escape character, escape next character. + escape = True + + elif char == '*': + # Multi-character wildcard. Match any string (except slashes), + # including an empty string. + regex += '[^/]*' + + elif char == '?': + # Single-character wildcard. Match any single character (except + # a slash). + regex += '[^/]' + + elif char == '[': + # Bracket expression wildcard. Except for the beginning + # exclamation mark, the whole bracket expression can be used + # directly as regex but we have to find where the expression + # ends. + # - "[][!]" matches ']', '[' and '!'. + # - "[]-]" matches ']' and '-'. + # - "[!]a-]" matches any character except ']', 'a' and '-'. + j = i + + # Pass bracket expression negation. + if j < end and (pattern[j] == '!' or pattern[j] == '^'): + j += 1 + + # Pass first closing bracket if it is at the beginning of the + # expression. + if j < end and pattern[j] == ']': + j += 1 + + # Find closing bracket. Stop once we reach the end or find it. + while j < end and pattern[j] != ']': + j += 1 + + if j < end: + # Found end of bracket expression. Increment j to be one past + # the closing bracket: + # + # [...] + # ^ ^ + # i j + # + j += 1 + expr = '[' + + if pattern[i] == '!': + # Bracket expression needs to be negated. + expr += '^' + i += 1 + elif pattern[i] == '^': + # POSIX declares that the regex bracket expression negation + # "[^...]" is undefined in a glob pattern. Python's + # `fnmatch.translate()` escapes the caret ('^') as a + # literal. Git supports the using a caret for negation. + # Maintain consistency with Git because that is the expected + # behavior. + expr += '^' + i += 1 + + # Build regex bracket expression. Escape slashes so they are + # treated as literal slashes by regex as defined by POSIX. + expr += pattern[i:j].replace('\\', '\\\\') + + # Add regex bracket expression to regex result. + regex += expr + + # Set i to one past the closing bracket. + i = j + + else: + # Failed to find closing bracket, treat opening bracket as a + # bracket literal instead of as an expression. + regex += '\\[' + + else: + # Regular character, escape it for regex. + regex += re.escape(char) + + if escape: + raise ValueError(f"Escape character found with no next character to escape: {pattern!r}") + + return regex + + @staticmethod + def escape(s: AnyStr) -> AnyStr: + """ + Escape special characters in the given string. + + *s* (:class:`str` or :class:`bytes`) a filename or a string that you + want to escape, usually before adding it to a ".gitignore". + + Returns the escaped string (:class:`str` or :class:`bytes`). + """ + if isinstance(s, str): + return_type = str + string = s + elif isinstance(s, bytes): + return_type = bytes + string = s.decode(_BYTES_ENCODING) + else: + raise TypeError(f"s:{s!r} is not a unicode or byte string.") + + # Reference: https://git-scm.com/docs/gitignore#_pattern_format + meta_characters = r"[]!*#?" + + out_string = "".join("\\" + x if x in meta_characters else x for x in string) + + if return_type is bytes: + return out_string.encode(_BYTES_ENCODING) + else: + return out_string + +util.register_pattern('gitwildmatch', GitWildMatchPattern) + + +class GitIgnorePattern(GitWildMatchPattern): + """ + The :class:`GitIgnorePattern` class is deprecated by :class:`GitWildMatchPattern`. + This class only exists to maintain compatibility with v0.4. + """ + + def __init__(self, *args, **kw) -> None: + """ + Warn about deprecation. + """ + self._deprecated() + super(GitIgnorePattern, self).__init__(*args, **kw) + + @staticmethod + def _deprecated() -> None: + """ + Warn about deprecation. + """ + warnings.warn(( + "GitIgnorePattern ('gitignore') is deprecated. Use " + "GitWildMatchPattern ('gitwildmatch') instead." + ), DeprecationWarning, stacklevel=3) + + @classmethod + def pattern_to_regex(cls, *args, **kw): + """ + Warn about deprecation. + """ + cls._deprecated() + return super(GitIgnorePattern, cls).pattern_to_regex(*args, **kw) + +# Register `GitIgnorePattern` as "gitignore" for backward compatibility +# with v0.4. +util.register_pattern('gitignore', GitIgnorePattern) diff --git a/server/libs/pathspec/py.typed b/server/libs/pathspec/py.typed new file mode 100644 index 0000000..b01eaaf --- /dev/null +++ b/server/libs/pathspec/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. The pathspec package uses inline types. diff --git a/server/libs/pathspec/util.py b/server/libs/pathspec/util.py new file mode 100644 index 0000000..969e3bc --- /dev/null +++ b/server/libs/pathspec/util.py @@ -0,0 +1,719 @@ +""" +This module provides utility methods for dealing with path-specs. +""" + +import os +import os.path +import pathlib +import posixpath +import stat +import sys +import warnings +from collections.abc import ( + Collection as CollectionType, + Iterable as IterableType) +from os import ( + PathLike) +from typing import ( + Any, + AnyStr, + Callable, + Collection, + Dict, + Iterable, + Iterator, + List, + Optional, + Sequence, + Set, + Union) + +from .pattern import ( + Pattern) + +if sys.version_info >= (3, 9): + StrPath = Union[str, PathLike[str]] +else: + StrPath = Union[str, PathLike] + +NORMALIZE_PATH_SEPS = [ + __sep + for __sep in [os.sep, os.altsep] + if __sep and __sep != posixpath.sep +] +""" +*NORMALIZE_PATH_SEPS* (:class:`list` of :class:`str`) contains the path +separators that need to be normalized to the POSIX separator for the +current operating system. The separators are determined by examining +:data:`os.sep` and :data:`os.altsep`. +""" + +_registered_patterns = {} +""" +*_registered_patterns* (:class:`dict`) maps a name (:class:`str`) to the +registered pattern factory (:class:`~collections.abc.Callable`). +""" + + +def append_dir_sep(path: pathlib.Path) -> str: + """ + Appends the path separator to the path if the path is a directory. + This can be used to aid in distinguishing between directories and + files on the file-system by relying on the presence of a trailing path + separator. + + *path* (:class:`pathlib.path`) is the path to use. + + Returns the path (:class:`str`). + """ + str_path = str(path) + if path.is_dir(): + str_path += os.sep + + return str_path + + +def detailed_match_files( + patterns: Iterable[Pattern], + files: Iterable[str], + all_matches: Optional[bool] = None, +) -> Dict[str, 'MatchDetail']: + """ + Matches the files to the patterns, and returns which patterns matched + the files. + + *patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`) + contains the patterns to use. + + *files* (:class:`~collections.abc.Iterable` of :class:`str`) contains + the normalized file paths to be matched against *patterns*. + + *all_matches* (:class:`boot` or :data:`None`) is whether to return all + matches patterns (:data:`True`), or only the last matched pattern + (:data:`False`). Default is :data:`None` for :data:`False`. + + Returns the matched files (:class:`dict`) which maps each matched file + (:class:`str`) to the patterns that matched in order (:class:`.MatchDetail`). + """ + all_files = files if isinstance(files, CollectionType) else list(files) + return_files = {} + for pattern in patterns: + if pattern.include is not None: + result_files = pattern.match(all_files) # TODO: Replace with `.match_file()`. + if pattern.include: + # Add files and record pattern. + for result_file in result_files: + if result_file in return_files: + if all_matches: + return_files[result_file].patterns.append(pattern) + else: + return_files[result_file].patterns[0] = pattern + else: + return_files[result_file] = MatchDetail([pattern]) + + else: + # Remove files. + for file in result_files: + del return_files[file] + + return return_files + + +def _filter_patterns(patterns: Iterable[Pattern]) -> List[Pattern]: + """ + Filters out null-patterns. + + *patterns* (:class:`Iterable` of :class:`.Pattern`) contains the + patterns. + + Returns the patterns (:class:`list` of :class:`.Pattern`). + """ + return [ + __pat + for __pat in patterns + if __pat.include is not None + ] + + +def _is_iterable(value: Any) -> bool: + """ + Check whether the value is an iterable (excludes strings). + + *value* is the value to check, + + Returns whether *value* is a iterable (:class:`bool`). + """ + return isinstance(value, IterableType) and not isinstance(value, (str, bytes)) + + +def iter_tree_entries( + root: StrPath, + on_error: Optional[Callable] = None, + follow_links: Optional[bool] = None, +) -> Iterator['TreeEntry']: + """ + Walks the specified directory for all files and directories. + + *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory to + search. + + *on_error* (:class:`~collections.abc.Callable` or :data:`None`) + optionally is the error handler for file-system exceptions. It will be + called with the exception (:exc:`OSError`). Reraise the exception to + abort the walk. Default is :data:`None` to ignore file-system + exceptions. + + *follow_links* (:class:`bool` or :data:`None`) optionally is whether + to walk symbolic links that resolve to directories. Default is + :data:`None` for :data:`True`. + + Raises :exc:`RecursionError` if recursion is detected. + + Returns an :class:`~collections.abc.Iterator` yielding each file or + directory entry (:class:`.TreeEntry`) relative to *root*. + """ + if on_error is not None and not callable(on_error): + raise TypeError(f"on_error:{on_error!r} is not callable.") + + if follow_links is None: + follow_links = True + + yield from _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links) + + +def _iter_tree_entries_next( + root_full: str, + dir_rel: str, + memo: Dict[str, str], + on_error: Callable, + follow_links: bool, +) -> Iterator['TreeEntry']: + """ + Scan the directory for all descendant files. + + *root_full* (:class:`str`) the absolute path to the root directory. + + *dir_rel* (:class:`str`) the path to the directory to scan relative to + *root_full*. + + *memo* (:class:`dict`) keeps track of ancestor directories + encountered. Maps each ancestor real path (:class:`str`) to relative + path (:class:`str`). + + *on_error* (:class:`~collections.abc.Callable` or :data:`None`) + optionally is the error handler for file-system exceptions. + + *follow_links* (:class:`bool`) is whether to walk symbolic links that + resolve to directories. + + Yields each entry (:class:`.TreeEntry`). + """ + dir_full = os.path.join(root_full, dir_rel) + dir_real = os.path.realpath(dir_full) + + # Remember each encountered ancestor directory and its canonical + # (real) path. If a canonical path is encountered more than once, + # recursion has occurred. + if dir_real not in memo: + memo[dir_real] = dir_rel + else: + raise RecursionError(real_path=dir_real, first_path=memo[dir_real], second_path=dir_rel) + + with os.scandir(dir_full) as scan_iter: + node_ent: os.DirEntry + for node_ent in scan_iter: + node_rel = os.path.join(dir_rel, node_ent.name) + + # Inspect child node. + try: + node_lstat = node_ent.stat(follow_symlinks=False) + except OSError as e: + if on_error is not None: + on_error(e) + continue + + if node_ent.is_symlink(): + # Child node is a link, inspect the target node. + try: + node_stat = node_ent.stat() + except OSError as e: + if on_error is not None: + on_error(e) + continue + else: + node_stat = node_lstat + + if node_ent.is_dir(follow_symlinks=follow_links): + # Child node is a directory, recurse into it and yield its + # descendant files. + yield TreeEntry(node_ent.name, node_rel, node_lstat, node_stat) + + yield from _iter_tree_entries_next(root_full, node_rel, memo, on_error, follow_links) + + elif node_ent.is_file() or node_ent.is_symlink(): + # Child node is either a file or an unfollowed link, yield it. + yield TreeEntry(node_ent.name, node_rel, node_lstat, node_stat) + + # NOTE: Make sure to remove the canonical (real) path of the directory + # from the ancestors memo once we are done with it. This allows the + # same directory to appear multiple times. If this is not done, the + # second occurrence of the directory will be incorrectly interpreted + # as a recursion. See . + del memo[dir_real] + + +def iter_tree_files( + root: StrPath, + on_error: Optional[Callable] = None, + follow_links: Optional[bool] = None, +) -> Iterator[str]: + """ + Walks the specified directory for all files. + + *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory to + search for files. + + *on_error* (:class:`~collections.abc.Callable` or :data:`None`) + optionally is the error handler for file-system exceptions. It will be + called with the exception (:exc:`OSError`). Reraise the exception to + abort the walk. Default is :data:`None` to ignore file-system + exceptions. + + *follow_links* (:class:`bool` or :data:`None`) optionally is whether + to walk symbolic links that resolve to directories. Default is + :data:`None` for :data:`True`. + + Raises :exc:`RecursionError` if recursion is detected. + + Returns an :class:`~collections.abc.Iterator` yielding the path to + each file (:class:`str`) relative to *root*. + """ + for entry in iter_tree_entries(root, on_error=on_error, follow_links=follow_links): + if not entry.is_dir(follow_links): + yield entry.path + + +def iter_tree(root, on_error=None, follow_links=None): + """ + DEPRECATED: The :func:`.iter_tree` function is an alias for the + :func:`.iter_tree_files` function. + """ + warnings.warn(( + "util.iter_tree() is deprecated. Use util.iter_tree_files() instead." + ), DeprecationWarning, stacklevel=2) + return iter_tree_files(root, on_error=on_error, follow_links=follow_links) + + +def lookup_pattern(name: str) -> Callable[[AnyStr], Pattern]: + """ + Lookups a registered pattern factory by name. + + *name* (:class:`str`) is the name of the pattern factory. + + Returns the registered pattern factory (:class:`~collections.abc.Callable`). + If no pattern factory is registered, raises :exc:`KeyError`. + """ + return _registered_patterns[name] + + +def match_file(patterns: Iterable[Pattern], file: str) -> bool: + """ + Matches the file to the patterns. + + *patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`) + contains the patterns to use. + + *file* (:class:`str`) is the normalized file path to be matched + against *patterns*. + + Returns :data:`True` if *file* matched; otherwise, :data:`False`. + """ + matched = False + for pattern in patterns: + if pattern.include is not None: + if pattern.match_file(file) is not None: + matched = pattern.include + + return matched + + +def match_files( + patterns: Iterable[Pattern], + files: Iterable[str], +) -> Set[str]: + """ + DEPRECATED: This is an old function no longer used. Use the :func:`.match_file` + function with a loop for better results. + + Matches the files to the patterns. + + *patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`) + contains the patterns to use. + + *files* (:class:`~collections.abc.Iterable` of :class:`str`) contains + the normalized file paths to be matched against *patterns*. + + Returns the matched files (:class:`set` of :class:`str`). + """ + warnings.warn(( + "util.match_files() is deprecated. Use util.match_file() with a " + "loop for better results." + ), DeprecationWarning, stacklevel=2) + + use_patterns = _filter_patterns(patterns) + + return_files = set() + for file in files: + if match_file(use_patterns, file): + return_files.add(file) + + return return_files + + +def normalize_file( + file: StrPath, + separators: Optional[Collection[str]] = None, +) -> str: + """ + Normalizes the file path to use the POSIX path separator (i.e., + :data:`'/'`), and make the paths relative (remove leading :data:`'/'`). + + *file* (:class:`str` or :class:`os.PathLike[str]`) is the file path. + + *separators* (:class:`~collections.abc.Collection` of :class:`str`; or + :data:`None`) optionally contains the path separators to normalize. + This does not need to include the POSIX path separator (:data:`'/'`), + but including it will not affect the results. Default is :data:`None` + for :data:`NORMALIZE_PATH_SEPS`. To prevent normalization, pass an + empty container (e.g., an empty tuple :data:`()`). + + Returns the normalized file path (:class:`str`). + """ + # Normalize path separators. + if separators is None: + separators = NORMALIZE_PATH_SEPS + + # Convert path object to string. + norm_file: str = os.fspath(file) + + for sep in separators: + norm_file = norm_file.replace(sep, posixpath.sep) + + if norm_file.startswith('/'): + # Make path relative. + norm_file = norm_file[1:] + + elif norm_file.startswith('./'): + # Remove current directory prefix. + norm_file = norm_file[2:] + + return norm_file + + +def normalize_files( + files: Iterable[StrPath], + separators: Optional[Collection[str]] = None, +) -> Dict[str, List[StrPath]]: + """ + DEPRECATED: This function is no longer used. Use the :func:`.normalize_file` + function with a loop for better results. + + Normalizes the file paths to use the POSIX path separator. + + *files* (:class:`~collections.abc.Iterable` of :class:`str` or + :class:`os.PathLike[str]`) contains the file paths to be normalized. + + *separators* (:class:`~collections.abc.Collection` of :class:`str`; or + :data:`None`) optionally contains the path separators to normalize. + See :func:`normalize_file` for more information. + + Returns a :class:`dict` mapping each normalized file path (:class:`str`) + to the original file paths (:class:`list` of :class:`str` or + :class:`os.PathLike[str]`). + """ + warnings.warn(( + "util.normalize_files() is deprecated. Use util.normalize_file() " + "with a loop for better results." + ), DeprecationWarning, stacklevel=2) + + norm_files = {} + for path in files: + norm_file = normalize_file(path, separators=separators) + if norm_file in norm_files: + norm_files[norm_file].append(path) + else: + norm_files[norm_file] = [path] + + return norm_files + + +def register_pattern( + name: str, + pattern_factory: Callable[[AnyStr], Pattern], + override: Optional[bool] = None, +) -> None: + """ + Registers the specified pattern factory. + + *name* (:class:`str`) is the name to register the pattern factory + under. + + *pattern_factory* (:class:`~collections.abc.Callable`) is used to + compile patterns. It must accept an uncompiled pattern (:class:`str`) + and return the compiled pattern (:class:`.Pattern`). + + *override* (:class:`bool` or :data:`None`) optionally is whether to + allow overriding an already registered pattern under the same name + (:data:`True`), instead of raising an :exc:`AlreadyRegisteredError` + (:data:`False`). Default is :data:`None` for :data:`False`. + """ + if not isinstance(name, str): + raise TypeError(f"name:{name!r} is not a string.") + + if not callable(pattern_factory): + raise TypeError(f"pattern_factory:{pattern_factory!r} is not callable.") + + if name in _registered_patterns and not override: + raise AlreadyRegisteredError(name, _registered_patterns[name]) + + _registered_patterns[name] = pattern_factory + + +class AlreadyRegisteredError(Exception): + """ + The :exc:`AlreadyRegisteredError` exception is raised when a pattern + factory is registered under a name already in use. + """ + + def __init__( + self, + name: str, + pattern_factory: Callable[[AnyStr], Pattern], + ) -> None: + """ + Initializes the :exc:`AlreadyRegisteredError` instance. + + *name* (:class:`str`) is the name of the registered pattern. + + *pattern_factory* (:class:`~collections.abc.Callable`) is the + registered pattern factory. + """ + super(AlreadyRegisteredError, self).__init__(name, pattern_factory) + + @property + def message(self) -> str: + """ + *message* (:class:`str`) is the error message. + """ + return "{name!r} is already registered for pattern factory:{pattern_factory!r}.".format( + name=self.name, + pattern_factory=self.pattern_factory, + ) + + @property + def name(self) -> str: + """ + *name* (:class:`str`) is the name of the registered pattern. + """ + return self.args[0] + + @property + def pattern_factory(self) -> Callable[[AnyStr], Pattern]: + """ + *pattern_factory* (:class:`~collections.abc.Callable`) is the + registered pattern factory. + """ + return self.args[1] + + +class RecursionError(Exception): + """ + The :exc:`RecursionError` exception is raised when recursion is + detected. + """ + + def __init__( + self, + real_path: str, + first_path: str, + second_path: str, + ) -> None: + """ + Initializes the :exc:`RecursionError` instance. + + *real_path* (:class:`str`) is the real path that recursion was + encountered on. + + *first_path* (:class:`str`) is the first path encountered for + *real_path*. + + *second_path* (:class:`str`) is the second path encountered for + *real_path*. + """ + super(RecursionError, self).__init__(real_path, first_path, second_path) + + @property + def first_path(self) -> str: + """ + *first_path* (:class:`str`) is the first path encountered for + :attr:`self.real_path `. + """ + return self.args[1] + + @property + def message(self) -> str: + """ + *message* (:class:`str`) is the error message. + """ + return "Real path {real!r} was encountered at {first!r} and then {second!r}.".format( + real=self.real_path, + first=self.first_path, + second=self.second_path, + ) + + @property + def real_path(self) -> str: + """ + *real_path* (:class:`str`) is the real path that recursion was + encountered on. + """ + return self.args[0] + + @property + def second_path(self) -> str: + """ + *second_path* (:class:`str`) is the second path encountered for + :attr:`self.real_path `. + """ + return self.args[2] + + +class MatchDetail(object): + """ + The :class:`.MatchDetail` class contains information about + """ + + # Make the class dict-less. + __slots__ = ('patterns',) + + def __init__(self, patterns: Sequence[Pattern]) -> None: + """ + Initialize the :class:`.MatchDetail` instance. + + *patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`) + contains the patterns that matched the file in the order they were + encountered. + """ + + self.patterns = patterns + """ + *patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`) + contains the patterns that matched the file in the order they were + encountered. + """ + + +class TreeEntry(object): + """ + The :class:`.TreeEntry` class contains information about a file-system + entry. + """ + + # Make the class dict-less. + __slots__ = ('_lstat', 'name', 'path', '_stat') + + def __init__( + self, + name: str, + path: str, + lstat: os.stat_result, + stat: os.stat_result, + ) -> None: + """ + Initialize the :class:`.TreeEntry` instance. + + *name* (:class:`str`) is the base name of the entry. + + *path* (:class:`str`) is the relative path of the entry. + + *lstat* (:class:`os.stat_result`) is the stat result of the direct + entry. + + *stat* (:class:`os.stat_result`) is the stat result of the entry, + potentially linked. + """ + + self._lstat: os.stat_result = lstat + """ + *_lstat* (:class:`os.stat_result`) is the stat result of the direct + entry. + """ + + self.name: str = name + """ + *name* (:class:`str`) is the base name of the entry. + """ + + self.path: str = path + """ + *path* (:class:`str`) is the path of the entry. + """ + + self._stat: os.stat_result = stat + """ + *_stat* (:class:`os.stat_result`) is the stat result of the linked + entry. + """ + + def is_dir(self, follow_links: Optional[bool] = None) -> bool: + """ + Get whether the entry is a directory. + + *follow_links* (:class:`bool` or :data:`None`) is whether to follow + symbolic links. If this is :data:`True`, a symlink to a directory + will result in :data:`True`. Default is :data:`None` for :data:`True`. + + Returns whether the entry is a directory (:class:`bool`). + """ + if follow_links is None: + follow_links = True + + node_stat = self._stat if follow_links else self._lstat + return stat.S_ISDIR(node_stat.st_mode) + + def is_file(self, follow_links: Optional[bool] = None) -> bool: + """ + Get whether the entry is a regular file. + + *follow_links* (:class:`bool` or :data:`None`) is whether to follow + symbolic links. If this is :data:`True`, a symlink to a regular file + will result in :data:`True`. Default is :data:`None` for :data:`True`. + + Returns whether the entry is a regular file (:class:`bool`). + """ + if follow_links is None: + follow_links = True + + node_stat = self._stat if follow_links else self._lstat + return stat.S_ISREG(node_stat.st_mode) + + def is_symlink(self) -> bool: + """ + Returns whether the entry is a symbolic link (:class:`bool`). + """ + return stat.S_ISLNK(self._lstat.st_mode) + + def stat(self, follow_links: Optional[bool] = None) -> os.stat_result: + """ + Get the cached stat result for the entry. + + *follow_links* (:class:`bool` or :data:`None`) is whether to follow + symbolic links. If this is :data:`True`, the stat result of the + linked file will be returned. Default is :data:`None` for :data:`True`. + + Returns that stat result (:class:`os.stat_result`). + """ + if follow_links is None: + follow_links = True + + return self._stat if follow_links else self._lstat diff --git a/server/libs/tclint-0.6.0.dist-info/INSTALLER b/server/libs/tclint-0.6.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/libs/tclint-0.6.0.dist-info/METADATA b/server/libs/tclint-0.6.0.dist-info/METADATA new file mode 100644 index 0000000..ca24afa --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/METADATA @@ -0,0 +1,112 @@ +Metadata-Version: 2.4 +Name: tclint +Version: 0.6.0 +Summary: A CLI utility for linting and analyzing Tcl code. +Author-email: Noah Moroze +License: MIT License +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: ply==3.11 +Requires-Dist: tomli~=2.0.1; python_version < "3.11" +Requires-Dist: pathspec==0.11.2 +Requires-Dist: importlib-metadata==6.8.0 +Requires-Dist: pygls==1.3.1 +Requires-Dist: voluptuous==0.15.2 +Provides-Extra: dev +Requires-Dist: black; extra == "dev" +Requires-Dist: flake8; extra == "dev" +Requires-Dist: pytest; extra == "dev" +Requires-Dist: pytest-timeout; extra == "dev" +Requires-Dist: codespell; extra == "dev" +Requires-Dist: pytest-lsp; extra == "dev" +Dynamic: license-file + +# tclint   [![CI](https://github.com/nmoroze/tclint/actions/workflows/ci.yml/badge.svg)](https://github.com/nmoroze/tclint/actions/workflows/ci.yml) + +`tclint` is a collection of modern dev tools for Tcl. It includes a linter, a formatter, and a language server that provides Tcl support to your editor of choice. + +### Features + +- [Editor integration][lsp] for VS Code, Neovim, and Emacs +- [Linting][violations] for common Tcl errors +- [Formatter][tclfmt] that enforces a consistent, readable style +- [Plugin system](docs/plugins.md) that supports Tcl variants +- [More features][features] coming soon! + +## Getting Started + +Install `tclint` from PyPI using [`pipx`](https://pypa.github.io/pipx/) (recommended): + +```sh +pipx install tclint +``` + +Or with `pip`: + +```sh +pip install tclint +``` + +Run `tclint` on a Tcl source file by providing its path as a positional argument: + +```sh +tclint example.tcl +``` + +If the file contains any lint violations, they will be printed and `tclint` will return a non-zero exit code. Otherwise, the output will be empty and `tclint` will exit successfully. + +### Example + +```console +$ cat example.tcl +if { [expr {$input > 10}] } { + puts $input is greater than 10! +} +$ tclint example.tcl +data/example.tcl:1:6: unnecessary command substitution within expression [redundant-expr] +data/example.tcl:2:3: too many args for puts: got 5, expected no more than 3 [command-args] +``` + +## Usage + +`tclint` is a command-line utility. It takes a list of paths as positional arguments, which may either be direct paths to source files, or directories which will be recursively searched for files ending in `.tcl`, `.sdc`, `.xdc`, or `.upf`. + +Collected files will be checked for lint violations. See the +[Violations](docs/violations.md) documentation page for a description of all +lint violations `tclint` may report. + +Aspects of `tclint`'s behavior can be controlled by a configuration file. By default, `tclint` will look for a file named `tclint.toml` or `.tclint` in the current working directory (in that order), but a path to an alternate configuration file can be provided using the `-c` or `--config` flag. See [Configuration](docs/configuration.md) for documentation on the configuration file. + +`tclint` includes a plugin system for checking EDA tool-specific commands. See the [Plugins](docs/plugins.md) documentation page for more info. + +## Contributing + +`tclint` welcomes community contributions. The best way to help the project is to [open an issue](https://github.com/nmoroze/tclint/issues/new) if you find a bug or have a feature request. + +PRs are also welcome, but for non-trivial changes please open an issue first to solicit feedback. This helps avoid wasted effort. + +Use the following steps to set up `tclint` for local development: + +```sh +$ git clone https://github.com/nmoroze/tclint.git # or URL to fork +$ cd tclint +$ pip install -e .[dev] +``` + +Please format, lint, and run tests before submitting changes: + +```sh +$ black --preview . +$ ./util/pre-commit +``` + +## License + +This project is copyright Noah Moroze, released under the [MIT license](LICENSE). + +[vscode]: https://marketplace.visualstudio.com/items?itemName=nmoroze.tclint +[violations]: docs/violations.md +[lsp]: docs/lsp.md +[tclfmt]: docs/tclfmt.md +[features]: https://github.com/nmoroze/tclint/issues/91 diff --git a/server/libs/tclint-0.6.0.dist-info/RECORD b/server/libs/tclint-0.6.0.dist-info/RECORD new file mode 100644 index 0000000..6f60013 --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/RECORD @@ -0,0 +1,51 @@ +../../bin/tclfmt.exe,sha256=8ZZ_4y2Bn-gjRcDczvxwNhwRfZ-MlnkLw5NSFVnorMI,108435 +../../bin/tclint.exe,sha256=qChLBArPHW4hqqqd04-iDo9uoAQoNYKKCYZWdgaifhc,108435 +../../bin/tclsp.exe,sha256=-GEYOcDbCgp7WBq2zcwTNxO7RM-3vBQUG62K2aqWuCY,108434 +tclint-0.6.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +tclint-0.6.0.dist-info/METADATA,sha256=FMBk7GnSMWPVKrjBqk6Lxu4Wyf0-xG-Fne14sDdfuvA,4061 +tclint-0.6.0.dist-info/RECORD,, +tclint-0.6.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +tclint-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91 +tclint-0.6.0.dist-info/entry_points.txt,sha256=IKl_khZUS1DefUWuXWPY2cG0cLD1FRMh3qrhXIOy7O8,112 +tclint-0.6.0.dist-info/licenses/LICENSE,sha256=PGii0wulXro34f25070gTG-JRGM-TAyXyKVObnNJU68,1055 +tclint-0.6.0.dist-info/top_level.txt,sha256=_cnnEELsoakzUgD9HHdivUoNbpKG9tUGznWvbGLmHQM,7 +tclint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +tclint/__main__.py,sha256=b18i_1-ZvzsA-TjgZUbH7MNVwbmsoiqIXZrg0lUvJNI,64 +tclint/__pycache__/__init__.cpython-311.pyc,, +tclint/__pycache__/__main__.cpython-311.pyc,, +tclint/__pycache__/_version.cpython-311.pyc,, +tclint/__pycache__/checks.cpython-311.pyc,, +tclint/__pycache__/comments.cpython-311.pyc,, +tclint/__pycache__/config.cpython-311.pyc,, +tclint/__pycache__/format.cpython-311.pyc,, +tclint/__pycache__/lexer.cpython-311.pyc,, +tclint/__pycache__/parser.cpython-311.pyc,, +tclint/__pycache__/syntax_tree.cpython-311.pyc,, +tclint/__pycache__/violations.cpython-311.pyc,, +tclint/_version.py,sha256=jF9TuoEIJRaca3ScKo6qaz6PzaMlu7jjuSQIrJ3nX4U,511 +tclint/checks.py,sha256=u6EI1V0fNRhcFAGBeKdXlHrCHrOD0cUAyXpzYgzBcq8,6872 +tclint/cli/__pycache__/tclfmt.cpython-311.pyc,, +tclint/cli/__pycache__/tclint.cpython-311.pyc,, +tclint/cli/__pycache__/tclsp.cpython-311.pyc,, +tclint/cli/__pycache__/utils.cpython-311.pyc,, +tclint/cli/tclfmt.py,sha256=jeVLuPkGUkTx2RTqmdcPZu13m7x43DQwlumXFwlL5oA,5517 +tclint/cli/tclint.py,sha256=RYWm_41AUOdbY0fXOSkOy1-sMVYUmwPyRklG-DOa3dw,4488 +tclint/cli/tclsp.py,sha256=martVsLCMZQKOg0WAZuWLJ8wO2wwh0LVVrkFcbLcFMY,14633 +tclint/cli/utils.py,sha256=eaGUozjoyJecVOHMU_fFJWCv1JoNdH_eonj5os_6XB8,2924 +tclint/commands/__init__.py,sha256=CQVM2J2JOIWkt8GIxbIeCIqQTBmuwKzVO6f1eguwix0,1113 +tclint/commands/__pycache__/__init__.cpython-311.pyc,, +tclint/commands/__pycache__/builtin.cpython-311.pyc,, +tclint/commands/__pycache__/checks.cpython-311.pyc,, +tclint/commands/__pycache__/plugins.cpython-311.pyc,, +tclint/commands/__pycache__/schema.cpython-311.pyc,, +tclint/commands/builtin.py,sha256=ny6ERMBsqesK0ML46oVWwOlKuU4Wqq7G8NvkOQbn_Ec,36147 +tclint/commands/checks.py,sha256=7ia0ZbhTLHvSbwVimU2wnEqB1SeRdyC40VMui8s7Z3I,8556 +tclint/commands/plugins.py,sha256=OX42Dm9wXoGUnPUAr4PWj9zWBu7huz6O2YKEqlqprck,2640 +tclint/commands/schema.py,sha256=nPXrgxOl5codb0RtRp-kUI85ML76ZF58eadGanZq3S8,979 +tclint/comments.py,sha256=j50bULPa_l7yhHVJw2HJF0AQMP5_dOYtL78haMIw5kA,3019 +tclint/config.py,sha256=-JM6DtkznWpnJ-wDKxc-uONGjBlmY1--73k-AmTnrZs,13801 +tclint/format.py,sha256=DyYejVdV_gOFkbuo7U2DezoEz129hRHe5hbg61XMHWM,16799 +tclint/lexer.py,sha256=I81EHH47no0ljjpfa0bHNTJrkZqGgU6-AgH0XeIK_5Q,6100 +tclint/parser.py,sha256=fInbPEXRDfgAjf5DX8I7exQwJaRd4qk6FULO-eWVMeM,28223 +tclint/syntax_tree.py,sha256=tfG4_Ff_AD_tGTHID_pW2Qdz7gRH-3NnfyK3hIj-zeg,11727 +tclint/violations.py,sha256=g2nYpPViuxL6LqLL64_kkGzeGxpTZQ1L9z5jusBGFEA,1219 diff --git a/server/libs/lark/parsers/__init__.py b/server/libs/tclint-0.6.0.dist-info/REQUESTED similarity index 100% rename from server/libs/lark/parsers/__init__.py rename to server/libs/tclint-0.6.0.dist-info/REQUESTED diff --git a/server/libs/lark-1.2.2.dist-info/WHEEL b/server/libs/tclint-0.6.0.dist-info/WHEEL similarity index 65% rename from server/libs/lark-1.2.2.dist-info/WHEEL rename to server/libs/tclint-0.6.0.dist-info/WHEEL index 71360e0..e7fa31b 100644 --- a/server/libs/lark-1.2.2.dist-info/WHEEL +++ b/server/libs/tclint-0.6.0.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: setuptools (72.2.0) +Generator: setuptools (80.9.0) Root-Is-Purelib: true Tag: py3-none-any diff --git a/server/libs/tclint-0.6.0.dist-info/entry_points.txt b/server/libs/tclint-0.6.0.dist-info/entry_points.txt new file mode 100644 index 0000000..c182ada --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] +tclfmt = tclint.cli.tclfmt:main +tclint = tclint.cli.tclint:main +tclsp = tclint.cli.tclsp:main diff --git a/server/libs/tclint-0.6.0.dist-info/licenses/LICENSE b/server/libs/tclint-0.6.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..85a1017 --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/licenses/LICENSE @@ -0,0 +1,8 @@ +Copyright Noah Moroze + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/server/libs/tclint-0.6.0.dist-info/top_level.txt b/server/libs/tclint-0.6.0.dist-info/top_level.txt new file mode 100644 index 0000000..3543f35 --- /dev/null +++ b/server/libs/tclint-0.6.0.dist-info/top_level.txt @@ -0,0 +1 @@ +tclint diff --git a/server/src/parser/tcl.lark b/server/libs/tclint/__init__.py similarity index 100% rename from server/src/parser/tcl.lark rename to server/libs/tclint/__init__.py diff --git a/server/libs/tclint/__main__.py b/server/libs/tclint/__main__.py new file mode 100644 index 0000000..95fa5d0 --- /dev/null +++ b/server/libs/tclint/__main__.py @@ -0,0 +1,4 @@ +import sys +from tclint.cli.tclint import main + +sys.exit(main()) diff --git a/server/libs/tclint/_version.py b/server/libs/tclint/_version.py new file mode 100644 index 0000000..92633a5 --- /dev/null +++ b/server/libs/tclint/_version.py @@ -0,0 +1,21 @@ +# file generated by setuptools-scm +# don't change, don't track in version control + +__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple + from typing import Union + + VERSION_TUPLE = Tuple[Union[int, str], ...] +else: + VERSION_TUPLE = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE + +__version__ = version = '0.6.0' +__version_tuple__ = version_tuple = (0, 6, 0) diff --git a/server/libs/tclint/checks.py b/server/libs/tclint/checks.py new file mode 100644 index 0000000..8566823 --- /dev/null +++ b/server/libs/tclint/checks.py @@ -0,0 +1,227 @@ +import re + +from tclint.commands import get_commands +from tclint.violations import Rule, Violation + +from tclint.syntax_tree import ( + Visitor, + BracedExpression, + Expression, + BracedWord, + QuotedWord, + CommandSub, +) + + +class LineLengthChecker: + """Ensures lines aren't too long. + + Reports 'line-length' violations. + """ + + # ref: https://github.com/eslint/eslint/blob/b29a16b22f234f6134475efb6c7be5ac946556ee/lib/rules/max-len.js#L101 # noqa: E501 + # ^ ironic lint waiver... + URL_RE = re.compile(r"[^:/?#]:\/\/[^?#]") + + def check(self, input, _, config): + violations = [] + for i, line in enumerate(input.split("\n")): + if self.URL_RE.search(line) is not None: + # ignore URLs + continue + + lineno = i + 1 + if len(line) > config.style_line_length: + start = (lineno, 1) + end = (lineno, len(line) + 1) + violations.append( + Violation( + Rule.LINE_LENGTH, + f"line length is {len(line)}, maximum allowed is" + f" {config.style_line_length}", + start, + end, + ) + ) + + return violations + + +class TrailingWhitespaceChecker: + """Ensures lines don't include trailing whitespace. + + Reports 'trailing-whitespace' violations. + """ + + def check(self, input, _, config): + violations = [] + for i, line in enumerate(input.split("\n")): + lineno = i + 1 + + WHITESPACE = (" ", "\t") + if line.endswith(WHITESPACE): + start_col = len(line.rstrip("".join(WHITESPACE))) + start = (lineno, start_col + 1) + end = (lineno, len(line) + 1) + violations.append( + Violation( + Rule.TRAILING_WHITESPACE, + "line has trailing whitespace", + start, + end, + ) + ) + + return violations + + +class RedefinedBuiltinChecker(Visitor): + """Ensures names of built-in commands aren't reused by proc definitions. + + Reports 'redefined-builtin' violations. + """ + + def check(self, _, tree, config): + self._violations = [] + + plugins = [config.commands] if config.commands is not None else [] + commands = get_commands(plugins) + self._commands = commands.keys() + + tree.accept(self, recurse=True) + + return self._violations + + def visit_command(self, command): + if command.routine.contents != "proc": + return + + if len(command.args) == 0: + # This is a syntax error, but should already be caught as a command-args + # error by the parser's `proc` command handling. + return + + name = command.args[0].contents + + if name in self._commands: + self._violations.append( + Violation( + Rule.REDEFINED_BUILTIN, + f"redefinition of built-in command '{name}'", + command.pos, + command.args[1].end_pos, + ) + ) + + +class UnbracedExprChecker(Visitor): + def check(self, _, tree, __): + self._violations = [] + tree.accept(self, recurse=True) + return self._violations + + def visit_command(self, command): + if command.routine.contents != "expr": + return + + if len(command.args) == 0: + # This is a syntax error, but should already be caught as a command-args + # error by the parser's `expr` command handling. + return + + if len(command.args) == 1 and isinstance( + command.args[0], (BracedExpression, Expression) + ): + return + + # If we got here, tclint had trouble parsing the expression due to one of the + # two following cases. + + for child in command.args: + if child.contents is None: + self._violations.append( + Violation( + Rule.UNBRACED_EXPR, + "expression with substitutions should be enclosed by braces", + command.args[0].pos, + command.args[-1].end_pos, + ) + ) + return + + for child in command.args: + if isinstance(child, (BracedWord, QuotedWord)): + self._violations.append( + Violation( + Rule.UNBRACED_EXPR, + "expression containing braced or quoted words should be" + " enclosed by braces", + command.args[0].pos, + command.args[-1].end_pos, + ) + ) + return + + # If we reach here, there's probably a bug in expr parsing logic. + assert False, ( + "Children of expr node were different than expected, please file a bug" + " report" + ) + + +class RedundantExprChecker(Visitor): + def check(self, _, tree, __): + self._violations = [] + tree.accept(self, recurse=True) + return self._violations + + def _check_operand(self, operand): + if not isinstance(operand, CommandSub) or len(operand.children) != 1: + return + + command = operand.children[0] + if command.routine.contents == "expr": + self._violations.append( + Violation( + Rule.REDUNDANT_EXPR, + "unnecessary command substitution within expression", + operand.pos, + operand.end_pos, + ) + ) + + def visit_braced_expression(self, expression): + if len(expression.children) == 1: + self._check_operand(expression.children[0]) + + def visit_expression(self, expression): + if len(expression.children) == 1: + self._check_operand(expression.children[0]) + + def visit_unary_op(self, expr): + self._check_operand(expr.children[1]) + + def visit_binary_op(self, expr): + self._check_operand(expr.children[0]) + self._check_operand(expr.children[2]) + + def visit_ternary_op(self, expr): + self._check_operand(expr.children[0]) + self._check_operand(expr.children[2]) + self._check_operand(expr.children[4]) + + def visit_function(self, function): + for arg in function.children[1:]: + self._check_operand(arg) + + +def get_checkers(): + checkers = ( + RedefinedBuiltinChecker(), + UnbracedExprChecker(), + RedundantExprChecker(), + LineLengthChecker(), + TrailingWhitespaceChecker(), + ) + + return checkers diff --git a/server/libs/tclint/cli/tclfmt.py b/server/libs/tclint/cli/tclfmt.py new file mode 100644 index 0000000..cc7c8be --- /dev/null +++ b/server/libs/tclint/cli/tclfmt.py @@ -0,0 +1,182 @@ +"""CLI utility for formatting Tcl code.""" + +import argparse +import pathlib +import sys + +from tclint.cli.utils import resolve_sources, register_codec_warning +from tclint.config import ( + get_config, + setup_tclfmt_config_cli_args, + Config, + ConfigError, + RunConfig, +) +from tclint.parser import Parser, TclSyntaxError +from tclint.format import Formatter, FormatterOpts + +try: + from tclint._version import __version__ # type: ignore +except ModuleNotFoundError: + __version__ = "(unknown version)" + +# exit code flags +EXIT_OK = 0 +EXIT_FORMAT_VIOLATIONS = 1 +EXIT_SYNTAX_ERROR = 2 +EXIT_INPUT_ERROR = 4 + + +def format(script: str, config: Config, debug=False) -> str: + plugins = [config.commands] if config.commands is not None else [] + parser = Parser(debug=debug, command_plugins=plugins) + + formatter = Formatter( + FormatterOpts( + indent=config.get_indent(), + spaces_in_braces=config.style_spaces_in_braces, + max_blank_lines=config.style_max_blank_lines, + indent_namespace_eval=config.style_indent_namespace_eval, + ) + ) + return formatter.format_top(script, parser) + + +def check(path: pathlib.Path, script: str, formatted: str): + parser = Parser() + original_tree = parser.parse(script) + formatted_tree = parser.parse(formatted) + if original_tree != formatted_tree: + print(f"Warning: {path} syntax trees don't match", file=sys.stderr) + print("\n".join(original_tree.diff(formatted_tree)), file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser("tclfmt") + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + parser.add_argument( + "source", + nargs="+", + help=( + "files to format. By default, prints formatted files to stdout. Provide '-'" + " to read from stdin" + ), + type=pathlib.Path, + ) + + mode_group = parser.add_argument_group("mode") + mode_mutex = mode_group.add_mutually_exclusive_group(required=False) + mode_mutex.add_argument( + "--in-place", help="update files that require formatting", action="store_true" + ) + mode_mutex.add_argument( + "--check", + help="list files that require formatting and set the exit code", + action="store_true", + ) + parser.add_argument( + "-d", + "--debug", + action="count", + default=0, + help=( + "display debug output. Provide additional times to increase the verbosity" + " of output (e.g. -dd)" + ), + ) + parser.add_argument( + "-c", + "--config", + help="path to config file", + type=pathlib.Path, + default=None, + metavar="", + ) + setup_tclfmt_config_cli_args(parser) + args = parser.parse_args() + + try: + config = get_config(args.config, pathlib.Path.cwd()) + except ConfigError as e: + print(f"Invalid config file: {e}") + return EXIT_INPUT_ERROR + + if config is None: + config = RunConfig() + + config.apply_cli_args(args) + + try: + # TODO: we should eventually allow tclfmt to find a config by walking up + # directories, at which point exclude_root should be the parent dir of + # the config file, unless -c is used (eslint rules) + exclude_root = pathlib.Path.cwd() + sources = resolve_sources( + args.source, + exclude_patterns=config.exclude, + exclude_root=exclude_root, + extensions=config.extensions, + ) + except FileNotFoundError as e: + print(f"Invalid path provided: {e}") + return EXIT_INPUT_ERROR + + retcode = EXIT_OK + + register_codec_warning("replace_with_warning") + + reformat_count = 0 + for path in sources: + if path is None: + script = sys.stdin.read() + out_prefix = "(stdin)" + else: + with open(path, "r", errors="replace_with_warning") as f: + script = f.read() + out_prefix = str(path) + + try: + formatted = format( + script, config.get_for_path(path), debug=(args.debug > 1) + ) + if args.in_place and path: + with open(path, "w") as f: + f.write(formatted) + elif args.check: + if script != formatted: + print(f"{out_prefix}: needs reformatting") + retcode |= EXIT_FORMAT_VIOLATIONS + reformat_count += 1 + else: + if args.in_place: + print("Warning: --in-place option ignored when reading from stdin") + print(formatted, end="") + + if args.debug > 0: + check(path, script, formatted) + except TclSyntaxError as e: + line, col = e.pos + print(f"{out_prefix}:{line}:{col}: syntax error: {e}", file=sys.stderr) + retcode |= EXIT_SYNTAX_ERROR + continue + + if args.check: + messages = [] + if reformat_count == 0: + messages.append("Formatting clean!") + elif reformat_count == 1: + messages.append("1 file needs reformatting.") + else: + messages.append(f"{reformat_count} files need reformatting.") + messages.append( + f"Checked {len(sources)} file{'s' if len(sources) != 1 else ''}." + ) + print(" ".join(messages)) + + return retcode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/libs/tclint/cli/tclint.py b/server/libs/tclint/cli/tclint.py new file mode 100644 index 0000000..6afd8b1 --- /dev/null +++ b/server/libs/tclint/cli/tclint.py @@ -0,0 +1,173 @@ +"""Main CLI entry point.""" + +import argparse +import pathlib +import sys +from typing import Dict, List, Optional + + +from tclint.config import ( + get_config, + setup_config_cli_args, + Config, + ConfigError, + RunConfig, +) +from tclint.parser import Parser, TclSyntaxError +from tclint.checks import get_checkers +from tclint.violations import Violation, Rule +from tclint.comments import CommentVisitor +from tclint.cli.utils import resolve_sources, register_codec_warning + +try: + from tclint._version import __version__ # type: ignore +except ModuleNotFoundError: + __version__ = "(unknown version)" + +# exit code flags +EXIT_OK = 0 +EXIT_LINT_VIOLATIONS = 1 +EXIT_SYNTAX_ERROR = 2 +EXIT_INPUT_ERROR = 4 + + +def filter_violations( + violations: List[Violation], + config_ignore: List[Rule], + inline_ignore: Dict[int, List[Rule]], +) -> List[Violation]: + filtered_violations = [] + + for violation in violations: + if violation.id in config_ignore: + continue + line = violation.start[0] + if line in inline_ignore and violation.id in inline_ignore[line]: + continue + + filtered_violations.append(violation) + + return filtered_violations + + +def lint( + script: str, + config: Config, + path: Optional[pathlib.Path], + debug=0, +) -> List[Violation]: + plugins = [config.commands] if config.commands is not None else [] + parser = Parser(debug=(debug > 0), command_plugins=plugins) + + violations = [] + tree = parser.parse(script) + violations += parser.violations + + if debug > 0: + print(tree.pretty(positions=(debug > 1))) + + for checker in get_checkers(): + violations += checker.check(script, tree, config) + + v = CommentVisitor() + ignore_lines = v.run(tree, path) + violations = filter_violations(violations, config.ignore, ignore_lines) + + return violations + + +def main(): + parser = argparse.ArgumentParser("tclint") + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + parser.add_argument( + "source", + nargs="+", + help="files to lint. Provide '-' to read from stdin", + type=pathlib.Path, + ) + parser.add_argument( + "-d", + "--debug", + action="count", + default=0, + help=( + "display debug output. Provide additional times to increase the verbosity" + " of output (e.g. -dd)" + ), + ) + parser.add_argument( + "-c", + "--config", + help="path to config file", + type=pathlib.Path, + default=None, + metavar="", + ) + setup_config_cli_args(parser) + args = parser.parse_args() + + try: + config = get_config(args.config, pathlib.Path()) + except ConfigError as e: + print(f"Invalid config file: {e}") + return EXIT_INPUT_ERROR + + if config is None: + config = RunConfig() + + config.apply_cli_args(args) + + try: + # TODO: we should eventually allow tclint to find a config by walking up + # directories, at which point exclude_root should be the parent dir of + # the config file, unless -c is used (eslint rules) + exclude_root = pathlib.Path.cwd() + sources = resolve_sources( + args.source, + exclude_patterns=config.exclude, + exclude_root=exclude_root, + extensions=config.extensions, + ) + except FileNotFoundError as e: + print(f"Invalid path provided: {e}") + return EXIT_INPUT_ERROR + + retcode = EXIT_OK + + register_codec_warning("replace_with_warning") + + for path in sources: + if path is None: + script = sys.stdin.read() + out_prefix = "(stdin)" + else: + with open(path, "r", errors="replace_with_warning") as f: + script = f.read() + out_prefix = str(path) + + try: + violations = lint( + script, + config.get_for_path(path), + path, + debug=args.debug, + ) + except TclSyntaxError as e: + line, col = e.start + print(f"{out_prefix}:{line}:{col}: syntax error: {e}") + retcode |= EXIT_SYNTAX_ERROR + continue + + for violation in sorted(violations): + print(f"{out_prefix}:{violation}") + + if len(violations) > 0: + retcode |= EXIT_LINT_VIOLATIONS + + return retcode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/libs/tclint/cli/tclsp.py b/server/libs/tclint/cli/tclsp.py new file mode 100644 index 0000000..b0b61dd --- /dev/null +++ b/server/libs/tclint/cli/tclsp.py @@ -0,0 +1,437 @@ +import argparse +import dataclasses +import logging +from pathlib import Path +from typing import Dict, List, Optional, Tuple +import uuid + +from lsprotocol import types as lsp + +from pygls.server import LanguageServer +from pygls.workspace import TextDocument +from pygls.uris import to_fs_path + +from tclint.cli import tclint +from tclint.config import get_config, DEFAULT_CONFIGS, RunConfig, Config, ConfigError +from tclint.format import Formatter, FormatterOpts +from tclint.lexer import TclSyntaxError +from tclint.parser import Parser +from tclint.cli import utils + +try: + from tclint._version import __version__ # type: ignore +except ModuleNotFoundError: + __version__ = "(unknown version)" + + +DIAGNOSTIC_SOURCE = "tclint" + + +def lint(source, config, path): + diagnostics = [] + + try: + violations = tclint.lint(source, config, path) + 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 + + +@dataclasses.dataclass +class ExtensionSettings: + # This path is expected to be absolute. + config_file: Optional[Path] = dataclasses.field(default=None) + + +class TclspServer(LanguageServer): + """Main server class. Implements pull diagnostics using a method adapted from + https://pygls.readthedocs.io/en/latest/examples/pull-diagnostics.html.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.diagnostics = {} + self.global_config: RunConfig = None + # Maps workspace roots to configs. + self.configs: Dict[Path, RunConfig] = {} + self.client_supports_refresh = False + + self.global_settings = ExtensionSettings() + self.workspace_settings: Dict[Path, ExtensionSettings] = {} + + def get_roots(self) -> List[Path]: + """Returns root folders currently open in the workspace.""" + roots = [] + for uri in self.workspace.folders.keys(): + path = to_fs_path(uri) + if path is not None: + roots.append(Path(path)) + + if len(roots) > 0: + return roots + + if self.workspace.root_path is not None: + roots.append(Path(self.workspace.root_path)) + + return roots + + def get_root(self, path: Path) -> Optional[Path]: + """Returns workspace root folder that's closest to path. + + Returns None if path is not in a workspace folder or if there are no workspace + folders. + """ + roots = self.get_roots() + closest_root = None + distance = float("inf") + for root in roots: + try: + relpath = path.relative_to(root) + except ValueError: + continue + if len(relpath.parts) < distance: + distance = len(relpath.parts) + closest_root = root + return closest_root + + def get_config_file(self, workspace_root: Path) -> Optional[Path]: + if workspace_root in self.workspace_settings: + settings = self.workspace_settings[workspace_root] + return settings.config_file + return self.global_settings.config_file + + def load_configs(self): + self.configs = {} + for root in self.get_roots(): + try: + path = self.get_config_file(root) + config = get_config(path, root) + if config is not None: + self.configs[root] = config + except ConfigError as e: + self.show_message(f"Error loading config file: {e}") + + # If a global config file exists, we apply it to any file not under a workspace + # folder. + global_path = self.global_settings.config_file + if global_path is not None: + try: + config = get_config(global_path, global_path.parent) + self.global_config = config + except ConfigError as e: + self.show_message(f"Error loading config file: {e}") + + def get_config(self, path: Path, root: Optional[Path]) -> Config: + if root in self.configs: + return self.configs[root].get_for_path(path) + if self.global_config is not None: + return self.global_config.get_for_path(path) + return Config() + + def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]: + path = Path(document.path) + root = self.get_root(path) + config = self.get_config(path, root) + + if root is None: + root = path.parent + + is_excluded = utils.make_exclude_filter(config.exclude) + if is_excluded(path, root): + return [] + + return lint(document.source, config, path) + + def compute_diagnostics(self, document: TextDocument): + # `None` sentinel ensures that `diagnostics` gets updated if the URI is not + # present. + _, previous = self.diagnostics.get(document.uri, (0, None)) + + diagnostics = self._compute_diagnostics(document) + + # Only update if the list has changed + if previous != diagnostics: + self.diagnostics[document.uri] = (document.version, diagnostics) + + def format( + self, + document: TextDocument, + options: lsp.FormattingOptions, + range: Optional[Tuple[int, int]] = None, + ): + path = Path(document.path) + root = self.get_root(path) + config = self.get_config(path, root) + + parser = Parser() + + if config.style_indent is None: + indent = "\t" if not options.insert_spaces else " " * options.tab_size + else: + indent = config.get_indent() + + formatter = Formatter( + FormatterOpts( + indent=indent, + spaces_in_braces=config.style_spaces_in_braces, + max_blank_lines=config.style_max_blank_lines, + indent_namespace_eval=config.style_indent_namespace_eval, + ) + ) + + if range is not None: + start, end = range + return formatter.format_partial(document.source[start:end], parser) + + return formatter.format_top(document.source, parser) + + +server = TclspServer("tclsp", __version__) + + +@server.feature(lsp.TEXT_DOCUMENT_DID_OPEN) +def did_open(ls: TclspServer, params: lsp.DidOpenTextDocumentParams): + """Parse each document when it is opened""" + logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_OPEN, params) + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.compute_diagnostics(doc) + + +@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE) +def did_change(ls: TclspServer, params: lsp.DidOpenTextDocumentParams): + """Parse each document when it is changed""" + logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CHANGE, params) + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.compute_diagnostics(doc) + + +@server.feature( + lsp.TEXT_DOCUMENT_DIAGNOSTIC, + lsp.DiagnosticOptions( + identifier="pull-diagnostics", + inter_file_dependencies=False, + # We could support workspace diagnostics, although an implementation based on + # the pygls tutorial seems to add client-server noise for no benefit (it ends up + # replying to a frequent workspace diagnostics request with "unchanged" + # messages). + workspace_diagnostics=False, + ), +) +def document_diagnostic(ls: TclspServer, params: lsp.DocumentDiagnosticParams): + """Return diagnostics for the requested document""" + logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DIAGNOSTIC, params) + + was_cached = True + if (uri := params.text_document.uri) not in ls.diagnostics: + was_cached = False + doc = ls.workspace.get_text_document(uri) + ls.compute_diagnostics(doc) + + version, diagnostics = ls.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) + + +@server.feature(lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES) +def change_watched_files(ls: TclspServer, params: lsp.DidChangeWatchedFilesParams): + logging.debug("Received %s: %s", lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES, params) + + # Clear diagnostics cache so they get recalculated when requested + ls.diagnostics = {} + + ls.load_configs() + if ls.client_supports_refresh: + ls.lsp.send_request(lsp.WORKSPACE_DIAGNOSTIC_REFRESH, None) + + +@server.feature(lsp.TEXT_DOCUMENT_FORMATTING) +def format_document(ls: TclspServer, params: lsp.DocumentFormattingParams): + """Format the entire document""" + doc = ls.workspace.get_text_document(params.text_document.uri) + + source = doc.source + start = lsp.Position(line=0, character=0) + last_line = source.rsplit("\n", 1)[-1] + end = lsp.Position(line=source.count("\n"), character=len(last_line)) + + formatted = ls.format(doc, params.options) + return [ + lsp.TextEdit( + range=lsp.Range(start=start, end=end), + new_text=formatted, + ) + ] + + +@server.feature(lsp.TEXT_DOCUMENT_RANGE_FORMATTING) +def format_range(ls: TclspServer, params: lsp.DocumentRangeFormattingParams): + """Format the given range with a document""" + doc = ls.workspace.get_text_document(params.text_document.uri) + + # Round up range to full lines. + start_line = params.range.start.line + end_line = params.range.end.line + if params.range.end.character > 0: + end_line += 1 + range = lsp.Range( + start=lsp.Position(line=start_line, character=0), + end=lsp.Position(line=end_line, character=0), + ) + + start = doc.offset_at_position(range.start) + end = doc.offset_at_position(range.end) + + try: + formatted = ls.format(doc, params.options, range=(start, end)) + except TclSyntaxError: + return None + + return [ + lsp.TextEdit( + range=range, + new_text=formatted, + ) + ] + + +@server.feature(lsp.INITIALIZE) +def initialize(ls: TclspServer, params: lsp.InitializeParams) -> None: + if params.initialization_options is None: + return + + # Apply settings provided on initialization. The schema was copied from the template + # that the tclint-vscode extension is based on. + globalSettings = params.initialization_options.get("globalSettings", {}) + if globalSettings.get("configPath"): + path = Path(globalSettings["configPath"]).expanduser() + if not path.is_absolute(): + ls.show_message( + f"Warning: expected global config path to be absolute, got {path}" + ) + else: + ls.global_settings.config_file = path + + for settings in params.initialization_options.get("settings", []): + root = Path(settings["cwd"]) + if root not in ls.workspace_settings: + ls.workspace_settings[root] = ExtensionSettings() + if settings.get("configPath"): + path = Path(settings["configPath"]).expanduser() + if not path.is_absolute(): + path = root / path + ls.workspace_settings[root].config_file = path + + +@server.feature(lsp.INITIALIZED) +def init(ls: TclspServer, params: lsp.InitializeParams): + """Registers file watchers on config filenames so that we can reload configs and + refresh diagnostics if they've changed. + + Based on code snippet in + https://github.com/openlawlibrary/pygls/issues/376#issuecomment-1717656614. + """ + capabilities = ls.client_capabilities.workspace + + try: + ls.client_supports_refresh = ( + capabilities.diagnostics.refresh_support # type: ignore[union-attr] + ) + except AttributeError: + ls.client_supports_refresh = False + + try: + client_supports_watched_files_registration = ( + capabilities.did_change_watched_files.dynamic_registration # type: ignore[union-attr] # noqa: E501 + ) + except AttributeError: + client_supports_watched_files_registration = False + + if client_supports_watched_files_registration: + watchers = [] + for filename in (*DEFAULT_CONFIGS, "pyproject.toml"): + pattern = f"**/{filename}" + watchers.append(lsp.FileSystemWatcher(glob_pattern=pattern)) + + for settings in (ls.global_settings, *ls.workspace_settings.values()): + if settings.config_file is not None: + watchers.append( + lsp.FileSystemWatcher(glob_pattern=settings.config_file) + ) + + ls.register_capability( + lsp.RegistrationParams( + registrations=[ + lsp.Registration( + id=str(uuid.uuid4()), + method=lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES, + register_options=lsp.DidChangeWatchedFilesRegistrationOptions( + watchers=watchers + ), + ) + ] + ) + ) + + ls.load_configs() + + +def main(): + parser = argparse.ArgumentParser("tclsp") + log_levels = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + parser.add_argument( + "-l", + "--log-level", + default="info", + type=lambda x: x.lower(), + help="set the log level. defaults to info", + choices=log_levels.keys(), + ) + args = parser.parse_args() + logging.basicConfig(level=log_levels[args.log_level], format="%(message)s") + + server.start_io() + + +if __name__ == "__main__": + main() diff --git a/server/libs/tclint/cli/utils.py b/server/libs/tclint/cli/utils.py new file mode 100644 index 0000000..a9c1e20 --- /dev/null +++ b/server/libs/tclint/cli/utils.py @@ -0,0 +1,88 @@ +import codecs +import os +import pathlib +import re +from typing import List, Optional + +import pathspec + + +def register_codec_warning(name): + def replace_with_warning_handler(e): + # TODO: formal warning mechanism, include path + print("Warning: non-unicode characters in file, replacing with �") + return codecs.replace_errors(e) + + codecs.register_error(name, replace_with_warning_handler) + + +def make_exclude_filter(exclude_patterns: List[str]): + exclude_patterns = [ + re.sub(r"^\s*#", r"\#", pattern) for pattern in exclude_patterns + ] + exclude_spec = pathspec.PathSpec.from_lines("gitwildmatch", exclude_patterns) + + def is_excluded(path: pathlib.Path, root: pathlib.Path) -> bool: + abspath = path.resolve() + root = root.resolve() + + try: + relpath = pathlib.Path(os.path.relpath(abspath, start=root)) + except ValueError: + # We get here if path and exclude_root are on different drives (on Windows). + # Things should still behave roughly as expected without using a relative + # path. See test_cli_utils.py::test_exclude_filter_windows for test cases. + relpath = abspath + + if exclude_spec.match_file(relpath): + return True + return False + + return is_excluded + + +def resolve_sources( + paths: List[pathlib.Path], + exclude_patterns: List[str], + exclude_root: pathlib.Path, + extensions: List[str], +) -> List[Optional[pathlib.Path]]: + """Resolves paths passed via CLI to a list of filepaths to lint. + + `paths` is a list of paths that may be files or directories. Files are + returned verbatim if they exist, and directories are recursively searched + for files that have an extension specified in `extensions`. Paths that match a + pattern in `exclude_patterns` are ignored (based on gitignore pattern + format, see https://git-scm.com/docs/gitignore#_pattern_format). + + Raises FileNotFoundError if a supplied path does not exist. + """ + extensions = [f".{ext}" if not ext.startswith(".") else ext for ext in extensions] + is_excluded = make_exclude_filter(exclude_patterns) + + sources: List[Optional[pathlib.Path]] = [] + + for path in paths: + if str(path) == "-": + sources.append(None) + continue + + if not path.exists(): + raise FileNotFoundError(f"path {path} does not exist") + + if is_excluded(path, exclude_root): + continue + + if not path.is_dir(): + sources.append(path) + continue + + for dirpath, _, filenames in os.walk(path): + for name in filenames: + _, ext = os.path.splitext(name) + if ext.lower() in extensions: + child = pathlib.Path(dirpath) / name + if not is_excluded(child, exclude_root): + sources.append(child) + + return sources diff --git a/server/libs/tclint/commands/__init__.py b/server/libs/tclint/commands/__init__.py new file mode 100644 index 0000000..b6c4222 --- /dev/null +++ b/server/libs/tclint/commands/__init__.py @@ -0,0 +1,37 @@ +import pathlib +from typing import List, Dict, Union + +from tclint.commands import builtin as _builtin +from tclint.commands.plugins import PluginManager + +# import to expose in package +from tclint.commands.checks import CommandArgError + +__all__ = ["CommandArgError", "validate_command_plugins", "get_commands"] + + +def validate_command_plugins(plugins: List[str]) -> List[str]: + valid_plugins = [] + for plugin in set(plugins): + if PluginManager.load(plugin) is not None: + valid_plugins.append(plugin) + + return valid_plugins + + +def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict: + commands = {} + commands.update(_builtin.commands) + + for plugin in plugins: + if isinstance(plugin, str): + plugin_commands = PluginManager.load(plugin) + elif isinstance(plugin, pathlib.Path): + plugin_commands = PluginManager.load_from_spec(plugin) + else: + raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}") + + if plugin_commands is not None: + commands.update(plugin_commands) + + return commands diff --git a/server/src/tools/commands/builtin.py b/server/libs/tclint/commands/builtin.py similarity index 69% rename from server/src/tools/commands/builtin.py rename to server/libs/tclint/commands/builtin.py index ce12405..44a1691 100644 --- a/server/src/tools/commands/builtin.py +++ b/server/libs/tclint/commands/builtin.py @@ -32,13 +32,13 @@ these would be helpful for your use case, please file an issue. - https://www.tcl.tk/man/tcl/TclCmd/mathop.html """ -from tools.commands.checks import ( +from tclint.commands.checks import ( CommandArgError, check_count, eval, ) -from tools.commands.schema import commands_schema -from tools.syntax_tree import BareWord +from tclint.commands.schema import commands_schema +from tclint.syntax_tree import BareWord def _check_code(arg): @@ -805,278 +805,274 @@ def _while(args, parser): ] -commands = commands_schema( - { - "after": { - "subcommands": { - "cancel": _after_cancel, - "idle": _after_idle, - "info": { - "positionals": [ - {"name": "id", "value": {"type": "any"}, "required": False} - ] - }, - "": _after, +commands = commands_schema({ + "after": { + "subcommands": { + "cancel": _after_cancel, + "idle": _after_idle, + "info": { + "positionals": [ + {"name": "id", "value": {"type": "any"}, "required": False} + ] }, + "": _after, }, - "append": { - "positionals": [ - {"name": "varname", "value": {"type": "any"}, "required": True}, - {"name": "value", "value": {"type": "variadic"}, "required": False}, - ] + }, + "append": { + "positionals": [ + {"name": "varname", "value": {"type": "any"}, "required": True}, + {"name": "value", "value": {"type": "variadic"}, "required": False}, + ] + }, + "apply": _apply, + "array": _array, + "binary": { + "subcommands": { + "decode": check_count("binary decode", 2, None), + "encode": check_count("binary encode", 2, None), + "format": check_count("binary format", 1, None), + "scan": check_count("binary scan", 2, None), }, - "apply": _apply, - "array": _array, - "binary": { - "subcommands": { - "decode": check_count("binary decode", 2, None), - "encode": check_count("binary encode", 2, None), - "format": check_count("binary format", 1, None), - "scan": check_count("binary scan", 2, None), - }, + }, + "break": check_count("break", 0, 0), + "catch": _catch, + "cd": { + "positionals": [ + {"name": "dirName", "value": {"type": "any"}, "required": False} + ], + }, + "chan": _chan, + # TODO: check subcommands + "clock": check_count("clock"), + "close": { + "positionals": [ + {"name": "channelId", "value": {"type": "any"}, "required": True}, + {"name": "read|write", "value": {"type": "any"}, "required": False}, + ], + }, + "concat": { + "positionals": [ + {"name": "arg", "value": {"type": "variadic"}, "required": True}, + ] + }, + "continue": {}, + "coroutine": { + "positionals": [ + {"name": "name", "value": {"type": "any"}, "required": True}, + {"name": "command", "value": {"type": "any"}, "required": True}, + {"name": "arg", "value": {"type": "variadic"}, "required": False}, + ] + }, + "dict": { + "subcommands": { + "append": check_count("dict append", 2, None), + "create": check_count("dict create"), + "exists": check_count("dict exists", 2, None), + "filter": _dict_filter, + "for": _dict_map_for("dict for"), + "get": check_count("dict get", 1, None), + "incr": check_count("dict incr", 2, 3), + "info": check_count("dict info", 1, 1), + "keys": check_count("dict keys", 1, 2), + "lappend": check_count("dict lappend", 2, None), + "map": _dict_map_for("dict map"), + "merge": check_count("dict merge"), + "remove": check_count("dict remove", 1, None), + "replace": check_count("dict replace", 1, None), + "set": check_count("dict set", 3, None), + "size": check_count("dict size", 1, 1), + "unset": check_count("dict unset", 2, None), + "update": _dict_update, + "values": check_count("dict values", 1, 2), + "with": _dict_with, }, - "break": check_count("break", 0, 0), - "catch": _catch, - "cd": { - "positionals": [ - {"name": "dirName", "value": {"type": "any"}, "required": False} - ], + }, + "encoding": { + "subcommands": { + "convertfrom": check_count("encoding convertfrom", 1, 2), + "convertto": check_count("encoding convertto", 1, 2), + "dirs": check_count("encoding dirs", 0, 1), + "names": check_count("encoding names", 0, 0), + "system": check_count("encoding system", 0, 1), }, - "chan": _chan, - # TODO: check subcommands - "clock": check_count("clock"), - "close": { - "positionals": [ - {"name": "channelId", "value": {"type": "any"}, "required": True}, - {"name": "read|write", "value": {"type": "any"}, "required": False}, - ], + }, + "eof": check_count("eof", 1, 1), + "error": check_count("error", 1, 3), + "eval": _eval, + "exec": check_count("exec", 1, None), + "exit": check_count("exit", 0, 1), + "expr": _expr, + "fblocked": check_count("fblocked", 1, 1), + "fconfigure": check_count("fconfigure", 1, None), + "fcopy": check_count("fcopy", 2, 6), + # TODO: check subcommands + "file": check_count("file", 1, None), + "fileevent": _fileevent, + "flush": check_count("flush", 1, 1), + "for": _for, + "foreach": _foreach, + "format": check_count("format", 1, None), + "gets": check_count("gets", 1, 2), + "glob": check_count("glob"), + "global": check_count("global"), + "history": check_count("history"), + "if": _if, + "incr": check_count("incr", 1, 2), + # TODO: check subcommands + "info": check_count("info", 1, None), + # TODO: check other subcommands + "interp": { + "subcommands": { + "eval": _interp_eval, + "": check_count("interp", 1, None), }, - "concat": { - "positionals": [ - {"name": "arg", "value": {"type": "variadic"}, "required": True}, - ] + }, + "join": check_count("join", 1, 2), + "lappend": check_count("lappend", 1, None), + "lassign": check_count("lassign", 1, None), + "lindex": check_count("lindex", 1, None), + "linsert": check_count("linsert", 2, None), + "list": check_count("list", 0, None), + "llength": check_count("llength", 1, 1), + "lrepeat": check_count("lrepeat", 1, None), + "lreplace": check_count("lreplace", 3, None), + "lreverse": check_count("lreverse", 1, 1), + "lset": check_count("lset", 2, None), + "lsort": check_count("lsort", 1, None), + "lmap": _lmap, + "load": check_count("load", 1, 6), + "lrange": check_count("lrange", 3, 3), + "lsearch": check_count("lsearch", 2, None), + "memory": { + "subcommands": { + "active": check_count("memory active", 1, 1), + "break_on_malloc": check_count("memory break_on_malloc", 1, 1), + "info": check_count("memory info", 0, 0), + # just on or off + "init": check_count("memory init", 1, 1), + "objs": check_count("memory objs", 1, 1), + "onexit": check_count("memory onexit", 1, 1), + "tag": check_count("memory tag", 1, 1), + # just on or off + "trace": check_count("memory trace", 1, 1), + "trace_on_at_malloc": check_count("memory trace_on_at_malloc", 1, 1), + # just on or off + "validate": check_count("memory validate", 1, 1), }, - "continue": {}, - "coroutine": { - "positionals": [ - {"name": "name", "value": {"type": "any"}, "required": True}, - {"name": "command", "value": {"type": "any"}, "required": True}, - {"name": "arg", "value": {"type": "variadic"}, "required": False}, - ] - }, - "dict": { - "subcommands": { - "append": check_count("dict append", 2, None), - "create": check_count("dict create"), - "exists": check_count("dict exists", 2, None), - "filter": _dict_filter, - "for": _dict_map_for("dict for"), - "get": check_count("dict get", 1, None), - "incr": check_count("dict incr", 2, 3), - "info": check_count("dict info", 1, 1), - "keys": check_count("dict keys", 1, 2), - "lappend": check_count("dict lappend", 2, None), - "map": _dict_map_for("dict map"), - "merge": check_count("dict merge"), - "remove": check_count("dict remove", 1, None), - "replace": check_count("dict replace", 1, None), - "set": check_count("dict set", 3, None), - "size": check_count("dict size", 1, 1), - "unset": check_count("dict unset", 2, None), - "update": _dict_update, - "values": check_count("dict values", 1, 2), - "with": _dict_with, - }, - }, - "encoding": { - "subcommands": { - "convertfrom": check_count("encoding convertfrom", 1, 2), - "convertto": check_count("encoding convertto", 1, 2), - "dirs": check_count("encoding dirs", 0, 1), - "names": check_count("encoding names", 0, 0), - "system": check_count("encoding system", 0, 1), - }, - }, - "eof": check_count("eof", 1, 1), - "error": check_count("error", 1, 3), - "eval": _eval, - "exec": check_count("exec", 1, None), - "exit": check_count("exit", 0, 1), - "expr": _expr, - "fblocked": check_count("fblocked", 1, 1), - "fconfigure": check_count("fconfigure", 1, None), - "fcopy": check_count("fcopy", 2, 6), - # TODO: check subcommands - "file": check_count("file", 1, None), - "fileevent": _fileevent, - "flush": check_count("flush", 1, 1), - "for": _for, - "foreach": _foreach, - "format": check_count("format", 1, None), - "gets": check_count("gets", 1, 2), - "glob": check_count("glob"), - "global": check_count("global"), - "history": check_count("history"), - "if": _if, - "incr": check_count("incr", 1, 2), - # TODO: check subcommands - "info": check_count("info", 1, None), - # TODO: check other subcommands - "interp": { - "subcommands": { - "eval": _interp_eval, - "": check_count("interp", 1, None), - }, - }, - "join": check_count("join", 1, 2), - "lappend": check_count("lappend", 1, None), - "lassign": check_count("lassign", 1, None), - "lindex": check_count("lindex", 1, None), - "linsert": check_count("linsert", 2, None), - "list": check_count("list", 0, None), - "llength": check_count("llength", 1, 1), - "lrepeat": check_count("lrepeat", 1, None), - "lreplace": check_count("lreplace", 3, None), - "lreverse": check_count("lreverse", 1, 1), - "lset": check_count("lset", 2, None), - "lsort": check_count("lsort", 1, None), - "lmap": _lmap, - "load": check_count("load", 1, 6), - "lrange": check_count("lrange", 3, 3), - "lsearch": check_count("lsearch", 2, None), - "memory": { - "subcommands": { - "active": check_count("memory active", 1, 1), - "break_on_malloc": check_count("memory break_on_malloc", 1, 1), - "info": check_count("memory info", 0, 0), - # just on or off - "init": check_count("memory init", 1, 1), - "objs": check_count("memory objs", 1, 1), - "onexit": check_count("memory onexit", 1, 1), - "tag": check_count("memory tag", 1, 1), - # just on or off - "trace": check_count("memory trace", 1, 1), - "trace_on_at_malloc": check_count("memory trace_on_at_malloc", 1, 1), - # just on or off - "validate": check_count("memory validate", 1, 1), - }, - }, - "namespace": { - "subcommands": { - "children": check_count("namespace children", 0, 2), - "code": _namespace_code, - "current": check_count("namespace current", 0, 0), - "delete": None, - "eval": _namespace_eval, - "exists": check_count("namespace exists", 1, 1), - "export": None, - "forget": None, - "import": None, - "inscope": _namespace_inscope, - "origin": check_count("namespace origin", 1, 1), - "parent": check_count("namespace parent", 0, 1), - "qualifiers": check_count("namespace qualifiers", 1, 1), - "tail": check_count("namespace tail", 1, 1), - "which": check_count("namespace which", 1, 2), - "ensemble": { - "subcommands": { - "create": None, - "configure": check_count( - "namespace ensemble configure", 1, None - ), - "exists": check_count("namespace ensemble exists", 1, 1), - }, + }, + "namespace": { + "subcommands": { + "children": check_count("namespace children", 0, 2), + "code": _namespace_code, + "current": check_count("namespace current", 0, 0), + "delete": None, + "eval": _namespace_eval, + "exists": check_count("namespace exists", 1, 1), + "export": None, + "forget": None, + "import": None, + "inscope": _namespace_inscope, + "origin": check_count("namespace origin", 1, 1), + "parent": check_count("namespace parent", 0, 1), + "qualifiers": check_count("namespace qualifiers", 1, 1), + "tail": check_count("namespace tail", 1, 1), + "which": check_count("namespace which", 1, 2), + "ensemble": { + "subcommands": { + "create": None, + "configure": check_count("namespace ensemble configure", 1, None), + "exists": check_count("namespace ensemble exists", 1, 1), }, }, }, - "open": check_count("open", 1, 3), - "package": { - "subcommands": { - "forget": None, - "ifneeded": _package_ifneeded, - "names": check_count("package names", 0, 0), - "present": check_count("package present", 0, None), - "provide": check_count("package provide", 1, 2), - "require": check_count("package require", 1, None), - "unknown": check_count("package unknown", 1, None), - "vcompare": check_count("package vcompare", 2, 2), - "versions": check_count("package versions", 1, 1), - "vsatisfies": check_count("package vsatisfies", 2, None), - "prefer": check_count("package prefer", 1, 1), - }, + }, + "open": check_count("open", 1, 3), + "package": { + "subcommands": { + "forget": None, + "ifneeded": _package_ifneeded, + "names": check_count("package names", 0, 0), + "present": check_count("package present", 0, None), + "provide": check_count("package provide", 1, 2), + "require": check_count("package require", 1, None), + "unknown": check_count("package unknown", 1, None), + "vcompare": check_count("package vcompare", 2, 2), + "versions": check_count("package versions", 1, 1), + "vsatisfies": check_count("package vsatisfies", 2, None), + "prefer": check_count("package prefer", 1, 1), }, - "pid": check_count("pid", 0, 1), - "pkg::create": check_count("pkg::create", 2, None), - "pkg_mkIndex": check_count("pkg_mkIndex", 1, None), - "proc": _proc, - "puts": { - "positionals": [ - {"name": "-nonewline", "value": {"type": "any"}, "required": False}, - {"name": "channelId", "value": {"type": "any"}, "required": False}, - {"name": "string", "value": {"type": "any"}, "required": True}, - ], + }, + "pid": check_count("pid", 0, 1), + "pkg::create": check_count("pkg::create", 2, None), + "pkg_mkIndex": check_count("pkg_mkIndex", 1, None), + "proc": _proc, + "puts": { + "positionals": [ + {"name": "-nonewline", "value": {"type": "any"}, "required": False}, + {"name": "channelId", "value": {"type": "any"}, "required": False}, + {"name": "string", "value": {"type": "any"}, "required": True}, + ], + }, + "pwd": check_count("pwd", 0, 0), + "read": check_count("read", 1, 2), + "regexp": check_count("regexp", 2, None), + "regsub": check_count("regsub", 3, None), + "rename": check_count("rename", 2, 2), + "return": _return, + # TODO: check subcommands + "safe": check_count("safe", 1, None), + "scan": check_count("scan", 2, None), + "seek": check_count("seek", 2, 3), + "set": check_count("set", 1, 2), + "socket": check_count("socket", 2, None), + "source": check_count("source", 1, 3), + "split": check_count("split", 1, 2), + # TODO: check subcommands + "string": check_count("string", 2, None), + "subst": check_count("subst", 1, 4), + "switch": _switch, + "tailcall": check_count("tailcall", 1, None), + "tcl::prefix": { + "subcommands": { + "all": check_count("tcl::prefix all", 2, 2), + "longest": check_count("tcl::prefix longest", 2, 2), + "match": check_count("tcl::prefix match", 2, None), }, - "pwd": check_count("pwd", 0, 0), - "read": check_count("read", 1, 2), - "regexp": check_count("regexp", 2, None), - "regsub": check_count("regsub", 3, None), - "rename": check_count("rename", 2, 2), - "return": _return, - # TODO: check subcommands - "safe": check_count("safe", 1, None), - "scan": check_count("scan", 2, None), - "seek": check_count("seek", 2, 3), - "set": check_count("set", 1, 2), - "socket": check_count("socket", 2, None), - "source": check_count("source", 1, 3), - "split": check_count("split", 1, 2), - # TODO: check subcommands - "string": check_count("string", 2, None), - "subst": check_count("subst", 1, 4), - "switch": _switch, - "tailcall": check_count("tailcall", 1, None), - "tcl::prefix": { - "subcommands": { - "all": check_count("tcl::prefix all", 2, 2), - "longest": check_count("tcl::prefix longest", 2, 2), - "match": check_count("tcl::prefix match", 2, None), - }, + }, + "tell": check_count("tell", 1, 1), + "throw": check_count("throw", 2, 2), + "time": _time, + "timerate": _timerate, + "tcl::tm::path": { + "subcommands": { + "add": check_count("tcl::tm::path add"), + "remove": check_count("tcl::tm::path remove"), + "list": check_count("tcl::tm::path list", 0, 0), }, - "tell": check_count("tell", 1, 1), - "throw": check_count("throw", 2, 2), - "time": _time, - "timerate": _timerate, - "tcl::tm::path": { - "subcommands": { - "add": check_count("tcl::tm::path add"), - "remove": check_count("tcl::tm::path remove"), - "list": check_count("tcl::tm::path list", 0, 0), - }, - }, - "tcl::tm::roots": check_count("tcl::tm::roots"), - # TODO: check subcommands - "trace": check_count("trace", 2, None), - "try": _try, - "unload": check_count("unload", 1, 6), - "unset": check_count("unset"), - "update": check_count("update", 0, 1), - "uplevel": check_count("uplevel", 1, None), - "upvar": check_count("upvar", 2, None), - "variable": check_count("variable", 1, None), - "vwait": check_count("vwait", 1, 1), - "while": _while, - "yield": { - "positionals": [ - {"name": "value", "value": {"type": "any"}, "required": False}, - ] - }, - "yieldto": { - "positionals": [ - {"name": "command", "value": {"type": "any"}, "required": True}, - {"name": "arg", "value": {"type": "variadic"}, "required": False}, - ] - }, - # TODO: check subcommands - "zlib": check_count("zlib", 3, None), - } -) + }, + "tcl::tm::roots": check_count("tcl::tm::roots"), + # TODO: check subcommands + "trace": check_count("trace", 2, None), + "try": _try, + "unload": check_count("unload", 1, 6), + "unset": check_count("unset"), + "update": check_count("update", 0, 1), + "uplevel": check_count("uplevel", 1, None), + "upvar": check_count("upvar", 2, None), + "variable": check_count("variable", 1, None), + "vwait": check_count("vwait", 1, 1), + "while": _while, + "yield": { + "positionals": [ + {"name": "value", "value": {"type": "any"}, "required": False}, + ] + }, + "yieldto": { + "positionals": [ + {"name": "command", "value": {"type": "any"}, "required": True}, + {"name": "arg", "value": {"type": "variadic"}, "required": False}, + ] + }, + # TODO: check subcommands + "zlib": check_count("zlib", 3, None), +}) diff --git a/server/src/tools/commands/checks.py b/server/libs/tclint/commands/checks.py similarity index 99% rename from server/src/tools/commands/checks.py rename to server/libs/tclint/commands/checks.py index 558bb3d..911f58e 100644 --- a/server/src/tools/commands/checks.py +++ b/server/libs/tclint/commands/checks.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import List, Optional, Union -from tools.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node +from tclint.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node class CommandArgError(Exception): diff --git a/server/libs/tclint/commands/plugins.py b/server/libs/tclint/commands/plugins.py new file mode 100644 index 0000000..276a7d4 --- /dev/null +++ b/server/libs/tclint/commands/plugins.py @@ -0,0 +1,86 @@ +from importlib_metadata import entry_points +import json +import pathlib +from typing import Dict, Optional +from types import ModuleType + +import voluptuous + +from tclint.commands.schema import schema as command_schema + + +class _PluginManager: + def __init__(self): + self._loaded = {} + self._installed = {} + self._loaded_specs = {} + for plugin in entry_points(group="tclint.plugins"): + if plugin.name in self._installed: + print(f"Warning: found duplicate definitions for plugin {plugin.name}") + self._installed[plugin.name] = plugin + + def load(self, name: str) -> Optional[Dict]: + if name in self._loaded: + return self._loaded[name] + + mod = self._load(name) + self._loaded[name] = mod + return mod + + def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + if path in self._loaded_specs: + return self._loaded_specs[path] + + spec = self._load_from_spec(path) + self._loaded_specs[path] = spec + return spec + + def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]: + try: + with open(path.expanduser(), "r") as f: + spec = json.load(f) + except (FileNotFoundError, RuntimeError): + print(f"Warning: command spec {path} not found, skipping...") + return None + + try: + # Apply defaults and validate the spec. + spec = command_schema(spec) + except voluptuous.Invalid as e: + print(f"Warning: invalid command spec {path}: {e}") + return None + + return spec["commands"] + + def get_mod(self, name: str) -> Optional[ModuleType]: + if name not in self._installed: + print(f"Warning: plugin {name} is not installed") + return None + + plugin = self._installed[name] + + try: + module = plugin.load() + except Exception as e: + print(f"Warning: error loading plugin {name}: {e}") + return None + + return module + + def _load(self, name: str): + module = self.get_mod(name) + if module is None: + print(f"Skipping requested plugin {name}") + return None + + if not hasattr(module, "commands"): + print(f"Warning: skipping plugin {name} since it does not define commands") + return None + + return getattr(module, "commands") + + +# TODO: we'll probably want to construct this in the tclint entry point and pass +# it around rather than using a singleton instance, but this made for an easier +# refactor. +PluginManager = _PluginManager() diff --git a/server/src/tools/commands/schema.py b/server/libs/tclint/commands/schema.py similarity index 100% rename from server/src/tools/commands/schema.py rename to server/libs/tclint/commands/schema.py diff --git a/server/libs/tclint/comments.py b/server/libs/tclint/comments.py new file mode 100644 index 0000000..d7b878d --- /dev/null +++ b/server/libs/tclint/comments.py @@ -0,0 +1,91 @@ +from collections import defaultdict + +from tclint.syntax_tree import Visitor +from tclint.violations import ALL_RULES, Rule + + +class CommentVisitor(Visitor): + """Scans the tree for lint waiver comments.""" + + def __init__(self): + # line -> [rule] + self.ignore_lines = defaultdict(set) + + self._disable_regions = { + # rule -> line + } + + def run(self, tree, path): + self._path = path + tree.accept(self, recurse=True) + + # resolve remaining disabled regions + last_line = tree.end_pos[0] + for rule, start_line in self._disable_regions.items(): + for line in range(start_line, last_line + 1): + self.ignore_lines[line].add(rule) + + return self.ignore_lines + + def visit_comment(self, comment): + contents = comment.value.strip() + + if not contents.startswith("tclint-"): + return + + split = contents.split(" ", 1) + + command = split[0] + + rule_strs = [] + if len(split) > 1: + rest = split[-1] + rule_strs = rest.split("--", 1)[0] + rule_strs = rule_strs.replace(" ", "") + rule_strs = rule_strs.split(",") + + rules = [] + if not rule_strs: + # default if no rules specified is all violation types + rules = ALL_RULES + else: + for rule in rule_strs: + try: + rules.append(Rule(rule)) + except ValueError: + self._warning( + f"unknown rule '{rule}' provided to '{command}'", comment.pos + ) + + if command == "tclint-disable": + for rule in rules: + # if in dictionary, already disabled - this has no effect + if rule not in self._disable_regions: + self._disable_regions[rule] = comment.line + elif command == "tclint-disable-line": + line = comment.line + self.ignore_lines[line].update(rules) + elif command == "tclint-disable-next-line": + line = comment.line + 1 + self.ignore_lines[line].update(rules) + elif command == "tclint-enable": + for rule in rules: + if rule in self._disable_regions: + disable_start_line = self._disable_regions[rule] + disable_end_line = comment.line + + for line in range(disable_start_line, disable_end_line + 1): + self.ignore_lines[line].add(rule) + + del self._disable_regions[rule] + else: + self._warning( + f"comment starts with '{command}', which looks like a tclint keyword." + " Is this a typo?", + comment.pos, + ) + + def _warning(self, message, pos): + # TODO: formal warning mechanism + prefix = self._path if self._path is not None else "(stdin)" + print(f"Warning: {prefix}:{pos[0]}:{pos[1]}: {message}") diff --git a/server/libs/tclint/config.py b/server/libs/tclint/config.py new file mode 100644 index 0000000..f8be7bc --- /dev/null +++ b/server/libs/tclint/config.py @@ -0,0 +1,427 @@ +import argparse +import pathlib +from typing import Union, List +from typing import Optional as OptionalType +import dataclasses +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from voluptuous import Schema, Optional, And, Coerce, Invalid, Range + +from tclint.violations import Rule + + +@dataclasses.dataclass +class Config: + """This dataclass defines the supported Config fields and their default + values. It provides an external interface for accessing config values. + + The type annotations defined here are fairly loose - more specific type + validation (and normalization) is defined by `validators` below. + """ + + exclude: List[str] = dataclasses.field(default_factory=list) + ignore: List[Rule] = dataclasses.field(default_factory=list) + commands: OptionalType[pathlib.Path] = dataclasses.field(default=None) + extensions: List[str] = dataclasses.field( + default_factory=lambda: ["tcl", "sdc", "xdc", "upf"] + ) + style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None) + style_line_length: int = dataclasses.field(default=100) + style_max_blank_lines: int = dataclasses.field(default=2) + style_indent_namespace_eval: bool = dataclasses.field(default=True) + style_spaces_in_braces: bool = dataclasses.field(default=False) + + def apply_cli_args(self, args): + args_dict = vars(args) + for field in dataclasses.fields(self): + if field.name in args_dict and args_dict[field.name] is not None: + setattr(self, field.name, args_dict[field.name]) + + # Special arguments that aren't handled automatically + if "extend_exclude" in args_dict and args_dict["extend_exclude"] is not None: + self.exclude.extend(args_dict["extend_exclude"]) + + if "extend_ignore" in args_dict and args_dict["extend_ignore"] is not None: + self.ignore.extend(args_dict["extend_ignore"]) + + def get_indent(self) -> str: + """Get indent setting as string. + + This helper does two things. One, it's a helpful utility to factor out the logic + required for calculating the indent. Two, it lets us ergonomically store if the + indentation is not set in style_indent, which the LSP relies on. + """ + if self.style_indent is None: + # Default indent + return " " * 4 + elif self.style_indent == "tab": + return "\t" + elif isinstance(self.style_indent, int): + return " " * self.style_indent + + # Should be unreachable, validated on ingestion of config + raise ValueError( + f"unexpected value for config.style_indent: {self.style_indent}" + ) + + +# Validators using `voluptuous` library that check and normalize config inputs. +# Used for checking both config files as well as config-related CLI args. + +# Using these for CLI args adds a constraint that all non-boolean validators +# need to be able to normalize a value from a string. This means one could put +# e.g. a string representation of a list into a .toml config file, but we shouldn't +# document this, since it won't be considered stable behavior. + + +def _str2list(s): + """Handles string-to-list normalization.""" + if isinstance(s, str): + if s == "": + return [] + return [v.strip() for v in s.split(",")] + return s + + +_VALIDATORS = { + # note: it's ok if paths don't exist - allows for generic + # configurations with directories like .git/ excluded + "exclude": _str2list, + "ignore": And( + _str2list, + [ + Coerce(Rule, msg="invalid rule ID"), + ], + ), + "commands": Coerce(pathlib.Path), + "extensions": _str2list, + "style_indent": Coerce( + lambda v: v if v == "tab" else int(v), msg="expected integer or 'tab'" + ), + "style_line_length": Coerce(int), + "style_max_blank_lines": And( + Coerce(int), + # we could technically support i >= 0, but I think 0 would be a weird + # setting and this lets us ignore pluralizing the violation message :) + Range(min=1), + ), + "style_indent_namespace_eval": bool, + "style_spaces_in_braces": bool, +} + + +def _validate_config(config): + """Validates dictionary read from TOML config file. Individual value validators + are implemented in the global dict, this defines the actual structure of the + schema.""" + + base_config = { + Optional("ignore"): _VALIDATORS["ignore"], + Optional("commands"): _VALIDATORS["commands"], + Optional("style"): { + Optional("indent"): _VALIDATORS["style_indent"], + Optional("line-length"): _VALIDATORS["style_line_length"], + Optional("max-blank-lines"): _VALIDATORS["style_max_blank_lines"], + Optional("indent-namespace-eval"): _VALIDATORS[ + "style_indent_namespace_eval" + ], + Optional("spaces-in-braces"): _VALIDATORS["style_spaces_in_braces"], + }, + } + + schema = Schema({ + # exclude and extensions can only be used in global context + Optional("exclude"): _VALIDATORS["exclude"], + Optional("extensions"): _VALIDATORS["extensions"], + **base_config, + Optional("fileset"): Schema( + [{"paths": [Coerce(pathlib.Path)], **base_config}], required=True + ), + }) + + try: + return schema(config) + except Invalid as e: + if not e.path: + raise ConfigError(e.error_message) + + # Stringify error path to my own taste. + path = [] + for item in e.path: + if isinstance(item, int): + # Brackets around indices + if len(path) > 0: + path[-1] += f"[{item}]" + else: + path.append(f"[{item}]") + else: + path.append(str(item)) + + raise ConfigError(f"{e.error_message} ({'.'.join(path)})") + + +def _validator(key): + def func(s): + try: + return Schema(_VALIDATORS[key])(s) + except Invalid as e: + raise argparse.ArgumentTypeError(str(e)) + + return func + + +def _add_bool(group, parser, dest, yes_flag, no_flag): + mutex_group = group.add_mutually_exclusive_group(required=False) + mutex_group.add_argument(yes_flag, dest=dest, action="store_true") + mutex_group.add_argument(no_flag, dest=dest, action="store_false") + parser.set_defaults(**{dest: None}) + + +def setup_common_config_cli_args(config_group): + config_group.add_argument( + "--exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' + ) + config_group.add_argument( + "--extend-exclude", type=_validator("exclude"), metavar='"path1, path2, ..."' + ) + config_group.add_argument( + "--extensions", type=_validator("extensions"), metavar='"tcl, xdc, ..."' + ) + config_group.add_argument( + "--commands", type=_validator("commands"), metavar="" + ) + + +def setup_config_cli_args(parser): + """This method defines config-related CLI arguments. + + The destvars of these switches should match the fields of Config. + """ + config_group = parser.add_argument_group("configuration arguments") + + config_group.add_argument( + "--ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' + ) + config_group.add_argument( + "--extend-ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."' + ) + setup_common_config_cli_args(config_group) + config_group.add_argument( + "--style-line-length", + type=_validator("style_line_length"), + metavar="", + ) + + +def setup_tclfmt_config_cli_args(parser): + """This method defines the subset of config-related CLI arguments used by tclfmt. + + The destvars of these switches should match the fields of Config. + """ + config_group = parser.add_argument_group("configuration arguments") + + setup_common_config_cli_args(config_group) + + config_group.add_argument( + "--indent", + type=_validator("style_indent"), + metavar="", + dest="style_indent", + ) + config_group.add_argument( + "--max-blank-lines", + type=_validator("style_max_blank_lines"), + metavar="", + dest="style_max_blank_lines", + ) + _add_bool( + config_group, + parser, + "style_indent_namespace_eval", + "--indent-namespace-eval", + "--no-indent-namespace-eval", + ) + _add_bool( + config_group, + parser, + "style_spaces_in_braces", + "--spaces-in-braces", + "--no-spaces-in-braces", + ) + + +def _flatten(d, prefix=None): + """Flattens TOML config dictionary structure to match the flat set of fields + expected by Config dataclass.""" + if prefix is None: + prefix = [] + + flat = {} + for k, v in d.items(): + if isinstance(v, dict): + flat.update(_flatten(v, prefix=prefix + [k])) + else: + flat["_".join(prefix + [k]).replace("-", "_")] = v + + return flat + + +class RunConfig: + """Class that holds information about both global and fileset configs. User + code can get a Config object that applies to a particular file by calling + get_from_path() and supplying that file's path.""" + + def __init__(self, global_config=None, fileset_configs=None): + if global_config is not None: + self._global_config = global_config + else: + self._global_config = Config() + + self._fileset_configs = [ + # ([pathlib.Path...], Config]) + ] + if fileset_configs is not None: + self._fileset_configs = fileset_configs + + @property + def exclude(self): + return self._global_config.exclude + + @property + def extensions(self): + return self._global_config.extensions + + @classmethod + def from_dict(cls, config_dict: dict, root: pathlib.Path): + config_dict = _validate_config(config_dict) + try: + fileset_config_dicts = config_dict.pop("fileset") + except KeyError: + fileset_config_dicts = [] + + config_dict = _flatten(config_dict) + global_config = Config(**config_dict) + + fileset_configs = [] + for fileset_config in fileset_config_dicts: + paths = [] + for path in fileset_config.pop("paths"): + if not path.is_absolute(): + path = root / path + paths.append(path.resolve()) + + fileset_config = _flatten(fileset_config) + + # pull in default values from global config + full_fileset_config = config_dict.copy() + full_fileset_config.update(fileset_config) + + fileset_configs.append((paths, Config(**full_fileset_config))) + + return cls(global_config, fileset_configs) + + @classmethod + def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path): + path = pathlib.Path(path) + + if not path.exists(): + raise FileNotFoundError + + with open(path, "rb") as f: + try: + data = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"{path}: {e}") + + try: + return cls.from_dict(data, root) + except ConfigError as e: + raise ConfigError(f"{path}: {e}") + + @classmethod + def from_pyproject(cls, directory=None): + if directory is None: + directory = pathlib.Path(".") + else: + directory = pathlib.Path(directory) + + path = directory / "pyproject.toml" + + if not path.exists(): + raise FileNotFoundError + + with open(path, "rb") as f: + data = tomllib.load(f) + + tclint_config = data.get("tool", {})["tclint"] + + try: + return cls.from_dict(tclint_config, directory) + except ConfigError as e: + raise ConfigError(f"pyproject.toml: {e}") + + def get_for_path(self, path) -> Config: + if path is None: + return self._global_config + + path = path.resolve() + for fileset_paths, config in self._fileset_configs: + for fileset_path in fileset_paths: + if path.is_relative_to(fileset_path): + return config + + return self._global_config + + def apply_cli_args(self, args): + self._global_config.apply_cli_args(args) + for _, fileset_config in self._fileset_configs: + fileset_config.apply_cli_args(args) + + +class ConfigError(Exception): + pass + + +DEFAULT_CONFIGS = ("tclint.toml", ".tclint") + + +def get_config( + config_path: OptionalType[pathlib.Path], root: pathlib.Path +) -> OptionalType[RunConfig]: + """Loads a config file. + + If `config_path` is supplied, attempts to read config file from this path. If the + path can't be found, raises a ConfigError. + + Otherwise, attempts to read config from `root`/{tclint.toml, .tclint, + pyproject.toml} (in that order). If none of these files can be found, returns None. + + `root` is also used to resolve some relative paths in the config file. + """ + # user-supplied + if config_path is not None: + try: + return RunConfig.from_path(config_path, root) + except FileNotFoundError: + raise ConfigError(f"path {config_path} doesn't exist") + + for path in DEFAULT_CONFIGS: + try: + return RunConfig.from_path(root / path, root) + except FileNotFoundError: + pass + + try: + return RunConfig.from_pyproject(directory=root) + except ConfigError as e: + raise e + except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError): + # just skip if file doesn't exist, contains TOML errors, or tclint key not found + pass + + return None diff --git a/server/libs/tclint/format.py b/server/libs/tclint/format.py new file mode 100644 index 0000000..cb01b7a --- /dev/null +++ b/server/libs/tclint/format.py @@ -0,0 +1,480 @@ +import dataclasses +import itertools +import textwrap +from typing import List, Tuple, Union +import sys + +from tclint.syntax_tree import ( + Node, + Script, + Command, + Comment, + CommandSub, + BareWord, + QuotedWord, + BracedWord, + CompoundBareWord, + VarSub, + ArgExpansion, + Expression, + BracedExpression, + ParenExpression, + UnaryOp, + BinaryOp, + TernaryOp, + Function, +) +from tclint.parser import Parser +from tclint.syntax_tree import List as ListNode + + +@dataclasses.dataclass +class LiteralBlock: + block: List[str] + pos: Tuple[int, int] + end_pos: Tuple[int, int] + + +@dataclasses.dataclass +class FormatterOpts: + indent: str + spaces_in_braces: bool + max_blank_lines: int + indent_namespace_eval: bool + + +class Formatter: + def __init__(self, opts: FormatterOpts): + self.opts = opts + + def _indent(self, lines: List[str], indent: str) -> List[str]: + indented = [] + for line in lines: + if line == "": + indented.append("") + else: + indented.append(indent + line) + + return indented + + def _brace(self, lines: List[str]) -> List[str]: + spaces_in_braces = " " if self.opts.spaces_in_braces else "" + if lines == [""]: + return ["{" + spaces_in_braces + "}"] + + braced_lines = lines[:] + braced_lines[0] = "{" + spaces_in_braces + lines[0] + braced_lines[-1] += spaces_in_braces + "}" + return braced_lines + + def format(self, *nodes: Union[Node, LiteralBlock]) -> List[str]: + formatted = [] + for node in nodes: + if isinstance(node, Script): + formatted += self.format_script(node) + elif isinstance(node, Command): + formatted += self.format_command(node) + elif isinstance(node, Comment): + formatted += self.format_comment(node) + elif isinstance(node, CommandSub): + formatted += self.format_command_sub(node) + elif isinstance(node, BareWord): + formatted += self.format_bare_word(node) + elif isinstance(node, QuotedWord): + formatted += self.format_quoted_word(node) + elif isinstance(node, BracedWord): + formatted += self.format_braced_word(node) + elif isinstance(node, CompoundBareWord): + formatted += self.format_compound_bare_word(node) + elif isinstance(node, VarSub): + formatted += self.format_var_sub(node) + elif isinstance(node, ArgExpansion): + formatted += self.format_arg_expansion(node) + elif isinstance(node, ListNode): + formatted += self.format_list(node) + elif isinstance(node, Expression): + formatted += self.format_expression(node) + elif isinstance(node, BracedExpression): + formatted += self.format_braced_expression(node) + elif isinstance(node, ParenExpression): + formatted += self.format_paren_expression(node) + elif isinstance(node, UnaryOp): + formatted += self.format_unary_op(node) + elif isinstance(node, BinaryOp): + formatted += self.format_binary_op(node) + elif isinstance(node, TernaryOp): + formatted += self.format_ternary_op(node) + elif isinstance(node, Function): + formatted += self.format_function(node) + elif isinstance(node, LiteralBlock): + formatted += node.block + else: + assert False, f"unrecognized node: {type(node)}" + + return formatted + + def format_top(self, script: str, parser: Parser) -> str: + tree = parser.parse(script) + self.script = script.split("\n") + return "\n".join(self.format_script_contents(tree)) + "\n" + + def format_partial(self, script: str, parser: Parser) -> str: + """Formats a partial Tcl script. + + This function formats a partial script according to the gofmt partial formatting + rules, "[preserving] leading indentation as well as leading and trailing spaces" + (ref: https://pkg.go.dev/cmd/gofmt#pkg-overview). Unlike Go, we have no way of + detecting if a given script is a program fragment, hence the distinct method + from `format_top` . + """ + leading = "".join(itertools.takewhile(str.isspace, script)) + try: + leading, indent = leading.rsplit("\n", 1) + leading += "\n" + except ValueError: + leading, indent = "", leading + trailing = "".join(itertools.takewhile(str.isspace, reversed(script)))[::-1] + + script = script.strip() + tree = parser.parse(script) + self.script = script.split("\n") + + formatted = "\n".join(self.format_script_contents(tree)) + + return leading + textwrap.indent(formatted, indent) + trailing + + def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]: + to_format = [] + skip_formatting_start = None + for child in script.children: + if skip_formatting_start is None: + to_format.append(child) + + if isinstance(child, Comment): + if child.value.strip() == "tclfmt-disable": + if skip_formatting_start is not None: + print( + "Warning: encountered 'tclint-disable' while formatting is" + " already disabled, ignoring...", + file=sys.stderr, + ) + else: + skip_formatting_start = child.pos[0] + elif child.value.strip() == "tclfmt-enable": + if skip_formatting_start is None: + print( + "Warning: encountered 'tclint-enable' while formatting is" + " already disabled, ignoring...", + file=sys.stderr, + ) + else: + skip_formatting_end = child.pos[0] + block = self.script[skip_formatting_start:skip_formatting_end] + to_format.append( + LiteralBlock( + block, + pos=(skip_formatting_start + 1, 1), + end_pos=(skip_formatting_end, 1), + ) + ) + skip_formatting_start = None + + if skip_formatting_start is not None: + print("Warning: missing 'tclint-enable'", file=sys.stderr) + to_format.append( + LiteralBlock( + self.script[skip_formatting_start:], + pos=(skip_formatting_start + 1, 1), + end_pos=script.end_pos, + ) + ) + + formatted = [""] + last_line = None + for child in to_format: + if last_line is not None: + if last_line == child.pos[0]: + if isinstance(child, Comment): + formatted[-1] += " ;" + else: + formatted[-1] += "; " + else: + newlines = child.pos[0] - last_line + newlines = min(newlines, self.opts.max_blank_lines + 1) + formatted.extend([""] * newlines) + last_line = child.end_pos[0] + + lines = self.format(child) + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + return formatted + + def format_script(self, script: Script, should_indent=True) -> List[str]: + lines = self.format_script_contents(script) + if script.pos[0] == script.end_pos[0]: + return self._brace(lines) + + # Usually, we enforce that multi-line scripts start on a new line after the open + # brace. However, if a comment was originally on the same line as the open brace + # we preserve it, since it's probably meant to be associated with this line + # (e.g. a tclint-disable-line). + open_brace = "{" + if ( + len(script.children) > 0 + and isinstance(script.children[0], Comment) + and script.pos[0] == script.children[0].pos[0] + ): + open_brace += " " + lines[0] + lines = lines[1:] + + if should_indent: + return [open_brace] + self._indent(lines, self.opts.indent) + ["}"] + else: + return [open_brace] + lines + ["}"] + + def format_command(self, command: Command) -> List[str]: + is_namespace_eval = ( + command.routine.contents == "namespace" + and len(command.args) > 0 + and command.args[0].contents == "eval" + ) + should_indent = not is_namespace_eval or self.opts.indent_namespace_eval + + hanging_indent = False + formatted = self.format(command.routine) + last_line = command.routine.end_pos[0] + for child in command.args: + if isinstance(child, Script): + child_lines = self.format_script(child, should_indent=should_indent) + else: + child_lines = self.format(child) + + if last_line == child.pos[0]: + formatted[-1] += " " + formatted[-1] += child_lines[0] + else: + formatted[-1] += " \\" + formatted.append(self.opts.indent + child_lines[0]) + hanging_indent = True + + if hanging_indent: + formatted.extend(self._indent(child_lines[1:], self.opts.indent)) + else: + formatted.extend(child_lines[1:]) + + last_line = child.end_pos[0] + + return formatted + + def format_comment(self, comment: Comment) -> List[str]: + return [f"#{comment.value}"] + + def format_command_sub(self, command_sub): + if len(command_sub.children) == 0: + return ["[]"] + + formatted = [] + contents = self.format_script_contents(command_sub) + if len(command_sub.children) > 1 and len(contents) > 1: + formatted.append("[") + formatted.extend(self._indent(contents, self.opts.indent)) + formatted.append("]") + else: + formatted.append("[" + contents[0]) + formatted.extend(contents[1:]) + formatted[-1] += "]" + + return formatted + + def format_bare_word(self, word) -> List[str]: + # Property enforced by parser + assert word.contents is not None + return [word.contents] + + def format_quoted_word(self, word) -> List[str]: + if word.contents is not None: + return [f'"{word.contents}"'] + + formatted = "" + for child in word.children: + formatted += "\n".join(self.format(child)) + + return [f'"{formatted}"'] + + def format_braced_word(self, word) -> List[str]: + assert word.contents is not None + return [f"{{{word.contents}}}"] + + def format_compound_bare_word(self, word) -> List[str]: + formatted = [""] + for child in word.children: + child_lines = self.format(child) + formatted[-1] += child_lines[0] + formatted.extend(child_lines[1:]) + + return formatted + + def format_var_sub(self, varsub) -> List[str]: + # We might be able to make the formatter infer whether braces are required, and + # remove them from the syntax tree. For now it's easier to just mimic the + # original format. + if varsub.braced: + formatted = [f"${{{varsub.value}}}"] + else: + formatted = [f"${varsub.value}"] + + if varsub.children: + # We just concatenate everything as is, since changes in whitespace are + # semantically meaningful in this context. Any newlines are captured by + # BareWords. + formatted[-1] += "(" + for child in varsub.children: + child_lines = self.format(child) + formatted[-1] += child_lines[0] + formatted.extend(child_lines[1:]) + formatted[-1] += ")" + + return formatted + + def format_arg_expansion(self, arg_expansion) -> List[str]: + lines = self.format(arg_expansion.list) + lines[0] = "{*}" + lines[0] + + return lines + + def format_list(self, list_node) -> List[str]: + # Similar to Script, but the contents are a bit more straightforward. + contents = [""] + last_line = None + for child in list_node.children: + if last_line is not None: + if last_line == child.pos[0]: + contents[-1] += " " + else: + newlines = child.pos[0] - last_line + newlines = min(newlines, 3) + contents.extend([""] * newlines) + + lines = self.format(child) + contents[-1] += lines[0] + contents.extend(lines[1:]) + + last_line = child.end_pos[0] + + if list_node.pos[0] == list_node.end_pos[0]: + return self._brace(contents) + + return ["{"] + self._indent(contents, self.opts.indent) + ["}"] + + def format_expression(self, expr) -> List[str]: + formatted = [""] + for child in expr.children: + lines = self.format(child) + formatted[-1] += lines[0] + for line in lines[1:]: + formatted[-1] += " \\" + formatted += self._indent([line], self.opts.indent) + + # Trick: we know there are quotes around the expression if the start of the + # expression is a different column than its first child. + quoted = expr.pos[1] != expr.children[0].pos[1] + if quoted: + formatted[0] = '"' + formatted[0] + formatted[-1] += '"' + + return formatted + + def format_braced_expression(self, expr) -> List[str]: + formatted = [""] + for child in expr.children: + lines = self.format(child) + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + if expr.pos[0] == expr.end_pos[0]: + return self._brace(formatted) + + return ["{"] + self._indent(formatted, self.opts.indent) + ["}"] + + def format_paren_expression(self, expr) -> List[str]: + body = expr.body + + formatted = ["("] + lines = self.format(body) + if expr.pos[0] != body.pos[0]: + formatted.extend(lines) + else: + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + + formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) + + if expr.end_pos[0] != body.end_pos[0]: + formatted.append(")") + else: + formatted[-1] += ")" + + return formatted + + def format_unary_op(self, expr): + op = self.format(expr.operator) + assert len(op) == 1 + + lines = self.format(expr.operand) + lines[0] = op[0] + lines[0] + return lines + + def _format_op(self, expr) -> List[str]: + nodes = expr.children + formatted = self.format(nodes[0]) + + last = nodes[0] + for next in nodes[1:]: + lines = self.format(next) + if last.end_pos[0] != next.pos[0]: + formatted.extend(lines) + else: + formatted[-1] += " " + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + last = next + + return formatted + + def format_binary_op(self, expr) -> List[str]: + return self._format_op(expr) + + def format_ternary_op(self, expr) -> List[str]: + return self._format_op(expr) + + def format_function(self, function): + name = self.format(function.name) + assert len(name) == 1 + name = name[0] + + formatted = [f"{name}("] + + last = function.name + for i, child in enumerate(function.args): + if i > 0: + formatted[-1] += "," + lines = self.format(child) + if last.end_pos[0] != child.pos[0]: + formatted.extend(lines) + else: + if i > 0: + formatted[-1] += " " + formatted[-1] += lines[0] + formatted.extend(lines[1:]) + last = child + + # indent any continuation lines, but we leave the closing paren dedented + formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent) + + if last.end_pos[0] != function.end_pos[0]: + formatted.append(")") + else: + formatted[-1] += ")" + + return formatted diff --git a/server/src/tools/lexer.py b/server/libs/tclint/lexer.py similarity index 81% rename from server/src/tools/lexer.py rename to server/libs/tclint/lexer.py index dcea50e..dc18312 100644 --- a/server/src/tools/lexer.py +++ b/server/libs/tclint/lexer.py @@ -1,32 +1,27 @@ -from enum import Enum import ply.lex as lex from typing import Tuple - -class Tok(str, Enum): - TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" - TOK_BACKSLASH_SUB = "BACKSLASH_SUB" - TOK_NEWLINE = "NEWLINE" - TOK_SEMI = "SEMI" - TOK_WS = "WS" - TOK_QUOTE = "QUOTE" - TOK_ARG_EXPANSION = "ARG_EXPANSION" - TOK_LBRACE = "LBRACE" - TOK_RBRACE = "RBRACE" - TOK_STAR = "STAR" - TOK_LBRACKET = "LBRACKET" - TOK_RBRACKET = "RBRACKET" - TOK_DOLLAR = "DOLLAR" - TOK_LPAREN = "LPAREN" - TOK_RPAREN = "RPAREN" - TOK_HASH = "HASH" - TOK_ALPHA_CHARS = "ALPHA_CHARS" - TOK_NUM_CHARS = "NUM_CHARS" - TOK_NAMESPACE_SEP = "NAMESPACE_SEP" - TOK_CHAR = "CHAR" - TOK_CONTENTS = "CONTENTS" - - +TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" +TOK_BACKSLASH_SUB = "BACKSLASH_SUB" +TOK_NEWLINE = "NEWLINE" +TOK_SEMI = "SEMI" +TOK_WS = "WS" +TOK_QUOTE = "QUOTE" +TOK_ARG_EXPANSION = "ARG_EXPANSION" +TOK_LBRACE = "LBRACE" +TOK_RBRACE = "RBRACE" +TOK_STAR = "STAR" +TOK_LBRACKET = "LBRACKET" +TOK_RBRACKET = "RBRACKET" +TOK_DOLLAR = "DOLLAR" +TOK_LPAREN = "LPAREN" +TOK_RPAREN = "RPAREN" +TOK_HASH = "HASH" +TOK_ALPHA_CHARS = "ALPHA_CHARS" +TOK_NUM_CHARS = "NUM_CHARS" +TOK_NAMESPACE_SEP = "NAMESPACE_SEP" +TOK_CHAR = "CHAR" +TOK_CONTENTS = "CONTENTS" TOK_EOF = None STATE_BRACEDWORD = "bracedword" @@ -40,7 +35,30 @@ class TclSyntaxError(Exception): class _LexTable: - tokens = tuple(t.value for t in Tok) + tokens = ( + TOK_BACKSLASH_NEWLINE, + TOK_BACKSLASH_SUB, + TOK_NEWLINE, + TOK_SEMI, + TOK_WS, + TOK_QUOTE, + TOK_ARG_EXPANSION, + TOK_LBRACE, + TOK_RBRACE, + TOK_STAR, + TOK_LBRACKET, + TOK_RBRACKET, + TOK_DOLLAR, + TOK_LPAREN, + TOK_RPAREN, + TOK_HASH, + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_NAMESPACE_SEP, + TOK_CHAR, + TOK_CONTENTS, + ) + # This defines a conditional lexing state for parsing braced words. This is a # performance optimization; since there are few special characters in this context, # we can use a smaller set of tokens to parse them faster. This has a large impact @@ -223,23 +241,3 @@ class Lexer: def assert_(self, *tokens): assert self.current.type in tokens self.next() - - -def main(): - code = 'puts "Hello, World!"\nset x 42\n' - lexer = Lexer() - lexer.input(code) - - print("Lexing input:\n", code) - print("\nTokens:\n" + "-" * 30) - - while lexer.type() is not None: - tok_type = lexer.type() - tok_value = lexer.value() - tok_pos = lexer.pos() - print(f"Type: {tok_type:20} | Value: {repr(tok_value):20} | Pos: {tok_pos}") - lexer.next() - - -if __name__ == "__main__": - main() diff --git a/server/libs/tclint/parser.py b/server/libs/tclint/parser.py new file mode 100644 index 0000000..b429838 --- /dev/null +++ b/server/libs/tclint/parser.py @@ -0,0 +1,900 @@ +import string +import re + +from tclint.lexer import ( + Lexer, + TclSyntaxError, + STATE_BRACEDWORD, + TOK_BACKSLASH_NEWLINE, + TOK_NEWLINE, + TOK_SEMI, + TOK_WS, + TOK_QUOTE, + TOK_ARG_EXPANSION, + TOK_LBRACE, + TOK_RBRACE, + TOK_LBRACKET, + TOK_RBRACKET, + TOK_DOLLAR, + TOK_LPAREN, + TOK_RPAREN, + TOK_HASH, + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_NAMESPACE_SEP, + TOK_EOF, +) +from tclint.syntax_tree import ( + Script, + Comment, + Command, + CommandSub, + ArgExpansion, + VarSub, + BareWord, + BracedWord, + QuotedWord, + CompoundBareWord, + List, + Expression, + BracedExpression, + ParenExpression, + UnaryOp, + BinaryOp, + TernaryOp, + Function, +) +from tclint.commands import CommandArgError, get_commands +from tclint.commands.checks import check_command +from tclint.violations import Rule, Violation + + +def _strip_ws(parse_func): + """Decorator used by expression parser for stripping whitespace around a node.""" + + def func(parser, ts): + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: + ts.next() + + node = parse_func(parser, ts) + + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE}: + ts.next() + + return node + + return func + + +class _Word: + """Helper class for constructing Word nodes out of multiple segments.""" + + def __init__(self): + self.segments = [] + self.current_segment = "" + self.current_start = None + + def add_tok(self, tok): + if self.current_start is None: + self.current_start = tok.value[1] + self.current_segment += tok.value[0] + + def add_node(self, node): + if self.current_segment != "": + self.segments.append( + BareWord(self.current_segment, pos=self.current_start, end_pos=node.pos) + ) + self.current_segment = "" + self.current_start = None + self.segments.append(node) + + def resolve(self, end_pos): + if self.current_segment: + self.segments.append( + BareWord(self.current_segment, pos=self.current_start, end_pos=end_pos) + ) + + return self.segments + + +class Parser: + def __init__(self, debug=False, command_plugins=None): + self._debug = debug + self._debug_indent = 0 + # TODO: better way to handle this? + self.violations = [] + + if command_plugins is None: + command_plugins = [] + self._commands = get_commands(command_plugins) + + def debug(self, *msg): + if self._debug: + print(" " * self._debug_indent, end="") + print(*msg) + + def parse(self, script, pos=None): + lexer = Lexer(pos=pos) + lexer.input(script) + tree = self._parse_script(lexer, in_command_sub=False) + assert ( + lexer.type() == TOK_EOF + ), "Didn't reach EOF parsing script, please file a bug report." + + return tree + + def _parse_command_args(self, routine, args): + """Since many built-in Tcl commands take in Tcl scripts or expressions + as arguments, building a complete parse tree requires checking command + names and possibly parsing their arguments. + + The node of any argument that gets parsed by this method is replaced with + the parse tree of that argument. Since this process requires checking + the arguments provided to these commands, this method may report lint + violations. + + This parsing process is analogous to how the Tcl interpreter interprets + scripts, and better handles weird edge cases compared to a traditional + parsing technique. For example, this may look like valid Tcl: + + proc foo {a} { + # output } + puts "}" + } + + But really, it is invalid since the } in the comment terminates the body + of the proc - Tcl blindly constructs the body of the proc until it + reaches the first }. tclint handles this correctly. + """ + if routine not in self._commands: + return args + + spec = self._commands[routine] + + try: + new_args = check_command(routine, args, self, spec) + except TclSyntaxError as e: + raise e + except CommandArgError as e: + raise e + except Exception: + if self._debug: + raise + raise CommandArgError( + f"error parsing command arguments, possibly malformed {routine} command" + ) + + if new_args is None: + return args + + return new_args + + def parse_script(self, node): + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as script" + ) + + script = self.parse(node.contents, pos=node.contents_pos) + if isinstance(node, BracedWord): + script.braced = True + + script.line = node.line + script.col = node.col + script.end_pos = node.end_pos + + return script + + def _parse_script(self, ts, in_command_sub): + self.debug(f"parse_script({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + if in_command_sub: + script = CommandSub(pos=pos) + else: + script = Script(pos=pos) + + while ts.type() is not TOK_EOF: + if ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + # strip whitespace at start of command + ts.next() + continue + + if ts.type() == TOK_HASH: + script.add(self.parse_comment(ts)) + else: + cmd = self.parse_command(ts, in_command_sub=in_command_sub) + if cmd is not None: + script.add(cmd) + + # when in command sub mode, a script is terminated by ] + if in_command_sub and ts.type() == TOK_RBRACKET: + return script + + ts.expect( + TOK_EOF, + TOK_NEWLINE, + TOK_SEMI, + message=f"expected newline or semicolon, got {ts.value()}", + pos=ts.pos(), + ) + + if in_command_sub and ts.type() is TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding end of command substitution", pos, ts.pos() + ) + + self._debug_indent -= 1 + + script.end_pos = ts.pos() + + return script + + def parse_comment(self, ts): + self.debug(f"parse_comment({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_HASH) + + value = "" + while ts.type() not in {TOK_NEWLINE, TOK_EOF}: + value += ts.value() + ts.next() + + # Stripping trailing whitespace from comments here allows tclfmt to clean it up + # without affecting the AST. + value = value.rstrip() + + return Comment(value, pos=pos, end_pos=ts.pos()) + + def parse_command(self, ts, in_command_sub): + self.debug(f"parse_command({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + routine = self.parse_word(ts, in_command_sub) + if routine is None: + self._debug_indent -= 1 + return None + + args = [] + while True: + if ts.type() not in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + break + + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + ts.next() + + word = self.parse_word(ts, in_command_sub) + if word is None: + break + + args.append(word) + + self._debug_indent -= 1 + + try: + parsed_args = self._parse_command_args(routine.contents, args) + except CommandArgError as e: + self.violations.append(Violation(Rule.COMMAND_ARGS, str(e), pos, ts.pos())) + parsed_args = args + + children = [routine, *parsed_args] + # We need to inherit end pos of last child to prevent us from counting + # extra whitespace at end of command, which is important for + # spaces-in-braces check. + return Command(*children, pos=pos, end_pos=children[-1].end_pos) + + def parse_word(self, ts, in_command_sub): + self.debug(f"parse_word({ts.current})") + if ts.type() == TOK_ARG_EXPANSION: + return self.parse_arg_expansion(ts, in_command_sub) + elif ts.type() == TOK_LBRACE: + return self.parse_braced_word(ts) + elif ts.type() == TOK_QUOTE: + return self.parse_quoted_word(ts) + else: + return self.parse_bare_word(ts, in_command_sub) + + def parse_arg_expansion(self, ts, in_command_sub): + self.debug(f"parse_arg_expansion({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_ARG_EXPANSION) + + delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + if in_command_sub: + delimiters.append(TOK_RBRACKET) + + # Arg expansion is just a regular braced word if followed by whitespace, + # or other word boundaries such as semicolon or right bracket + # (in command substitution) + if ts.type() in delimiters: + return BracedWord("*", pos=pos, end_pos=ts.pos()) + + return ArgExpansion( + self.parse_word(ts, in_command_sub), pos=pos, end_pos=ts.pos() + ) + + def parse_quoted_word(self, ts): + self.debug(f"parse_quoted_word({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + ts.assert_(TOK_QUOTE) + + word = _Word() + while ts.type() not in {TOK_QUOTE, TOK_EOF}: + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + word.add_node(var_sub) + else: + word.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + word.add_node(command_sub) + else: + word.add_tok(ts.current) + ts.next() + + res = word.resolve(ts.pos()) + + ts.expect( + TOK_QUOTE, message="reached EOF without finding match for quote", pos=pos + ) + + self._debug_indent -= 1 + + if not res: + res = [] + + return QuotedWord(*res, pos=pos, end_pos=ts.pos()) + + def parse_braced_word(self, ts): + self.debug(f"parse_braced_word({ts.current})") + pos = ts.pos() + + ts.lexer.push_state(STATE_BRACEDWORD) + + ts.assert_(TOK_LBRACE) + + word = "" + # store position for each brace we want to match, facilitating good + # error messages + expected_braces = [pos] + while True: + toktype = ts.type() + if toktype == TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for brace", + expected_braces[-1], + ts.pos(), + ) + + if toktype == TOK_LBRACE: + expected_braces.append(ts.pos()) + elif toktype == TOK_RBRACE: + try: + expected_braces.pop() + except IndexError: + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "found closing brace without matching open brace", start, end + ) + + if len(expected_braces) == 0: + ts.lexer.pop_state() + ts.next() + break + word += ts.value() + ts.next() + + end_pos = ts.pos() + return BracedWord(word, pos=pos, end_pos=end_pos) + + def parse_bare_word(self, ts, in_command_sub): + self.debug(f"parse_bare_word({ts.current})") + self._debug_indent += 1 + pos = ts.pos() + + word = _Word() + delimiters = [TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE, TOK_SEMI, TOK_EOF] + + # In command sub mode, words are ended by ] + if in_command_sub: + delimiters.append(TOK_RBRACKET) + + while ts.type() not in delimiters: + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + word.add_node(var_sub) + else: + word.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + word.add_node(command_sub) + else: + word.add_tok(ts.current) + ts.next() + + res = word.resolve(ts.pos()) + + self._debug_indent -= 1 + + if not res: + return None + if len(res) == 1: + return res[0] + return CompoundBareWord(*res, pos=pos, end_pos=ts.pos()) + + def parse_var_sub(self, ts): + self.debug(f"parse_var_sub({ts.current})") + pos = ts.pos() + + ts.assert_(TOK_DOLLAR) + + var = "" + if ts.type() == TOK_LBRACE: + brace_pos = ts.pos() + ts.next() + while ts.type() != TOK_RBRACE: + if ts.type() is TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for brace", + brace_pos, + ts.pos(), + ) + var += ts.value() + ts.next() + ts.next() + + return VarSub(var, pos=pos, end_pos=ts.pos(), braced=True) + + while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS, TOK_NAMESPACE_SEP}: + var += ts.value() + ts.next() + + if not var: + return None + + index_nodes = [] + if ts.type() == TOK_LPAREN: + paren_pos = ts.pos() + index = _Word() + ts.next() + while ts.type() != TOK_RPAREN: + if ts.type() == TOK_EOF: + raise TclSyntaxError( + "reached EOF without finding match for paren", + paren_pos, + ts.pos(), + ) + if ts.type() == TOK_DOLLAR: + dollar_tok = ts.current + var_sub = self.parse_var_sub(ts) + if var_sub: + index.add_node(var_sub) + else: + index.add_tok(dollar_tok) + elif ts.type() == TOK_LBRACKET: + command_sub = self.parse_command_sub(ts) + index.add_node(command_sub) + else: + index.add_tok(ts.current) + ts.next() + + index_nodes = index.resolve(ts.pos()) + ts.next() + + var_sub = VarSub(var, pos=pos, end_pos=ts.pos()) + + for index_segment in index_nodes: + var_sub.add(index_segment) + + return var_sub + + def parse_command_sub(self, ts): + self.debug(f"parse_command_sub({ts.current})") + self._debug_indent += 1 + + pos = ts.pos() + ts.assert_(TOK_LBRACKET) + + script = self._parse_script(ts, in_command_sub=True) + + ts.assert_(TOK_RBRACKET) + end_pos = ts.pos() + + script.line = pos[0] + script.col = pos[1] + script.end_pos = end_pos + + self._debug_indent -= 1 + return script + + def parse_list(self, node): + """Parse contents of node as Tcl list. This is a distinct entry point + that doesn't get used when generating the main syntax tree, but is used + in command-specific argument parsing. + """ + if isinstance(node, List): + return node + + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as list" + ) + + ts = Lexer(pos=node.contents_pos) + ts.input(node.contents) + + DELIMITERS = {TOK_WS, TOK_BACKSLASH_NEWLINE, TOK_NEWLINE} + + list_node = List(pos=node.pos, end_pos=node.end_pos) + while ts.type() is not TOK_EOF: + while ts.type() in DELIMITERS: + ts.next() + + if ts.type() is TOK_EOF: + break + + if ts.type() == TOK_LBRACE: + # we can reuse parse_braced_word, since it doesn't use + # substitutions in any case + list_node.add(self.parse_braced_word(ts)) + elif ts.type() == TOK_QUOTE: + quote_word_pos = ts.pos() + + ts.assert_(TOK_QUOTE) + + bare_word_pos = ts.pos() + contents = "" + while ts.type() not in {TOK_QUOTE, TOK_EOF}: + contents += ts.value() + ts.next() + word = BareWord(contents, pos=bare_word_pos, end_pos=ts.pos()) + + ts.expect( + TOK_QUOTE, + message="reached EOF without finding match for quote", + pos=quote_word_pos, + ) + + list_node.add(QuotedWord(word, pos=quote_word_pos, end_pos=ts.pos())) + else: + pos = ts.pos() + contents = "" + while ts.type() not in {*DELIMITERS, TOK_EOF}: + contents += ts.value() + ts.next() + list_node.add(BareWord(contents, pos=pos, end_pos=ts.pos())) + + return list_node + + def parse_expression(self, node): + if node.contents is None: + raise CommandArgError( + "expected braced word or word without substitutions in argument" + " interpreted as expr" + ) + + ts = Lexer(pos=node.contents_pos) + ts.input(node.contents) + + contents = self._parse_expression(ts) + ts.expect( + TOK_EOF, + message=f"expected end of expression, got {ts.value()}", + pos=ts.pos(), + ) + if isinstance(node, BracedWord): + return BracedExpression(contents, pos=node.pos, end_pos=node.end_pos) + + return Expression(contents, pos=node.pos, end_pos=node.end_pos) + + @_strip_ws + def _parse_expression(self, ts): + op1 = self._parse_operand(ts) + expr = op1 + + # last condition is hack to break out of expression in case we're in ternary op + if ts.type() not in {TOK_EOF, TOK_RPAREN} and ts.value() not in {":", ","}: + if ts.value() == "?": + # weird hack to record operator + start = ts.pos() + ts.next() + q = BareWord("?", pos=start, end_pos=ts.pos()) + + op2 = self._parse_expression(ts) + if ts.value() != ":": + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "expected ':' to continue ternary expression", start, end + ) + + # weird hack again + start = ts.pos() + ts.next() + colon = BareWord(":", pos=start, end_pos=ts.pos()) + + op3 = self._parse_expression(ts) + expr = TernaryOp( + op1, q, op2, colon, op3, pos=op1.pos, end_pos=op3.end_pos + ) + else: + operator = self._parse_operator(ts) + op2 = self._parse_expression(ts) + expr = BinaryOp(op1, operator, op2, pos=op1.pos, end_pos=op2.end_pos) + + if ts.type() != TOK_RPAREN and ts.value() not in {":", ","}: + ts.expect(TOK_EOF, message="expected end of expression", pos=ts.pos()) + + return expr + + @_strip_ws + def _parse_operand(self, ts): + if ts.type() == TOK_DOLLAR: + return self.parse_var_sub(ts) + if ts.type() == TOK_QUOTE: + return self.parse_quoted_word(ts) + if ts.type() == TOK_LBRACE: + return self.parse_braced_word(ts) + if ts.type() == TOK_LBRACKET: + return self.parse_command_sub(ts) + if ts.type() == TOK_LPAREN: + start = ts.pos() + ts.next() + expr = self._parse_expression(ts) + ts.expect( + TOK_RPAREN, + message="reached EOF without finding match for paren", + pos=expr.pos, + ) + end = ts.pos() + return ParenExpression(expr, start, end) + if ts.value() in {"-", "+", "~", "!"}: + operator_val = ts.value() + operator_pos = ts.pos() + ts.next() + operator = BareWord(operator_val, pos=operator_pos, end_pos=ts.pos()) + operand = self._parse_operand(ts) + # Since _parse_operand() munches whitespace after the operand, we + # set the end of the UnaryOp to the end of the operand rather than + # ts.pos(). Otherwise, the bounds of the UnaryOp would include all + # that whitespace. + return UnaryOp(operator, operand, pos=operator_pos, end_pos=operand.end_pos) + + # If none of these, collect tokens that may comprise an operand + operand = "" + operand_pos = ts.pos() + + # First, we want to check for numeric operands (either ints or numeric + # floats) by consuming tokens as long as they comprise the prefix of a + # numeric operand + while ts.type() != TOK_EOF and ( + _is_int_prefix(operand + ts.value()) + or _is_float_prefix(operand + ts.value()) + ): + operand += ts.value() + ts.next() + + # Next, we check if we've consumed an entire numeric literal. If so, we + # move on. If not, we keep consuming tokens that may correspond to a + # valid bareword (pretty much just alphanumeric chars). + if not (_is_int_literal(operand) or _is_float_literal(operand)): + while ts.type() in {TOK_ALPHA_CHARS, TOK_NUM_CHARS}: + operand += ts.value() + ts.next() + + # The above method is a little hacky. Note that it doesn't parse things + # exactly the same as Tcl. E.g. if a script includes `expr {1foo}`, + # tclint will report an invalid operator "foo", whereas tclsh will + # report an invalid bareword "1foo". Despite reporting them differently + # both tools should still catch the same syntax errors, since there are + # no legal barewords that begin with a numeric literal prefix, and tclsh + # will stop parsing numeric operands if they're actually followed by a + # legal operator (e.g. `expr {1eq1}` will be handled properly). + + is_func = _is_function(operand) + + if not ( + _is_int_literal(operand) + or _is_float_literal(operand) + or _is_bool_literal(operand) + or is_func + ): + raise TclSyntaxError( + f"invalid bareword in expression: {operand}", operand_pos, ts.pos() + ) + + node = BareWord(operand, pos=operand_pos, end_pos=ts.pos()) + + if is_func: + node = self._parse_function(ts, node) + + return node + + def _parse_operator(self, ts): + pos = ts.pos() + + # hacky logic to handle parsing legal operators + + if ts.value() in {"*", "&", "|"}: + # one or two of these characters are legal operators + operator = ts.value() + ts.next() + if ts.value() == operator: + operator += ts.value() + ts.next() + elif ts.value() in {"<", ">"}: + operator = ts.value() + ts.next() + if ts.value() in {operator, "="}: + operator += ts.value() + ts.next() + elif ts.value() in {"=", "!"}: + operator = ts.value() + ts.next() + if ts.value() != "=": + raise TclSyntaxError( + f"invalid operator in expression: {operator}", pos, ts.pos() + ) + operator += ts.value() + ts.next() + elif ts.value() in {"*", "/", "%", "+", "-", "^", "eq", "ne", "in", "ni"}: + operator = ts.value() + ts.next() + else: + raise TclSyntaxError( + f"invalid operator in expression: {ts.value()}", pos, ts.pos() + ) + + return BareWord(operator, pos=pos, end_pos=ts.pos()) + + def _parse_function(self, ts, name): + while ts.type() in {TOK_WS, TOK_BACKSLASH_NEWLINE}: + ts.next() + + ts.expect( + TOK_LPAREN, + message="expected open paren after function name", + pos=name.pos, + ) + + delims = {TOK_RPAREN, TOK_EOF} + + arguments = [] + if ts.type() not in delims: + arguments.append(self._parse_expression(ts)) + + while ts.type() not in delims: + if ts.value() != ",": + start = ts.pos() + ts.next() + end = ts.pos() + raise TclSyntaxError( + "expected comma between function arguments", start, end + ) + ts.next() + + arguments.append(self._parse_expression(ts)) + + ts.expect( + TOK_RPAREN, + message="expected close paren after function arguments", + pos=name.pos, + ) + return Function(name, *arguments, pos=name.pos, end_pos=ts.pos()) + + +def _all(_list, non_empty=False): + """Like all(), but if non_empty is True, list must also have at least 1 element.""" + if non_empty and len(_list) == 0: + return False + return all(_list) + + +def _is_int(operand, full=False): + """Returns whether operand is a valid Tcl integer literal. + + If full is False, will also return True if operand is the prefix of an + integer literal. An empty string is not a valid full literal, but is a + valid prefix. + """ + # prefixes + if operand.startswith("0b"): + return _all([digit in "01" for digit in operand[2:]], non_empty=full) + if operand.startswith("0o"): + return _all( + [digit in string.octdigits for digit in operand[2:]], non_empty=full + ) + if operand.startswith("0x"): + return _all( + [digit in string.hexdigits for digit in operand[2:]], non_empty=full + ) + if operand.startswith("0"): + # fun fact: apparently a lone 0 prefix is interpreted as octal + return _all( + [digit in string.octdigits for digit in operand[1:]], non_empty=full + ) + + return _all([digit in string.digits for digit in operand], non_empty=full) + + +def _is_int_literal(operand): + return _is_int(operand, full=True) + + +def _is_int_prefix(operand): + return _is_int(operand, full=False) + + +def _is_float_literal(operand): + if operand.lower() in {"nan", "inf"}: + return True + + return ( + operand != "" and re.fullmatch(r"\d*\.?\d*([Ee][+-]?\d+)?", operand) is not None + ) + + +def _is_float_prefix(operand): + """Returns whether operand is the prefix of a valid numeric float literal.""" + return re.fullmatch(r"\d*\.?\d*([Ee][+-]?)?\d*", operand) is not None + + +def _is_bool_literal(operand): + return operand in {"false", "no", "off", "true", "yes", "on"} + + +# map of function names to # arguments accepted +# None indicates 1 or more arguments +# TODO: use these values in an actual separate check. they might want to live elsewhere +_FUNCTIONS = { + "abs": 1, + "acos": 1, + "asin": 1, + "atan": 1, + "atan2": 2, + "bool": 1, + "ceil": 1, + "cos": 1, + "cosh": 1, + "double": 1, + "entier": 1, + "exp": 1, + "floor": 1, + "fmod": 2, + "hypot": 2, + "int": 1, + "isqrt": 1, + "log": 1, + "log10": 1, + "max": None, + "min": None, + "pow": 2, + "rand": 0, + "round": 1, + "sin": 1, + "sinh": 1, + "sqrt": 1, + "srand": 1, + "tan": 1, + "tanh": 1, + "wide": 1, +} + + +def _is_function(operand): + return operand in _FUNCTIONS.keys() diff --git a/server/src/tools/syntax_tree.py b/server/libs/tclint/syntax_tree.py similarity index 96% rename from server/src/tools/syntax_tree.py rename to server/libs/tclint/syntax_tree.py index fdfac56..d11f6df 100644 --- a/server/src/tools/syntax_tree.py +++ b/server/libs/tclint/syntax_tree.py @@ -1,4 +1,4 @@ -"""Classes for representing and interacting with Tcl syntax trees.""" +"""Classes for representing and interacting with Tcl syntax trees. """ class Visitor: @@ -67,6 +67,9 @@ class Node: """ def __init__(self, *init, pos=None, end_pos=None): + """pos: line, column of first character of parsed region (1-indexed) + end_pos: line, column of first character after parsed region (1-indexed) + """ self.line = None self.col = None if pos is not None: @@ -77,6 +80,7 @@ class Node: if len(init) > 0 and not isinstance(init[0], Node): self.value = init[0] init = init[1:] + if not all(isinstance(v, Node) for v in init): raise TypeError("Children must be Node instances") @@ -184,12 +188,12 @@ class Node: return lines if len(self.children) != len(other.children): - my_children = ",".join( - [child.__class__.__name__ for child in self.children] - ) - other_children = ",".join( - [child.__class__.__name__ for child in other.children] - ) + my_children = ",".join([ + child.__class__.__name__ for child in self.children + ]) + other_children = ",".join([ + child.__class__.__name__ for child in other.children + ]) lines += [f"{indent}-{my_cls}({my_children})"] lines += [f"{indent}+{other_cls}({other_children})"] diff --git a/server/libs/tclint/violations.py b/server/libs/tclint/violations.py new file mode 100644 index 0000000..8b08329 --- /dev/null +++ b/server/libs/tclint/violations.py @@ -0,0 +1,50 @@ +from enum import Enum +from typing import Tuple + + +class Rule(Enum): + """This enum serves a few purposes: + + 1) define symbols for rule IDs to be used in code + 2) map these symbols to names in the UI + 3) collect all rule IDs/provide validation for IDs + """ + + LINE_LENGTH = "line-length" + TRAILING_WHITESPACE = "trailing-whitespace" + COMMAND_ARGS = "command-args" + REDEFINED_BUILTIN = "redefined-builtin" + UNBRACED_EXPR = "unbraced-expr" + REDUNDANT_EXPR = "redundant-expr" + + def __str__(self): + return self.value + + +ALL_RULES = [rule for rule in Rule] + + +class Violation: + def __init__( + self, id: Rule, message: str, start: Tuple[int, int], end: Tuple[int, int] + ): + self.id = id + self.message = message + self.start = start + self.end = end + + def __lt__(self, other): + return self.start < other.start + + def __str__(self): + line, col = self.start + rule = str(self.id) + + return f"{line}:{col}: {self.message} [{rule}]" + + @classmethod + def create(cls, id): + def func(message: str, start: Tuple[int, int], end: Tuple[int, int]): + return cls(id, message, start, end) + + return func diff --git a/server/libs/zipp-3.23.0.dist-info/INSTALLER b/server/libs/zipp-3.23.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/libs/zipp-3.23.0.dist-info/METADATA b/server/libs/zipp-3.23.0.dist-info/METADATA new file mode 100644 index 0000000..6420117 --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/METADATA @@ -0,0 +1,106 @@ +Metadata-Version: 2.4 +Name: zipp +Version: 3.23.0 +Summary: Backport of pathlib-compatible object wrapper for zip files +Author-email: "Jason R. Coombs" +License-Expression: MIT +Project-URL: Source, https://github.com/jaraco/zipp +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Requires-Python: >=3.9 +Description-Content-Type: text/x-rst +License-File: LICENSE +Provides-Extra: test +Requires-Dist: pytest!=8.1.*,>=6; extra == "test" +Requires-Dist: jaraco.itertools; extra == "test" +Requires-Dist: jaraco.functools; extra == "test" +Requires-Dist: more_itertools; extra == "test" +Requires-Dist: big-O; extra == "test" +Requires-Dist: pytest-ignore-flaky; extra == "test" +Requires-Dist: jaraco.test; extra == "test" +Provides-Extra: doc +Requires-Dist: sphinx>=3.5; extra == "doc" +Requires-Dist: jaraco.packaging>=9.3; extra == "doc" +Requires-Dist: rst.linker>=1.9; extra == "doc" +Requires-Dist: furo; extra == "doc" +Requires-Dist: sphinx-lint; extra == "doc" +Requires-Dist: jaraco.tidelift>=1.4; extra == "doc" +Provides-Extra: check +Requires-Dist: pytest-checkdocs>=2.4; extra == "check" +Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check" +Provides-Extra: cover +Requires-Dist: pytest-cov; extra == "cover" +Provides-Extra: enabler +Requires-Dist: pytest-enabler>=2.2; extra == "enabler" +Provides-Extra: type +Requires-Dist: pytest-mypy; extra == "type" +Dynamic: license-file + +.. image:: https://img.shields.io/pypi/v/zipp.svg + :target: https://pypi.org/project/zipp + +.. image:: https://img.shields.io/pypi/pyversions/zipp.svg + +.. image:: https://github.com/jaraco/zipp/actions/workflows/main.yml/badge.svg + :target: https://github.com/jaraco/zipp/actions?query=workflow%3A%22tests%22 + :alt: tests + +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff + +.. image:: https://readthedocs.org/projects/zipp/badge/?version=latest +.. :target: https://zipp.readthedocs.io/en/latest/?badge=latest + +.. image:: https://img.shields.io/badge/skeleton-2025-informational + :target: https://blog.jaraco.com/skeleton + +.. image:: https://tidelift.com/badges/package/pypi/zipp + :target: https://tidelift.com/subscription/pkg/pypi-zipp?utm_source=pypi-zipp&utm_medium=readme + + +A pathlib-compatible Zipfile object wrapper. Official backport of the standard library +`Path object `_. + + +Compatibility +============= + +New features are introduced in this third-party library and later merged +into CPython. The following table indicates which versions of this library +were contributed to different versions in the standard library: + +.. list-table:: + :header-rows: 1 + + * - zipp + - stdlib + * - 3.18 + - 3.13 + * - 3.16 + - 3.12 + * - 3.5 + - 3.11 + * - 3.2 + - 3.10 + * - 3.3 ?? + - 3.9 + * - 1.0 + - 3.8 + + +Usage +===== + +Use ``zipp.Path`` in place of ``zipfile.Path`` on any Python. + +For Enterprise +============== + +Available as part of the Tidelift Subscription. + +This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. + +`Learn more `_. diff --git a/server/libs/zipp-3.23.0.dist-info/RECORD b/server/libs/zipp-3.23.0.dist-info/RECORD new file mode 100644 index 0000000..08332d5 --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/RECORD @@ -0,0 +1,21 @@ +zipp-3.23.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +zipp-3.23.0.dist-info/METADATA,sha256=vdZ9TRbPC_O4k-fRjNPS13StuC837Zhbx3cMYHIms1s,3563 +zipp-3.23.0.dist-info/RECORD,, +zipp-3.23.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +zipp-3.23.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91 +zipp-3.23.0.dist-info/licenses/LICENSE,sha256=WlfLTbheKi3YjCkGKJCK3VfjRRRJ4KmnH9-zh3b9dZ0,1076 +zipp-3.23.0.dist-info/top_level.txt,sha256=iAbdoSHfaGqBfVb2XuR9JqSQHCoOsOtG6y9C_LSpqFw,5 +zipp/__init__.py,sha256=ieXh9GIMdABjKRX_JUJtP9k5wdBLK4Mt5X4nszSkmYE,11976 +zipp/__pycache__/__init__.cpython-311.pyc,, +zipp/__pycache__/_functools.cpython-311.pyc,, +zipp/__pycache__/glob.cpython-311.pyc,, +zipp/_functools.py,sha256=f6Kt9LxZ4TE-cY1lJVdXSId3memSXmH9IdgMbU-_x2k,575 +zipp/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +zipp/compat/__pycache__/__init__.cpython-311.pyc,, +zipp/compat/__pycache__/overlay.cpython-311.pyc,, +zipp/compat/__pycache__/py310.cpython-311.pyc,, +zipp/compat/__pycache__/py313.cpython-311.pyc,, +zipp/compat/overlay.py,sha256=oEIGAnbr8yGjuKTrVSO2ByewPui71uppbX18BLnYTKE,783 +zipp/compat/py310.py,sha256=S7i6N9mToEn3asNb2ILyjnzvITOXrATD_J4emjyBbDU,256 +zipp/compat/py313.py,sha256=RndvDNtuY7H2D9ecnnzcPBMZ8mZc42gmXD_IwQAXXAE,654 +zipp/glob.py,sha256=DLV9LBsDxA6YVW82e3-tkoNrus1h4R-j3BR6VqS0AzE,3382 diff --git a/server/libs/zipp-3.23.0.dist-info/REQUESTED b/server/libs/zipp-3.23.0.dist-info/REQUESTED new file mode 100644 index 0000000..e69de29 diff --git a/server/libs/zipp-3.23.0.dist-info/WHEEL b/server/libs/zipp-3.23.0.dist-info/WHEEL new file mode 100644 index 0000000..e7fa31b --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (80.9.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/server/libs/zipp-3.23.0.dist-info/licenses/LICENSE b/server/libs/zipp-3.23.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..f60bd57 --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/licenses/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/libs/zipp-3.23.0.dist-info/top_level.txt b/server/libs/zipp-3.23.0.dist-info/top_level.txt new file mode 100644 index 0000000..e82f676 --- /dev/null +++ b/server/libs/zipp-3.23.0.dist-info/top_level.txt @@ -0,0 +1 @@ +zipp diff --git a/server/libs/zipp/__init__.py b/server/libs/zipp/__init__.py new file mode 100644 index 0000000..ed5b214 --- /dev/null +++ b/server/libs/zipp/__init__.py @@ -0,0 +1,456 @@ +""" +A Path-like interface for zipfiles. + +This codebase is shared between zipfile.Path in the stdlib +and zipp in PyPI. See +https://github.com/python/importlib_metadata/wiki/Development-Methodology +for more detail. +""" + +import functools +import io +import itertools +import pathlib +import posixpath +import re +import stat +import sys +import zipfile + +from ._functools import save_method_args +from .compat.py310 import text_encoding +from .glob import Translator + +__all__ = ['Path'] + + +def _parents(path): + """ + Given a path with elements separated by + posixpath.sep, generate all parents of that path. + + >>> list(_parents('b/d')) + ['b'] + >>> list(_parents('/b/d/')) + ['/b'] + >>> list(_parents('b/d/f/')) + ['b/d', 'b'] + >>> list(_parents('b')) + [] + >>> list(_parents('')) + [] + """ + return itertools.islice(_ancestry(path), 1, None) + + +def _ancestry(path): + """ + Given a path with elements separated by + posixpath.sep, generate all elements of that path. + + >>> list(_ancestry('b/d')) + ['b/d', 'b'] + >>> list(_ancestry('/b/d/')) + ['/b/d', '/b'] + >>> list(_ancestry('b/d/f/')) + ['b/d/f', 'b/d', 'b'] + >>> list(_ancestry('b')) + ['b'] + >>> list(_ancestry('')) + [] + + Multiple separators are treated like a single. + + >>> list(_ancestry('//b//d///f//')) + ['//b//d///f', '//b//d', '//b'] + """ + path = path.rstrip(posixpath.sep) + while path.rstrip(posixpath.sep): + yield path + path, tail = posixpath.split(path) + + +_dedupe = dict.fromkeys +"""Deduplicate an iterable in original order""" + + +def _difference(minuend, subtrahend): + """ + Return items in minuend not in subtrahend, retaining order + with O(1) lookup. + """ + return itertools.filterfalse(set(subtrahend).__contains__, minuend) + + +class InitializedState: + """ + Mix-in to save the initialization state for pickling. + """ + + @save_method_args + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getstate__(self): + return self._saved___init__.args, self._saved___init__.kwargs + + def __setstate__(self, state): + args, kwargs = state + super().__init__(*args, **kwargs) + + +class CompleteDirs(InitializedState, zipfile.ZipFile): + """ + A ZipFile subclass that ensures that implied directories + are always included in the namelist. + + >>> list(CompleteDirs._implied_dirs(['foo/bar.txt', 'foo/bar/baz.txt'])) + ['foo/', 'foo/bar/'] + >>> list(CompleteDirs._implied_dirs(['foo/bar.txt', 'foo/bar/baz.txt', 'foo/bar/'])) + ['foo/'] + """ + + @staticmethod + def _implied_dirs(names): + parents = itertools.chain.from_iterable(map(_parents, names)) + as_dirs = (p + posixpath.sep for p in parents) + return _dedupe(_difference(as_dirs, names)) + + def namelist(self): + names = super().namelist() + return names + list(self._implied_dirs(names)) + + def _name_set(self): + return set(self.namelist()) + + def resolve_dir(self, name): + """ + If the name represents a directory, return that name + as a directory (with the trailing slash). + """ + names = self._name_set() + dirname = name + '/' + dir_match = name not in names and dirname in names + return dirname if dir_match else name + + def getinfo(self, name): + """ + Supplement getinfo for implied dirs. + """ + try: + return super().getinfo(name) + except KeyError: + if not name.endswith('/') or name not in self._name_set(): + raise + return zipfile.ZipInfo(filename=name) + + @classmethod + def make(cls, source): + """ + Given a source (filename or zipfile), return an + appropriate CompleteDirs subclass. + """ + if isinstance(source, CompleteDirs): + return source + + if not isinstance(source, zipfile.ZipFile): + return cls(source) + + # Only allow for FastLookup when supplied zipfile is read-only + if 'r' not in source.mode: + cls = CompleteDirs + + source.__class__ = cls + return source + + @classmethod + def inject(cls, zf: zipfile.ZipFile) -> zipfile.ZipFile: + """ + Given a writable zip file zf, inject directory entries for + any directories implied by the presence of children. + """ + for name in cls._implied_dirs(zf.namelist()): + zf.writestr(name, b"") + return zf + + +class FastLookup(CompleteDirs): + """ + ZipFile subclass to ensure implicit + dirs exist and are resolved rapidly. + """ + + def namelist(self): + return self._namelist + + @functools.cached_property + def _namelist(self): + return super().namelist() + + def _name_set(self): + return self._name_set_prop + + @functools.cached_property + def _name_set_prop(self): + return super()._name_set() + + +def _extract_text_encoding(encoding=None, *args, **kwargs): + # compute stack level so that the caller of the caller sees any warning. + is_pypy = sys.implementation.name == 'pypy' + # PyPy no longer special cased after 7.3.19 (or maybe 7.3.18) + # See jaraco/zipp#143 + is_old_pypi = is_pypy and sys.pypy_version_info < (7, 3, 19) + stack_level = 3 + is_old_pypi + return text_encoding(encoding, stack_level), args, kwargs + + +class Path: + """ + A :class:`importlib.resources.abc.Traversable` interface for zip files. + + Implements many of the features users enjoy from + :class:`pathlib.Path`. + + Consider a zip file with this structure:: + + . + ├── a.txt + └── b + ├── c.txt + └── d + └── e.txt + + >>> data = io.BytesIO() + >>> zf = zipfile.ZipFile(data, 'w') + >>> zf.writestr('a.txt', 'content of a') + >>> zf.writestr('b/c.txt', 'content of c') + >>> zf.writestr('b/d/e.txt', 'content of e') + >>> zf.filename = 'mem/abcde.zip' + + Path accepts the zipfile object itself or a filename + + >>> path = Path(zf) + + From there, several path operations are available. + + Directory iteration (including the zip file itself): + + >>> a, b = path.iterdir() + >>> a + Path('mem/abcde.zip', 'a.txt') + >>> b + Path('mem/abcde.zip', 'b/') + + name property: + + >>> b.name + 'b' + + join with divide operator: + + >>> c = b / 'c.txt' + >>> c + Path('mem/abcde.zip', 'b/c.txt') + >>> c.name + 'c.txt' + + Read text: + + >>> c.read_text(encoding='utf-8') + 'content of c' + + existence: + + >>> c.exists() + True + >>> (b / 'missing.txt').exists() + False + + Coercion to string: + + >>> import os + >>> str(c).replace(os.sep, posixpath.sep) + 'mem/abcde.zip/b/c.txt' + + At the root, ``name``, ``filename``, and ``parent`` + resolve to the zipfile. + + >>> str(path) + 'mem/abcde.zip/' + >>> path.name + 'abcde.zip' + >>> path.filename == pathlib.Path('mem/abcde.zip') + True + >>> str(path.parent) + 'mem' + + If the zipfile has no filename, such attributes are not + valid and accessing them will raise an Exception. + + >>> zf.filename = None + >>> path.name + Traceback (most recent call last): + ... + TypeError: ... + + >>> path.filename + Traceback (most recent call last): + ... + TypeError: ... + + >>> path.parent + Traceback (most recent call last): + ... + TypeError: ... + + # workaround python/cpython#106763 + >>> pass + """ + + __repr = "{self.__class__.__name__}({self.root.filename!r}, {self.at!r})" + + def __init__(self, root, at=""): + """ + Construct a Path from a ZipFile or filename. + + Note: When the source is an existing ZipFile object, + its type (__class__) will be mutated to a + specialized type. If the caller wishes to retain the + original type, the caller should either create a + separate ZipFile object or pass a filename. + """ + self.root = FastLookup.make(root) + self.at = at + + def __eq__(self, other): + """ + >>> Path(zipfile.ZipFile(io.BytesIO(), 'w')) == 'foo' + False + """ + if self.__class__ is not other.__class__: + return NotImplemented + return (self.root, self.at) == (other.root, other.at) + + def __hash__(self): + return hash((self.root, self.at)) + + def open(self, mode='r', *args, pwd=None, **kwargs): + """ + Open this entry as text or binary following the semantics + of ``pathlib.Path.open()`` by passing arguments through + to io.TextIOWrapper(). + """ + if self.is_dir(): + raise IsADirectoryError(self) + zip_mode = mode[0] + if zip_mode == 'r' and not self.exists(): + raise FileNotFoundError(self) + stream = self.root.open(self.at, zip_mode, pwd=pwd) + if 'b' in mode: + if args or kwargs: + raise ValueError("encoding args invalid for binary operation") + return stream + # Text mode: + encoding, args, kwargs = _extract_text_encoding(*args, **kwargs) + return io.TextIOWrapper(stream, encoding, *args, **kwargs) + + def _base(self): + return pathlib.PurePosixPath(self.at) if self.at else self.filename + + @property + def name(self): + return self._base().name + + @property + def suffix(self): + return self._base().suffix + + @property + def suffixes(self): + return self._base().suffixes + + @property + def stem(self): + return self._base().stem + + @property + def filename(self): + return pathlib.Path(self.root.filename).joinpath(self.at) + + def read_text(self, *args, **kwargs): + encoding, args, kwargs = _extract_text_encoding(*args, **kwargs) + with self.open('r', encoding, *args, **kwargs) as strm: + return strm.read() + + def read_bytes(self): + with self.open('rb') as strm: + return strm.read() + + def _is_child(self, path): + return posixpath.dirname(path.at.rstrip("/")) == self.at.rstrip("/") + + def _next(self, at): + return self.__class__(self.root, at) + + def is_dir(self): + return not self.at or self.at.endswith("/") + + def is_file(self): + return self.exists() and not self.is_dir() + + def exists(self): + return self.at in self.root._name_set() + + def iterdir(self): + if not self.is_dir(): + raise ValueError("Can't listdir a file") + subs = map(self._next, self.root.namelist()) + return filter(self._is_child, subs) + + def match(self, path_pattern): + return pathlib.PurePosixPath(self.at).match(path_pattern) + + def is_symlink(self): + """ + Return whether this path is a symlink. + """ + info = self.root.getinfo(self.at) + mode = info.external_attr >> 16 + return stat.S_ISLNK(mode) + + def glob(self, pattern): + if not pattern: + raise ValueError(f"Unacceptable pattern: {pattern!r}") + + prefix = re.escape(self.at) + tr = Translator(seps='/') + matches = re.compile(prefix + tr.translate(pattern)).fullmatch + return map(self._next, filter(matches, self.root.namelist())) + + def rglob(self, pattern): + return self.glob(f'**/{pattern}') + + def relative_to(self, other, *extra): + return posixpath.relpath(str(self), str(other.joinpath(*extra))) + + def __str__(self): + return posixpath.join(self.root.filename, self.at) + + def __repr__(self): + return self.__repr.format(self=self) + + def joinpath(self, *other): + next = posixpath.join(self.at, *other) + return self._next(self.root.resolve_dir(next)) + + __truediv__ = joinpath + + @property + def parent(self): + if not self.at: + return self.filename.parent + parent_at = posixpath.dirname(self.at.rstrip('/')) + if parent_at: + parent_at += '/' + return self._next(parent_at) diff --git a/server/libs/zipp/_functools.py b/server/libs/zipp/_functools.py new file mode 100644 index 0000000..7390be2 --- /dev/null +++ b/server/libs/zipp/_functools.py @@ -0,0 +1,20 @@ +import collections +import functools + + +# from jaraco.functools 4.0.2 +def save_method_args(method): + """ + Wrap a method such that when it is called, the args and kwargs are + saved on the method. + """ + args_and_kwargs = collections.namedtuple('args_and_kwargs', 'args kwargs') # noqa: PYI024 + + @functools.wraps(method) + def wrapper(self, /, *args, **kwargs): + attr_name = '_saved_' + method.__name__ + attr = args_and_kwargs(args, kwargs) + setattr(self, attr_name, attr) + return method(self, *args, **kwargs) + + return wrapper diff --git a/server/libs/zipp/compat/__init__.py b/server/libs/zipp/compat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/libs/zipp/compat/overlay.py b/server/libs/zipp/compat/overlay.py new file mode 100644 index 0000000..5a97ee7 --- /dev/null +++ b/server/libs/zipp/compat/overlay.py @@ -0,0 +1,37 @@ +""" +Expose zipp.Path as .zipfile.Path. + +Includes everything else in ``zipfile`` to match future usage. Just +use: + +>>> from zipp.compat.overlay import zipfile + +in place of ``import zipfile``. + +Relative imports are supported too. + +>>> from zipp.compat.overlay.zipfile import ZipInfo + +The ``zipfile`` object added to ``sys.modules`` needs to be +hashable (#126). + +>>> _ = hash(sys.modules['zipp.compat.overlay.zipfile']) +""" + +import importlib +import sys +import types + +import zipp + + +class HashableNamespace(types.SimpleNamespace): + def __hash__(self): + return hash(tuple(vars(self))) + + +zipfile = HashableNamespace(**vars(importlib.import_module('zipfile'))) +zipfile.Path = zipp.Path +zipfile._path = zipp + +sys.modules[__name__ + '.zipfile'] = zipfile # type: ignore[assignment] diff --git a/server/libs/zipp/compat/py310.py b/server/libs/zipp/compat/py310.py new file mode 100644 index 0000000..e1e7ec2 --- /dev/null +++ b/server/libs/zipp/compat/py310.py @@ -0,0 +1,13 @@ +import io +import sys + + +def _text_encoding(encoding, stacklevel=2, /): # pragma: no cover + return encoding + + +text_encoding = ( + io.text_encoding # type: ignore[unused-ignore, attr-defined] + if sys.version_info > (3, 10) + else _text_encoding +) diff --git a/server/libs/zipp/compat/py313.py b/server/libs/zipp/compat/py313.py new file mode 100644 index 0000000..ae45869 --- /dev/null +++ b/server/libs/zipp/compat/py313.py @@ -0,0 +1,34 @@ +import functools +import sys + + +# from jaraco.functools 4.1 +def identity(x): + return x + + +# from jaraco.functools 4.1 +def apply(transform): + def wrap(func): + return functools.wraps(func)(compose(transform, func)) + + return wrap + + +# from jaraco.functools 4.1 +def compose(*funcs): + def compose_two(f1, f2): + return lambda *args, **kwargs: f1(f2(*args, **kwargs)) + + return functools.reduce(compose_two, funcs) + + +def replace(pattern): + r""" + >>> replace(r'foo\z') + 'foo\\Z' + """ + return pattern[:-2] + pattern[-2:].replace(r'\z', r'\Z') + + +legacy_end_marker = apply(replace) if sys.version_info < (3, 14) else identity diff --git a/server/libs/zipp/glob.py b/server/libs/zipp/glob.py new file mode 100644 index 0000000..1b4ffb3 --- /dev/null +++ b/server/libs/zipp/glob.py @@ -0,0 +1,116 @@ +import os +import re + +from .compat.py313 import legacy_end_marker + +_default_seps = os.sep + str(os.altsep) * bool(os.altsep) + + +class Translator: + """ + >>> Translator('xyz') + Traceback (most recent call last): + ... + AssertionError: Invalid separators + + >>> Translator('') + Traceback (most recent call last): + ... + AssertionError: Invalid separators + """ + + seps: str + + def __init__(self, seps: str = _default_seps): + assert seps and set(seps) <= set(_default_seps), "Invalid separators" + self.seps = seps + + def translate(self, pattern): + """ + Given a glob pattern, produce a regex that matches it. + """ + return self.extend(self.match_dirs(self.translate_core(pattern))) + + @legacy_end_marker + def extend(self, pattern): + r""" + Extend regex for pattern-wide concerns. + + Apply '(?s:)' to create a non-matching group that + matches newlines (valid on Unix). + + Append '\z' to imply fullmatch even when match is used. + """ + return rf'(?s:{pattern})\z' + + def match_dirs(self, pattern): + """ + Ensure that zipfile.Path directory names are matched. + + zipfile.Path directory names always end in a slash. + """ + return rf'{pattern}[/]?' + + def translate_core(self, pattern): + r""" + Given a glob pattern, produce a regex that matches it. + + >>> t = Translator() + >>> t.translate_core('*.txt').replace('\\\\', '') + '[^/]*\\.txt' + >>> t.translate_core('a?txt') + 'a[^/]txt' + >>> t.translate_core('**/*').replace('\\\\', '') + '.*/[^/][^/]*' + """ + self.restrict_rglob(pattern) + return ''.join(map(self.replace, separate(self.star_not_empty(pattern)))) + + def replace(self, match): + """ + Perform the replacements for a match from :func:`separate`. + """ + return match.group('set') or ( + re.escape(match.group(0)) + .replace('\\*\\*', r'.*') + .replace('\\*', rf'[^{re.escape(self.seps)}]*') + .replace('\\?', r'[^/]') + ) + + def restrict_rglob(self, pattern): + """ + Raise ValueError if ** appears in anything but a full path segment. + + >>> Translator().translate('**foo') + Traceback (most recent call last): + ... + ValueError: ** must appear alone in a path segment + """ + seps_pattern = rf'[{re.escape(self.seps)}]+' + segments = re.split(seps_pattern, pattern) + if any('**' in segment and segment != '**' for segment in segments): + raise ValueError("** must appear alone in a path segment") + + def star_not_empty(self, pattern): + """ + Ensure that * will not match an empty segment. + """ + + def handle_segment(match): + segment = match.group(0) + return '?*' if segment == '*' else segment + + not_seps_pattern = rf'[^{re.escape(self.seps)}]+' + return re.sub(not_seps_pattern, handle_segment, pattern) + + +def separate(pattern): + """ + Separate out character sets to avoid translating their contents. + + >>> [m.group(0) for m in separate('*.txt')] + ['*.txt'] + >>> [m.group(0) for m in separate('a[?]txt')] + ['a', '[?]', 'txt'] + """ + return re.finditer(r'([^\[]+)|(?P[\[].*?[\]])|([\[][^\]]*$)', pattern) diff --git a/server/requirements.in b/server/requirements.in index beeb719..0261164 100644 --- a/server/requirements.in +++ b/server/requirements.in @@ -13,6 +13,4 @@ pygls packaging # TODO: Add your tool here -ply -lark -voluptuous \ No newline at end of file +tclint \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index c585a64..b16a3e7 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -16,10 +16,10 @@ cattrs==25.1.1 \ # via # lsprotocol # pygls -lark==1.2.2 \ - --hash=sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c \ - --hash=sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80 - # via -r ./requirements.in +importlib-metadata==6.8.0 \ + --hash=sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb \ + --hash=sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743 + # via tclint lsprotocol==2023.0.1 \ --hash=sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2 \ --hash=sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d @@ -28,13 +28,23 @@ packaging==25.0 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f # via -r ./requirements.in +pathspec==0.11.2 \ + --hash=sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20 \ + --hash=sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3 + # via tclint ply==3.11 \ --hash=sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3 \ --hash=sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce - # via -r ./requirements.in + # via tclint pygls==1.3.1 \ --hash=sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018 \ --hash=sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e + # via + # -r ./requirements.in + # tclint +tclint==0.6.0 \ + --hash=sha256:8dd4d7b519e040c164615df8072cc4c28def4bfdc9d2a8672a280b0984b45fc3 \ + --hash=sha256:f60d2378dd203c0ee1268e9f9138f17cb6106b8824b727e584f054c5932a87db # via -r ./requirements.in typing-extensions==4.14.1 \ --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ @@ -43,4 +53,8 @@ typing-extensions==4.14.1 \ voluptuous==0.15.2 \ --hash=sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566 \ --hash=sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa - # via -r ./requirements.in + # via tclint +zipp==3.23.0 \ + --hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \ + --hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166 + # via importlib-metadata diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 2d8ea86..16ebe47 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 +from typing import Any, Optional, Sequence, Tuple import re @@ -42,16 +42,67 @@ import lsp_jsonrpc as jsonrpc import lsp_utils as utils import lsprotocol.types as lsp from pygls import server, uris, workspace +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.format import Formatter, FormatterOpts +from tools.semantic_tokens import ( + SemanticTokenCollector, + collect_semantic_tokens, + encode_tokens, +) +from tools.nx_plugins import commands + + +class TclLanguageServer(server.LanguageServer): + 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)) + + indent = "\t" if not options.insert_spaces else " " * options.tab_size + + formatter = Formatter( + FormatterOpts( + indent=indent, + spaces_in_braces=False, + max_blank_lines=500, + indent_namespace_eval=True, + ), + ) + + if range is not None: + start, end = range + return formatter.format_partial(document.source[start:end], parser) + + return formatter.format_top(document.source, parser) WORKSPACE_SETTINGS = {} GLOBAL_SETTINGS = {} RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py" +TOKEN_TYPES = [ + "command", + "variable", + "function", + "string", + "number", + "keyword", + "comment", +] +TOKEN_MODIFIERS = [] + MAX_WORKERS = 5 -LSP_SERVER = server.LanguageServer( +LSP_SERVER = TclLanguageServer( name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS ) @@ -104,6 +155,17 @@ def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: return lsp.CompletionList(is_incomplete=False, items=items) +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL) +def on_semantic_tokens(params: lsp.SemanticTokensParams): + doc = LSP_SERVER.workspace.get_document(params.text_document.uri) + code = doc.source + + tokens = collect_semantic_tokens(code) + data = encode_tokens(tokens) + + return lsp.SemanticTokens(data=data) + + @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_HOVER) def hover(params: lsp.HoverParams) -> lsp.Hover: pos = params.position @@ -189,19 +251,19 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | None: """LSP handler for textDocument/formatting request.""" doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - text = doc.source - formatted = text - if GLOBAL_SETTINGS.get("formatter", False): - formatted = format_tcl(text) + source = doc.source + start = lsp.Position(line=0, character=0) + last_line = source.rsplit("\n", 1)[-1] + end = lsp.Position(line=source.count("\n"), character=len(last_line)) - last_line = len(text.splitlines()) - full_range = lsp.Range( - start=lsp.Position(line=0, character=0), - end=lsp.Position(line=last_line, character=0), - ) - edit = lsp.TextEdit(range=full_range, new_text=formatted) - return [edit] + formatted = LSP_SERVER.format(doc, params.options) + return [ + lsp.TextEdit( + range=lsp.Range(start=start, end=end), + new_text=formatted, + ) + ] # ********************************************************** @@ -232,9 +294,16 @@ 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" ) + semantic_tokens_legend = lsp.SemanticTokensLegend( + token_types=TOKEN_TYPES, + token_modifiers=TOKEN_MODIFIERS, + ) return lsp.InitializeResult( capabilities=lsp.ServerCapabilities( document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), + semantic_tokens_provider=lsp.SemanticTokensOptions( + legend=semantic_tokens_legend, full=True, range=False + ), ) ) diff --git a/server/src/parser/ast.py b/server/src/parser/ast.py new file mode 100644 index 0000000..f7c937c --- /dev/null +++ b/server/src/parser/ast.py @@ -0,0 +1,93 @@ +class Node: + def _pos_str(self): + start_pos_str = "?" + if self.pos is not None: + start_pos_str = f"{self.pos[0]}:{self.pos[1]}" + end_pos_str = "?" + if self.end_pos is not None: + end_pos_str = f"{self.end_pos[0]}:{self.end_pos[1]}" + + return f" # {start_pos_str}-{end_pos_str}" + + def _make_str(self, indent=None, positions=False): + if indent is not None: + s = " " * indent + else: + s = "" + + s += self.__class__.__name__ + s += "(" + + if self.value: + s += repr(self.value) + if self.children: + s += ", " + + if positions and self.children: + s += self._pos_str() + + for i, child in enumerate(self.children): + if indent is not None: + s += "\n" + s += child._make_str( + indent=None if indent is None else indent + 1, positions=positions + ) + if i < len(self.children) - 1: + s += ", " + s += ")" + + if positions and not self.children: + s += self._pos_str() + + return s + + def pretty(self, positions=False): + return self._make_str(indent=0, positions=positions) + + def accept(self, visitor): + raise NotImplementedError() + + +class Script(Node): + def __init__(self, statements): + self.statements = statements + + def accept(self, visitor): + return visitor.visit_script(self) + + +class ProcDef(Node): + def __init__(self, name, args, body): + self.name = name + self.args = args + self.body = body + + def accept(self, visitor): + return visitor.visit_proc(self) + + +class SetStmt(Node): + def __init__(self, varname, value): + self.varname = varname + self.value = value + + def accept(self, visitor): + return visitor.visit_set(self) + + +class Namespace(Node): + def __init__(self, name, body): + self.name = name + self.body = body + + def accept(self, visitor): + return visitor.visit_namespace(self) + + +class CommandSubst(Node): + def __init__(self, name, args): + self.name = name + self.args = args + + def accept(self, visitor): + return visitor.visit_command(self) diff --git a/server/src/parser/lexer.py b/server/src/parser/lexer.py new file mode 100644 index 0000000..dc18312 --- /dev/null +++ b/server/src/parser/lexer.py @@ -0,0 +1,243 @@ +import ply.lex as lex +from typing import Tuple + +TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" +TOK_BACKSLASH_SUB = "BACKSLASH_SUB" +TOK_NEWLINE = "NEWLINE" +TOK_SEMI = "SEMI" +TOK_WS = "WS" +TOK_QUOTE = "QUOTE" +TOK_ARG_EXPANSION = "ARG_EXPANSION" +TOK_LBRACE = "LBRACE" +TOK_RBRACE = "RBRACE" +TOK_STAR = "STAR" +TOK_LBRACKET = "LBRACKET" +TOK_RBRACKET = "RBRACKET" +TOK_DOLLAR = "DOLLAR" +TOK_LPAREN = "LPAREN" +TOK_RPAREN = "RPAREN" +TOK_HASH = "HASH" +TOK_ALPHA_CHARS = "ALPHA_CHARS" +TOK_NUM_CHARS = "NUM_CHARS" +TOK_NAMESPACE_SEP = "NAMESPACE_SEP" +TOK_CHAR = "CHAR" +TOK_CONTENTS = "CONTENTS" +TOK_EOF = None + +STATE_BRACEDWORD = "bracedword" + + +class TclSyntaxError(Exception): + def __init__(self, message, start: Tuple[int, int], end: Tuple[int, int]): + super().__init__(message) + self.start = start + self.end = end + + +class _LexTable: + tokens = ( + TOK_BACKSLASH_NEWLINE, + TOK_BACKSLASH_SUB, + TOK_NEWLINE, + TOK_SEMI, + TOK_WS, + TOK_QUOTE, + TOK_ARG_EXPANSION, + TOK_LBRACE, + TOK_RBRACE, + TOK_STAR, + TOK_LBRACKET, + TOK_RBRACKET, + TOK_DOLLAR, + TOK_LPAREN, + TOK_RPAREN, + TOK_HASH, + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_NAMESPACE_SEP, + TOK_CHAR, + TOK_CONTENTS, + ) + + # This defines a conditional lexing state for parsing braced words. This is a + # performance optimization; since there are few special characters in this context, + # we can use a smaller set of tokens to parse them faster. This has a large impact + # since most Tcl programs have a large number of braced words. Any token with + # `bracedword` in its name is included in this state. Tokens that are included in + # this state and the default state also include `INITIAL` in their name. + states = ((STATE_BRACEDWORD, "exclusive"),) + + def _tok(self, t): + pos = (t.lexer.lineno, t.lexer.colno) + t.lexer.lineno += t.value.count("\n") + index = t.value.rfind("\n") + if index == -1: + t.lexer.colno += len(t.value) + else: + remaining = t.value[index + 1 :] + t.lexer.colno = len(remaining) + 1 + + t.value = (t.value, pos) + return t + + # Priority important + def t_bracedword_INITIAL_BACKSLASH_NEWLINE(self, t): + r"\\\n" + return self._tok(t) + + # Priority important + def t_bracedword_INITIAL_BACKSLASH_SUB(self, t): + r"\\." + return self._tok(t) + + def t_NEWLINE(self, t): + r"\n" + return self._tok(t) + + def t_SEMI(self, t): + r";" + return self._tok(t) + + # TODO: should use \s? + def t_WS(self, t): + r"[\t\v\f\r ]+" + return self._tok(t) + + def t_QUOTE(self, t): + r'"' + return self._tok(t) + + # Must be higher priority than LBRACE + def t_ARG_EXPANSION(self, t): + r"\{\*\}" + return self._tok(t) + + def t_bracedword_INITIAL_LBRACE(self, t): + r"\{" + return self._tok(t) + + def t_bracedword_INITIAL_RBRACE(self, t): + r"\}" + return self._tok(t) + + def t_STAR(self, t): + r"\*" + return self._tok(t) + + def t_LBRACKET(self, t): + r"\[" + return self._tok(t) + + def t_RBRACKET(self, t): + r"\]" + return self._tok(t) + + def t_DOLLAR(self, t): + r"\$" + return self._tok(t) + + def t_LPAREN(self, t): + r"\(" + return self._tok(t) + + def t_RPAREN(self, t): + r"\)" + return self._tok(t) + + def t_HASH(self, t): + r"\#" + return self._tok(t) + + # Valid non-numeric chars in variable names + def t_ALPHA_CHARS(self, t): + r"[A-Za-z_]+" + return self._tok(t) + + # Valid numeric chars in variable names + # This is split up from the above to facilitate expression parsing, since + # e.g. 1eq1 can't be a single token. + def t_NUM_CHARS(self, t): + r"[0-9]+" + return self._tok(t) + + def t_NAMESPACE_SEP(self, t): + r"::+" + return self._tok(t) + + def t_bracedword_CONTENTS(self, t): + r"[^{}\\]+" + return self._tok(t) + + # Catch-all. TODO: inefficient, should probably munch multiple chars + def t_CHAR(self, t): + r"." + return self._tok(t) + + # Error handling rule + # TODO: do we need this? since we have a catch-all... + # there is a warning + def t_bracedword_INITIAL_error(self, t): + print("Illegal character '%s'" % t.value[0]) + t.lexer.skip(1) + + def __init__(self): + self.lexer = lex.lex(object=self) + self.lexer.lineno = 1 + self.lexer.colno = 1 + + def new_lexer(self, pos=None): + lexer = self.lexer.clone() + lexer.lineno = 1 + lexer.colno = 1 + + if pos is not None: + line, col = pos + lexer.lineno = line + lexer.colno = col + + return lexer + + +# Calling `lex.lex()` performs an expensive reflection process to generate the lexer. +# This singleton class holds a preinitialized lexer that can then be cloned to create +# individual instances. +LexTable = _LexTable() + + +class Lexer: + def __init__(self, pos=None): + self.lexer = LexTable.new_lexer(pos) + self.current = None + + def input(self, text): + self.lexer.input(text) + self.current = self.lexer.token() + + def type(self): + if self.current is None: + return TOK_EOF + return self.current.type + + def value(self): + if self.current is None: + return None + return self.current.value[0] + + def pos(self): + if self.current is None: + return (self.lexer.lineno, self.lexer.colno) + return self.current.value[1] + + def next(self): + self.current = self.lexer.token() + + def expect(self, *tokens, message, pos): + if self.type() not in tokens: + self.next() # munch another token to update position + raise TclSyntaxError(message, pos, self.pos()) + + self.next() + + def assert_(self, *tokens): + assert self.current.type in tokens + self.next() diff --git a/server/src/parser/parser.py b/server/src/parser/parser.py index 4442313..5238b69 100644 --- a/server/src/parser/parser.py +++ b/server/src/parser/parser.py @@ -1,28 +1,124 @@ -from pathlib import Path -from lark import Lark - -# Grammar einlesen -with open(Path(__file__).parent.joinpath("tcl.lark"), encoding="utf-8") as f: - grammar = f.read() - -# LALR-Parser instanziieren -parser = Lark(grammar, parser="lalr", propagate_positions=True) +from parser.lexer import ( + TOK_ALPHA_CHARS, + TOK_DOLLAR, + TOK_EOF, + TOK_LBRACE, + TOK_LBRACKET, + TOK_NEWLINE, + TOK_NUM_CHARS, + TOK_RBRACE, + TOK_SEMI, + TOK_WS, + TclSyntaxError, +) +from parser.ast import CommandSubst, Namespace, ProcDef, Script, SetStmt -def parse(source: str): - return parser.parse(source) +class Parser: + def __init__(self, lexer): + self.lexer = lexer + def parse(self): + statements = [] + while self.lexer.type() != TOK_EOF: + if self.lexer.type() in (TOK_WS, TOK_NEWLINE, TOK_SEMI): + self.lexer.next() + continue + statements.append(self.parse_statement()) + return Script(statements) -if __name__ == "__main__": - samples = [ - "set x 42", - "set myArray(1) 123", - "set myArray2(1,2,3,$string) 123", - "proc myProc {} {}", - "proc myProc {arg} {}", - "proc myProc {arg arg2} {}", - "proc myProc {arg {arg2 test}} {}", - ] - for s in samples: - tree = parse(s) - print(tree.pretty()) + def parse_statement(self): + if self.lexer.type() == TOK_ALPHA_CHARS: + cmd = self.lexer.value() + if cmd == "proc": + return self.parse_proc() + elif cmd == "set": + return self.parse_set() + elif cmd == "namespace": + return self.parse_namespace() + else: + return self.parse_command() + else: + raise TclSyntaxError( + "Unknown statement", self.lexer.pos(), self.lexer.pos() + ) + + def parse_proc(self): + self.lexer.next() # skip 'proc' + name = self.expect_value(TOK_ALPHA_CHARS) + args = self.parse_arguments() + body = self.parse_body() + return ProcDef(name, args, body) + + def parse_arguments(self): + args = [] + self.expect_token(TOK_LBRACE) + while self.lexer.type() != TOK_RBRACE: + if self.lexer.type() == TOK_LBRACE: + self.lexer.next() + arg_name = self.expect_value(TOK_ALPHA_CHARS) + default = ( + self.expect_value(TOK_ALPHA_CHARS) + if self.lexer.type() != TOK_RBRACE + else None + ) + self.expect_token(TOK_RBRACE) + args.append((arg_name, default)) + else: + args.append((self.expect_value(TOK_ALPHA_CHARS), None)) + self.expect_token(TOK_RBRACE) + return args + + def parse_body(self): + if self.lexer.type() == TOK_LBRACE: + self.lexer.next() + body_tokens = [] + while self.lexer.type() != TOK_RBRACE: + body_tokens.append(self.lexer.value()) + self.lexer.next() + self.lexer.next() # skip RBRACE + return " ".join(body_tokens) + else: + raise TclSyntaxError("Expected body", self.lexer.pos(), self.lexer.pos()) + + def parse_set(self): + self.lexer.next() + varname = self.expect_value(TOK_ALPHA_CHARS) + value = self.expect_value(TOK_ALPHA_CHARS) + return SetStmt(varname, value) + + def parse_namespace(self): + self.lexer.next() # skip 'namespace' + self.expect_token(TOK_ALPHA_CHARS) # 'eval' + name = self.expect_value(TOK_ALPHA_CHARS) + body = self.parse_body() + return Namespace(name, body) + + def parse_command(self): + name = self.expect_value(TOK_ALPHA_CHARS) + args = [] + while self.lexer.type() in ( + TOK_ALPHA_CHARS, + TOK_NUM_CHARS, + TOK_LBRACKET, + TOK_DOLLAR, + ): + args.append(self.lexer.value()) + self.lexer.next() + return CommandSubst(name, args) + + def expect_token(self, token): + if self.lexer.type() != token: + raise TclSyntaxError( + f"Expected {token}", self.lexer.pos(), self.lexer.pos() + ) + self.lexer.next() + + def expect_value(self, token): + if self.lexer.type() != token: + raise TclSyntaxError( + f"Expected {token}", self.lexer.pos(), self.lexer.pos() + ) + value = self.lexer.value() + self.lexer.next() + return value diff --git a/server/src/test_tcl.py b/server/src/test_tcl.py index 89cb451..783c406 100644 --- a/server/src/test_tcl.py +++ b/server/src/test_tcl.py @@ -1,9 +1,100 @@ -from tools.parser import Parser +from tclint.parser import Parser as tcLintParser +from tclint.lexer import Lexer as tclingLexer +from parser.lexer import Lexer +from parser.parser import Parser def main(): - parser = Parser(True) - parser.parse("puts hello") + parser = tcLintParser(True) + tree = parser.parse("""puts hello + proc myProc {arg {arg7 0}} {} + set myVar 123 + puts puts + LIB_SPF_prepend MOM_strt Start_Lib { + set somthing 1 + set more 2 +} myTag + +LIB_SPF_prepend MOM_strt Start_Lib { + proc test {} { + puts "Hello" + } + set somthing 1 + set someting 3 +} myTag""") + + print(tree.pretty(2)) + + +def lexer_test(): + lexer = tclingLexer() + tree = lexer.input("""puts hello + proc myProc {arg {arg7 0}} {} + set myVar 123 + puts puts + LIB_SPF_prepend MOM_strt Start_Lib { + set somthing 1 + set more 2 +} myTag + +LIB_SPF_prepend MOM_strt Start_Lib { + proc test {} { + puts "Hello" + } + set somthing 1 + set someting 3 +} myTag""") + + # print("Lexing input:\n", code) + # print("\nTokens:\n" + "-" * 30) + + while lexer.type() is not None: + tok_type = lexer.type() + tok_value = lexer.value() + tok_pos = lexer.pos() + print(f"Type: {tok_type:20} | Value: {repr(tok_value):20} | Pos: {tok_pos}") + lexer.next() + + +def test_1(): + code = """ + proc myProc {arg } { + set myVar 1 + } + + namespace eval myNS { + proc innerProc {} { + MOM_abort_program "Test" + } + } + set result [myNS::innerProc] + """ + + lexer = Lexer() + lexer.input(code) + parser = Parser(lexer) + ast = parser.parse() + + visitor = NodeVisitor() + ast.accept(visitor) + + +class NodeVisitor: + def visit_script(self, node): + for stmt in node.statements: + stmt.accept(self) + + def visit_proc(self, node): + print(f"Proc: {node.name}, args={node.args}") + + def visit_set(self, node): + print(f"Set: {node.varname} = {node.value}") + + def visit_namespace(self, node): + print(f"Namespace: {node.name}") + + def visit_command(self, node): + print(f"Command: {node.name} {node.args}") if __name__ == "__main__": diff --git a/server/src/tools/commands/__init__.py b/server/src/tools/commands/__init__.py deleted file mode 100644 index f498d2c..0000000 --- a/server/src/tools/commands/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -import pathlib -from typing import List, Dict, Union - -from tools.commands import builtin as _builtin - - -# import to expose in package -from tools.commands.checks import CommandArgError - -__all__ = ["CommandArgError", "get_commands"] - - -def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict: - commands = {} - commands.update(_builtin.commands) - - return commands diff --git a/server/src/tools/formatter.py b/server/src/tools/formatter.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/tools/nx_plugins.py b/server/src/tools/nx_plugins.py new file mode 100644 index 0000000..dd9ac25 --- /dev/null +++ b/server/src/tools/nx_plugins.py @@ -0,0 +1,16 @@ +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/parser.py b/server/src/tools/parser.py index 27145d8..07c10a7 100644 --- a/server/src/tools/parser.py +++ b/server/src/tools/parser.py @@ -1,892 +1,49 @@ -import string -import re - -from tools.commands.checks import CommandArgError, check_command -from tools.lexer import Lexer, TclSyntaxError, Tok, TOK_EOF, STATE_BRACEDWORD -from tools.syntax_tree import ( - ArgExpansion, - BareWord, - BinaryOp, - BracedExpression, - BracedWord, - Command, - CommandSub, - Comment, - CompoundBareWord, - Expression, - Function, - List, - ParenExpression, - QuotedWord, - Script, - TernaryOp, - UnaryOp, - VarSub, +import textwrap +from tclint.parser import Parser +from tclint.lexer import ( + STATE_BRACEDWORD, + TOK_LBRACE, + TOK_EOF, + TOK_RBRACE, + TclSyntaxError, ) +from tclint.syntax_tree import BracedWord -def _strip_ws(parse_func): - """Decorator used by expression parser for stripping whitespace around a node.""" - - def func(parser, ts): - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: - ts.next() - - node = parse_func(parser, ts) - - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE}: - ts.next() - - return node - - return func - - -class _Word: - """Helper class for constructing Word nodes out of multiple segments.""" - - def __init__(self): - self.segments = [] - self.current_segment = "" - self.current_start = None - - def add_tok(self, tok): - if self.current_start is None: - self.current_start = tok.value[1] - self.current_segment += tok.value[0] - - def add_node(self, node): - if self.current_segment != "": - self.segments.append( - BareWord(self.current_segment, pos=self.current_start, end_pos=node.pos) - ) - self.current_segment = "" - self.current_start = None - self.segments.append(node) - - def resolve(self, end_pos): - if self.current_segment: - self.segments.append( - BareWord(self.current_segment, pos=self.current_start, end_pos=end_pos) - ) - - return self.segments - - -class Parser: - def __init__(self, debug=False): - self._debug = debug - self._debug_indent = 0 - self.violations = [] - - self._commands = [] - - def debug(self, *msg): - if self._debug: - print(" " * self._debug_indent, end="") - print(*msg) - - def parse(self, script, pos=None): - lexer = Lexer(pos=pos) - lexer.input(script) - tree = self._parse_script(lexer, in_command_sub=False) - assert lexer.type() == TOK_EOF, ( - "Didn't reach EOF parsing script, please file a bug report." - ) - - return tree - - def _parse_command_args(self, routine, args): - """Since many built-in Tcl commands take in Tcl scripts or expressions - as arguments, building a complete parse tree requires checking command - names and possibly parsing their arguments. - - The node of any argument that gets parsed by this method is replaced with - the parse tree of that argument. Since this process requires checking - the arguments provided to these commands, this method may report lint - violations. - - This parsing process is analogous to how the Tcl interpreter interprets - scripts, and better handles weird edge cases compared to a traditional - parsing technique. For example, this may look like valid Tcl: - - proc foo {a} { - # output } - puts "}" - } - - But really, it is invalid since the } in the comment terminates the body - of the proc - Tcl blindly constructs the body of the proc until it - reaches the first }. tclint handles this correctly. - """ - if routine not in self._commands: - return args - - spec = self._commands[routine] - - try: - new_args = check_command(routine, args, self, spec) - except TclSyntaxError as e: - raise e - except CommandArgError as e: - raise e - except Exception: - if self._debug: - raise - raise CommandArgError( - f"error parsing command arguments, possibly malformed {routine} command" - ) - - if new_args is None: - return args - - return new_args - - def parse_script(self, node): - if node.contents is None: - raise CommandArgError( - "expected braced word or word without substitutions in argument" - " interpreted as script" - ) - - script = self.parse(node.contents, pos=node.contents_pos) - if isinstance(node, BracedWord): - script.braced = True - - script.line = node.line - script.col = node.col - script.end_pos = node.end_pos - - return script - - def _parse_script(self, ts, in_command_sub): - self.debug(f"parse_script({ts.current})") - self._debug_indent += 1 - pos = ts.pos() - - if in_command_sub: - script = CommandSub(pos=pos) - else: - script = Script(pos=pos) - - while ts.type() is not TOK_EOF: - if ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: - # strip whitespace at start of command - ts.next() - continue - - if ts.type() == Tok.TOK_HASH: - script.add(self.parse_comment(ts)) - else: - cmd = self.parse_command(ts, in_command_sub=in_command_sub) - if cmd is not None: - script.add(cmd) - - # when in command sub mode, a script is terminated by ] - if in_command_sub and ts.type() == Tok.TOK_RBRACKET: - return script - - ts.expect( - TOK_EOF, - Tok.TOK_NEWLINE, - Tok.TOK_SEMI, - message=f"expected newline or semicolon, got {ts.value()}", - pos=ts.pos(), - ) - - if in_command_sub and ts.type() is TOK_EOF: - raise TclSyntaxError( - "reached EOF without finding end of command substitution", pos, ts.pos() - ) - - self._debug_indent -= 1 - - script.end_pos = ts.pos() - - return script - - def parse_comment(self, ts): - self.debug(f"parse_comment({ts.current})") - pos = ts.pos() - - ts.assert_(Tok.TOK_HASH) - - value = "" - while ts.type() not in {Tok.TOK_NEWLINE, TOK_EOF}: - value += ts.value() - ts.next() - - # Stripping trailing whitespace from comments here allows tclfmt to clean it up - # without affecting the AST. - value = value.rstrip() - - return Comment(value, pos=pos, end_pos=ts.pos()) - - def parse_command(self, ts, in_command_sub): - self.debug(f"parse_command({ts.current})") - self._debug_indent += 1 - pos = ts.pos() - - routine = self.parse_word(ts, in_command_sub) - if routine is None: - self._debug_indent -= 1 - return None - - args = [] - while True: - if ts.type() not in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: - break - - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: - ts.next() - - word = self.parse_word(ts, in_command_sub) - if word is None: - break - - args.append(word) - - self._debug_indent -= 1 - - try: - pass - parsed_args = self._parse_command_args(routine.contents, args) - except CommandArgError as e: - # self.violations.append(Violation(Rule.COMMAND_ARGS, str(e), pos, ts.pos())) - parsed_args = args - - children = [routine, *parsed_args] - # We need to inherit end pos of last child to prevent us from counting - # extra whitespace at end of command, which is important for - # spaces-in-braces check. - return Command(*children, pos=pos, end_pos=children[-1].end_pos) - - def parse_word(self, ts, in_command_sub): - self.debug(f"parse_word({ts.current})") - if ts.type() == Tok.TOK_ARG_EXPANSION: - return self.parse_arg_expansion(ts, in_command_sub) - elif ts.type() == Tok.TOK_LBRACE: - return self.parse_braced_word(ts) - elif ts.type() == Tok.TOK_QUOTE: - return self.parse_quoted_word(ts) - else: - return self.parse_bare_word(ts, in_command_sub) - - def parse_arg_expansion(self, ts, in_command_sub): - self.debug(f"parse_arg_expansion({ts.current})") - pos = ts.pos() - - ts.assert_(Tok.TOK_ARG_EXPANSION) - - delimiters = [ - Tok.TOK_WS, - Tok.TOK_BACKSLASH_NEWLINE, - Tok.TOK_NEWLINE, - Tok.TOK_SEMI, - TOK_EOF, - ] - if in_command_sub: - delimiters.append(Tok.TOK_RBRACKET) - - # Arg expansion is just a regular braced word if followed by whitespace, - # or other word boundaries such as semicolon or right bracket - # (in command substitution) - if ts.type() in delimiters: - return BracedWord("*", pos=pos, end_pos=ts.pos()) - - return ArgExpansion( - self.parse_word(ts, in_command_sub), pos=pos, end_pos=ts.pos() - ) - - def parse_quoted_word(self, ts): - self.debug(f"parse_quoted_word({ts.current})") - self._debug_indent += 1 - pos = ts.pos() - - ts.assert_(Tok.TOK_QUOTE) - - word = _Word() - while ts.type() not in {Tok.TOK_QUOTE, TOK_EOF}: - if ts.type() == Tok.TOK_DOLLAR: - dollar_tok = ts.current - var_sub = self.parse_var_sub(ts) - if var_sub: - word.add_node(var_sub) - else: - word.add_tok(dollar_tok) - elif ts.type() == Tok.TOK_LBRACKET: - command_sub = self.parse_command_sub(ts) - word.add_node(command_sub) - else: - word.add_tok(ts.current) - ts.next() - - res = word.resolve(ts.pos()) - - ts.expect( - Tok.TOK_QUOTE, - message="reached EOF without finding match for quote", - pos=pos, - ) - - self._debug_indent -= 1 - - if not res: - res = [] - - return QuotedWord(*res, pos=pos, end_pos=ts.pos()) - +class CustomParser(Parser): def parse_braced_word(self, ts): - self.debug(f"parse_braced_word({ts.current})") + """ + Ersetzt BracedWord durch echtes Script, wenn mehrzeilig. + """ pos = ts.pos() - ts.lexer.push_state(STATE_BRACEDWORD) + ts.assert_(TOK_LBRACE) - ts.assert_(Tok.TOK_LBRACE) - - word = "" - # store position for each brace we want to match, facilitating good - # error messages - expected_braces = [pos] + content = "" + expected = [pos] while True: - toktype = ts.type() - if toktype == TOK_EOF: + t = ts.type() + if t == TOK_EOF: raise TclSyntaxError( "reached EOF without finding match for brace", - expected_braces[-1], + expected[-1], ts.pos(), ) - - if toktype == Tok.TOK_LBRACE: - expected_braces.append(ts.pos()) - elif toktype == Tok.TOK_RBRACE: - try: - expected_braces.pop() - except IndexError: - start = ts.pos() - ts.next() - end = ts.pos() - raise TclSyntaxError( - "found closing brace without matching open brace", start, end - ) - - if len(expected_braces) == 0: + if t == TOK_LBRACE: + expected.append(ts.pos()) + elif t == TOK_RBRACE: + expected.pop() + if not expected: ts.lexer.pop_state() ts.next() break - word += ts.value() + content += ts.value() ts.next() end_pos = ts.pos() - return BracedWord(word, pos=pos, end_pos=end_pos) + # Mehrzeilig? Dann als Script parsen: + if "\n" in content.strip(): + self.parse_script(content) - def parse_bare_word(self, ts, in_command_sub): - self.debug(f"parse_bare_word({ts.current})") - self._debug_indent += 1 - pos = ts.pos() - - word = _Word() - delimiters = [ - Tok.TOK_WS, - Tok.TOK_BACKSLASH_NEWLINE, - Tok.TOK_NEWLINE, - Tok.TOK_SEMI, - TOK_EOF, - ] - - # In command sub mode, words are ended by ] - if in_command_sub: - delimiters.append(Tok.TOK_RBRACKET) - - while ts.type() not in delimiters: - if ts.type() == Tok.TOK_DOLLAR: - dollar_tok = ts.current - var_sub = self.parse_var_sub(ts) - if var_sub: - word.add_node(var_sub) - else: - word.add_tok(dollar_tok) - elif ts.type() == Tok.TOK_LBRACKET: - command_sub = self.parse_command_sub(ts) - word.add_node(command_sub) - else: - word.add_tok(ts.current) - ts.next() - - res = word.resolve(ts.pos()) - - self._debug_indent -= 1 - - if not res: - return None - if len(res) == 1: - return res[0] - return CompoundBareWord(*res, pos=pos, end_pos=ts.pos()) - - def parse_var_sub(self, ts): - self.debug(f"parse_var_sub({ts.current})") - pos = ts.pos() - - ts.assert_(Tok.TOK_DOLLAR) - - var = "" - if ts.type() == Tok.TOK_LBRACE: - brace_pos = ts.pos() - ts.next() - while ts.type() != Tok.TOK_RBRACE: - if ts.type() is TOK_EOF: - raise TclSyntaxError( - "reached EOF without finding match for brace", - brace_pos, - ts.pos(), - ) - var += ts.value() - ts.next() - ts.next() - - return VarSub(var, pos=pos, end_pos=ts.pos(), braced=True) - - while ts.type() in { - Tok.TOK_ALPHA_CHARS, - Tok.TOK_NUM_CHARS, - Tok.TOK_NAMESPACE_SEP, - }: - var += ts.value() - ts.next() - - if not var: - return None - - index_nodes = [] - if ts.type() == Tok.TOK_LPAREN: - paren_pos = ts.pos() - index = _Word() - ts.next() - while ts.type() != Tok.TOK_RPAREN: - if ts.type() == TOK_EOF: - raise TclSyntaxError( - "reached EOF without finding match for paren", - paren_pos, - ts.pos(), - ) - if ts.type() == Tok.TOK_DOLLAR: - dollar_tok = ts.current - var_sub = self.parse_var_sub(ts) - if var_sub: - index.add_node(var_sub) - else: - index.add_tok(dollar_tok) - elif ts.type() == Tok.TOK_LBRACKET: - command_sub = self.parse_command_sub(ts) - index.add_node(command_sub) - else: - index.add_tok(ts.current) - ts.next() - - index_nodes = index.resolve(ts.pos()) - ts.next() - - var_sub = VarSub(var, pos=pos, end_pos=ts.pos()) - - for index_segment in index_nodes: - var_sub.add(index_segment) - - return var_sub - - def parse_command_sub(self, ts): - self.debug(f"parse_command_sub({ts.current})") - self._debug_indent += 1 - - pos = ts.pos() - ts.assert_(Tok.TOK_LBRACKET) - - script = self._parse_script(ts, in_command_sub=True) - - ts.assert_(Tok.TOK_RBRACKET) - end_pos = ts.pos() - - script.line = pos[0] - script.col = pos[1] - script.end_pos = end_pos - - self._debug_indent -= 1 - return script - - def parse_list(self, node): - """Parse contents of node as Tcl list. This is a distinct entry point - that doesn't get used when generating the main syntax tree, but is used - in command-specific argument parsing. - """ - if isinstance(node, List): - return node - - if node.contents is None: - raise CommandArgError( - "expected braced word or word without substitutions in argument" - " interpreted as list" - ) - - ts = Lexer(pos=node.contents_pos) - ts.input(node.contents) - - DELIMITERS = {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE, Tok.TOK_NEWLINE} - - list_node = List(pos=node.pos, end_pos=node.end_pos) - while ts.type() is not TOK_EOF: - while ts.type() in DELIMITERS: - ts.next() - - if ts.type() is TOK_EOF: - break - - if ts.type() == Tok.TOK_LBRACE: - # we can reuse parse_braced_word, since it doesn't use - # substitutions in any case - list_node.add(self.parse_braced_word(ts)) - elif ts.type() == Tok.TOK_QUOTE: - quote_word_pos = ts.pos() - - ts.assert_(Tok.TOK_QUOTE) - - bare_word_pos = ts.pos() - contents = "" - while ts.type() not in {Tok.TOK_QUOTE, TOK_EOF}: - contents += ts.value() - ts.next() - word = BareWord(contents, pos=bare_word_pos, end_pos=ts.pos()) - - ts.expect( - Tok.TOK_QUOTE, - message="reached EOF without finding match for quote", - pos=quote_word_pos, - ) - - list_node.add(QuotedWord(word, pos=quote_word_pos, end_pos=ts.pos())) - else: - pos = ts.pos() - contents = "" - while ts.type() not in {*DELIMITERS, TOK_EOF}: - contents += ts.value() - ts.next() - list_node.add(BareWord(contents, pos=pos, end_pos=ts.pos())) - - return list_node - - def parse_expression(self, node): - if node.contents is None: - raise CommandArgError( - "expected braced word or word without substitutions in argument" - " interpreted as expr" - ) - - ts = Lexer(pos=node.contents_pos) - ts.input(node.contents) - - contents = self._parse_expression(ts) - ts.expect( - TOK_EOF, - message=f"expected end of expression, got {ts.value()}", - pos=ts.pos(), - ) - if isinstance(node, BracedWord): - return BracedExpression(contents, pos=node.pos, end_pos=node.end_pos) - - return Expression(contents, pos=node.pos, end_pos=node.end_pos) - - @_strip_ws - def _parse_expression(self, ts): - op1 = self._parse_operand(ts) - expr = op1 - - # last condition is hack to break out of expression in case we're in ternary op - if ts.type() not in {TOK_EOF, Tok.TOK_RPAREN} and ts.value() not in {":", ","}: - if ts.value() == "?": - # weird hack to record operator - start = ts.pos() - ts.next() - q = BareWord("?", pos=start, end_pos=ts.pos()) - - op2 = self._parse_expression(ts) - if ts.value() != ":": - start = ts.pos() - ts.next() - end = ts.pos() - raise TclSyntaxError( - "expected ':' to continue ternary expression", start, end - ) - - # weird hack again - start = ts.pos() - ts.next() - colon = BareWord(":", pos=start, end_pos=ts.pos()) - - op3 = self._parse_expression(ts) - expr = TernaryOp( - op1, q, op2, colon, op3, pos=op1.pos, end_pos=op3.end_pos - ) - else: - operator = self._parse_operator(ts) - op2 = self._parse_expression(ts) - expr = BinaryOp(op1, operator, op2, pos=op1.pos, end_pos=op2.end_pos) - - if ts.type() != Tok.TOK_RPAREN and ts.value() not in {":", ","}: - ts.expect(TOK_EOF, message="expected end of expression", pos=ts.pos()) - - return expr - - @_strip_ws - def _parse_operand(self, ts): - if ts.type() == Tok.TOK_DOLLAR: - return self.parse_var_sub(ts) - if ts.type() == Tok.TOK_QUOTE: - return self.parse_quoted_word(ts) - if ts.type() == Tok.TOK_LBRACE: - return self.parse_braced_word(ts) - if ts.type() == Tok.TOK_LBRACKET: - return self.parse_command_sub(ts) - if ts.type() == Tok.TOK_LPAREN: - start = ts.pos() - ts.next() - expr = self._parse_expression(ts) - ts.expect( - Tok.TOK_RPAREN, - message="reached EOF without finding match for paren", - pos=expr.pos, - ) - end = ts.pos() - return ParenExpression(expr, start, end) - if ts.value() in {"-", "+", "~", "!"}: - operator_val = ts.value() - operator_pos = ts.pos() - ts.next() - operator = BareWord(operator_val, pos=operator_pos, end_pos=ts.pos()) - operand = self._parse_operand(ts) - # Since _parse_operand() munches whitespace after the operand, we - # set the end of the UnaryOp to the end of the operand rather than - # ts.pos(). Otherwise, the bounds of the UnaryOp would include all - # that whitespace. - return UnaryOp(operator, operand, pos=operator_pos, end_pos=operand.end_pos) - - # If none of these, collect tokens that may comprise an operand - operand = "" - operand_pos = ts.pos() - - # First, we want to check for numeric operands (either ints or numeric - # floats) by consuming tokens as long as they comprise the prefix of a - # numeric operand - while ts.type() != TOK_EOF and ( - _is_int_prefix(operand + ts.value()) - or _is_float_prefix(operand + ts.value()) - ): - operand += ts.value() - ts.next() - - # Next, we check if we've consumed an entire numeric literal. If so, we - # move on. If not, we keep consuming tokens that may correspond to a - # valid bareword (pretty much just alphanumeric chars). - if not (_is_int_literal(operand) or _is_float_literal(operand)): - while ts.type() in {Tok.TOK_ALPHA_CHARS, Tok.TOK_NUM_CHARS}: - operand += ts.value() - ts.next() - - # The above method is a little hacky. Note that it doesn't parse things - # exactly the same as Tcl. E.g. if a script includes `expr {1foo}`, - # tclint will report an invalid operator "foo", whereas tclsh will - # report an invalid bareword "1foo". Despite reporting them differently - # both tools should still catch the same syntax errors, since there are - # no legal barewords that begin with a numeric literal prefix, and tclsh - # will stop parsing numeric operands if they're actually followed by a - # legal operator (e.g. `expr {1eq1}` will be handled properly). - - is_func = _is_function(operand) - - if not ( - _is_int_literal(operand) - or _is_float_literal(operand) - or _is_bool_literal(operand) - or is_func - ): - raise TclSyntaxError( - f"invalid bareword in expression: {operand}", operand_pos, ts.pos() - ) - - node = BareWord(operand, pos=operand_pos, end_pos=ts.pos()) - - if is_func: - node = self._parse_function(ts, node) - - return node - - def _parse_operator(self, ts): - pos = ts.pos() - - # hacky logic to handle parsing legal operators - - if ts.value() in {"*", "&", "|"}: - # one or two of these characters are legal operators - operator = ts.value() - ts.next() - if ts.value() == operator: - operator += ts.value() - ts.next() - elif ts.value() in {"<", ">"}: - operator = ts.value() - ts.next() - if ts.value() in {operator, "="}: - operator += ts.value() - ts.next() - elif ts.value() in {"=", "!"}: - operator = ts.value() - ts.next() - if ts.value() != "=": - raise TclSyntaxError( - f"invalid operator in expression: {operator}", pos, ts.pos() - ) - operator += ts.value() - ts.next() - elif ts.value() in {"*", "/", "%", "+", "-", "^", "eq", "ne", "in", "ni"}: - operator = ts.value() - ts.next() - else: - raise TclSyntaxError( - f"invalid operator in expression: {ts.value()}", pos, ts.pos() - ) - - return BareWord(operator, pos=pos, end_pos=ts.pos()) - - def _parse_function(self, ts, name): - while ts.type() in {Tok.TOK_WS, Tok.TOK_BACKSLASH_NEWLINE}: - ts.next() - - ts.expect( - Tok.TOK_LPAREN, - message="expected open paren after function name", - pos=name.pos, - ) - - delims = {Tok.TOK_RPAREN, TOK_EOF} - - arguments = [] - if ts.type() not in delims: - arguments.append(self._parse_expression(ts)) - - while ts.type() not in delims: - if ts.value() != ",": - start = ts.pos() - ts.next() - end = ts.pos() - raise TclSyntaxError( - "expected comma between function arguments", start, end - ) - ts.next() - - arguments.append(self._parse_expression(ts)) - - ts.expect( - Tok.TOK_RPAREN, - message="expected close paren after function arguments", - pos=name.pos, - ) - return Function(name, *arguments, pos=name.pos, end_pos=ts.pos()) - - -def _all(_list, non_empty=False): - """Like all(), but if non_empty is True, list must also have at least 1 element.""" - if non_empty and len(_list) == 0: - return False - return all(_list) - - -def _is_int(operand, full=False): - """Returns whether operand is a valid Tcl integer literal. - - If full is False, will also return True if operand is the prefix of an - integer literal. An empty string is not a valid full literal, but is a - valid prefix. - """ - # prefixes - if operand.startswith("0b"): - return _all([digit in "01" for digit in operand[2:]], non_empty=full) - if operand.startswith("0o"): - return _all( - [digit in string.octdigits for digit in operand[2:]], non_empty=full - ) - if operand.startswith("0x"): - return _all( - [digit in string.hexdigits for digit in operand[2:]], non_empty=full - ) - if operand.startswith("0"): - # fun fact: apparently a lone 0 prefix is interpreted as octal - return _all( - [digit in string.octdigits for digit in operand[1:]], non_empty=full - ) - - return _all([digit in string.digits for digit in operand], non_empty=full) - - -def _is_int_literal(operand): - return _is_int(operand, full=True) - - -def _is_int_prefix(operand): - return _is_int(operand, full=False) - - -def _is_float_literal(operand): - if operand.lower() in {"nan", "inf"}: - return True - - return ( - operand != "" and re.fullmatch(r"\d*\.?\d*([Ee][+-]?\d+)?", operand) is not None - ) - - -def _is_float_prefix(operand): - """Returns whether operand is the prefix of a valid numeric float literal.""" - return re.fullmatch(r"\d*\.?\d*([Ee][+-]?)?\d*", operand) is not None - - -def _is_bool_literal(operand): - return operand in {"false", "no", "off", "true", "yes", "on"} - - -# map of function names to # arguments accepted -# None indicates 1 or more arguments -# TODO: use these values in an actual separate check. they might want to live elsewhere -_FUNCTIONS = { - "abs": 1, - "acos": 1, - "asin": 1, - "atan": 1, - "atan2": 2, - "bool": 1, - "ceil": 1, - "cos": 1, - "cosh": 1, - "double": 1, - "entier": 1, - "exp": 1, - "floor": 1, - "fmod": 2, - "hypot": 2, - "int": 1, - "isqrt": 1, - "log": 1, - "log10": 1, - "max": None, - "min": None, - "pow": 2, - "rand": 0, - "round": 1, - "sin": 1, - "sinh": 1, - "sqrt": 1, - "srand": 1, - "tan": 1, - "tanh": 1, - "wide": 1, -} - - -def _is_function(operand): - return operand in _FUNCTIONS.keys() + # Einzeilig: unverändert als Literal + return BracedWord(content, pos=pos, end_pos=end_pos) diff --git a/server/src/tools/semantic_tokens.py b/server/src/tools/semantic_tokens.py new file mode 100644 index 0000000..ffc80ac --- /dev/null +++ b/server/src/tools/semantic_tokens.py @@ -0,0 +1,76 @@ +from tclint.parser import Parser +from tclint.syntax_tree import Visitor, BareWord, VarSub, Comment, Command, Function + +TOKEN_TYPES = { + "command": 0, + "variable": 1, + "function": 2, + "string": 3, + "number": 4, + "keyword": 5, + "comment": 6, +} + + +class SemanticTokenCollector(Visitor): + def __init__(self): + self.tokens = [] + + def _add_token(self, node, token_type): + if not node.pos or not node.end_pos: + return + + line, col = node.pos + end_line, end_col = node.end_pos + length = (end_col - col) if line == end_line else 1 + + self.tokens.append((line - 1, col - 1, length, token_type, 0)) + + def visit_command(self, command: Command): + self._add_token(command.routine, "command") + for arg in command.args: + arg.accept(self, recurse=True) + + def visit_comment(self, comment: Comment): + self._add_token(comment, "comment") + + def visit_bare_word(self, word: BareWord): + if word.value.isdigit(): + self._add_token(word, "number") + else: + self._add_token(word, "string") + + def visit_var_sub(self, var_sub: VarSub): + self._add_token(var_sub, "variable") + + def visit_function(self, function: Function): + self._add_token(function.name, "function") + for arg in function.args: + arg.accept(self, recurse=True) + + +def collect_semantic_tokens(code: str): + parser = Parser() + tree = parser.parse(code) + visitor = SemanticTokenCollector() + tree.accept(visitor, recurse=True) + return visitor.tokens + + +def encode_tokens(tokens): + tokens.sort() + encoded = [] + + last_line = 0 + last_char = 0 + + for line, char, length, token_type, modifiers in tokens: + delta_line = line - last_line + delta_start = char - last_char if delta_line == 0 else char + + encoded.extend([delta_line, delta_start, length, token_type, modifiers]) + + last_line = line + last_char = char if delta_line == 0 else 0 + + return encoded diff --git a/test/test.tcl b/test/test.tcl index bbac630..8779fbf 100644 --- a/test/test.tcl +++ b/test/test.tcl @@ -2,4 +2,12 @@ proc myProc {arg {opt 1}} { } +set myVar 1 + +namespace eval myNameSpace { + proc namespaceProc {} {} +} + +set result [myNameSpace::namespaceProc] + MOM_abort_program "Test" \ No newline at end of file -- 2.54.0 From 4d79f268b8bdfd2098e26ac511649e2e25ee6613 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Sun, 27 Jul 2025 22:11:16 +0200 Subject: [PATCH 09/10] 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 -- 2.54.0 From 9a56aca6ef244c9ac9b4b6c3f9b7d9492ac48b21 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 28 Jul 2025 22:15:19 +0200 Subject: [PATCH 10/10] modify linter / checks --- server/src/lsp_server.py | 20 +- server/src/nx_plugins/poco_plugin.py | 13 - server/src/parser/ast.py | 93 ------- server/src/parser/lexer.py | 243 ------------------ server/src/parser/parser.py | 124 --------- .../src/{nx_plugins => plugins}/__init__.py | 0 server/src/plugins/poco_plugin.py | 48 ++++ server/src/tools/checks.py | 4 + 8 files changed, 59 insertions(+), 486 deletions(-) delete mode 100644 server/src/nx_plugins/poco_plugin.py delete mode 100644 server/src/parser/ast.py delete mode 100644 server/src/parser/lexer.py delete mode 100644 server/src/parser/parser.py rename server/src/{nx_plugins => plugins}/__init__.py (100%) create mode 100644 server/src/plugins/poco_plugin.py create mode 100644 server/src/tools/checks.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index a359eb1..707f401 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -54,17 +54,18 @@ from tools.semantic_tokens import ( collect_semantic_tokens, encode_tokens, ) -from nx_plugins.poco_plugin import commands -from tools import poco_check +from plugins.poco_plugin import commands +from tools import checks -DIAGNOSTIC_SOURCE = "tclint" +DIAGNOSTIC_SOURCE = "nx-post-support" class TclLanguageServer(server.LanguageServer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.parser = Parser() - self.parser._commands.update(commands) + for command in commands: + self.parser._commands.update(command) self.diagnostics = {} def format( @@ -98,19 +99,12 @@ class TclLanguageServer(server.LanguageServer): document: TextDocument, ) -> List[Violation]: violations = [] + self.parser.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(): + for checker in checks.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): diff --git a/server/src/nx_plugins/poco_plugin.py b/server/src/nx_plugins/poco_plugin.py deleted file mode 100644 index 622864e..0000000 --- a/server/src/nx_plugins/poco_plugin.py +++ /dev/null @@ -1,13 +0,0 @@ -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/parser/ast.py b/server/src/parser/ast.py deleted file mode 100644 index f7c937c..0000000 --- a/server/src/parser/ast.py +++ /dev/null @@ -1,93 +0,0 @@ -class Node: - def _pos_str(self): - start_pos_str = "?" - if self.pos is not None: - start_pos_str = f"{self.pos[0]}:{self.pos[1]}" - end_pos_str = "?" - if self.end_pos is not None: - end_pos_str = f"{self.end_pos[0]}:{self.end_pos[1]}" - - return f" # {start_pos_str}-{end_pos_str}" - - def _make_str(self, indent=None, positions=False): - if indent is not None: - s = " " * indent - else: - s = "" - - s += self.__class__.__name__ - s += "(" - - if self.value: - s += repr(self.value) - if self.children: - s += ", " - - if positions and self.children: - s += self._pos_str() - - for i, child in enumerate(self.children): - if indent is not None: - s += "\n" - s += child._make_str( - indent=None if indent is None else indent + 1, positions=positions - ) - if i < len(self.children) - 1: - s += ", " - s += ")" - - if positions and not self.children: - s += self._pos_str() - - return s - - def pretty(self, positions=False): - return self._make_str(indent=0, positions=positions) - - def accept(self, visitor): - raise NotImplementedError() - - -class Script(Node): - def __init__(self, statements): - self.statements = statements - - def accept(self, visitor): - return visitor.visit_script(self) - - -class ProcDef(Node): - def __init__(self, name, args, body): - self.name = name - self.args = args - self.body = body - - def accept(self, visitor): - return visitor.visit_proc(self) - - -class SetStmt(Node): - def __init__(self, varname, value): - self.varname = varname - self.value = value - - def accept(self, visitor): - return visitor.visit_set(self) - - -class Namespace(Node): - def __init__(self, name, body): - self.name = name - self.body = body - - def accept(self, visitor): - return visitor.visit_namespace(self) - - -class CommandSubst(Node): - def __init__(self, name, args): - self.name = name - self.args = args - - def accept(self, visitor): - return visitor.visit_command(self) diff --git a/server/src/parser/lexer.py b/server/src/parser/lexer.py deleted file mode 100644 index dc18312..0000000 --- a/server/src/parser/lexer.py +++ /dev/null @@ -1,243 +0,0 @@ -import ply.lex as lex -from typing import Tuple - -TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE" -TOK_BACKSLASH_SUB = "BACKSLASH_SUB" -TOK_NEWLINE = "NEWLINE" -TOK_SEMI = "SEMI" -TOK_WS = "WS" -TOK_QUOTE = "QUOTE" -TOK_ARG_EXPANSION = "ARG_EXPANSION" -TOK_LBRACE = "LBRACE" -TOK_RBRACE = "RBRACE" -TOK_STAR = "STAR" -TOK_LBRACKET = "LBRACKET" -TOK_RBRACKET = "RBRACKET" -TOK_DOLLAR = "DOLLAR" -TOK_LPAREN = "LPAREN" -TOK_RPAREN = "RPAREN" -TOK_HASH = "HASH" -TOK_ALPHA_CHARS = "ALPHA_CHARS" -TOK_NUM_CHARS = "NUM_CHARS" -TOK_NAMESPACE_SEP = "NAMESPACE_SEP" -TOK_CHAR = "CHAR" -TOK_CONTENTS = "CONTENTS" -TOK_EOF = None - -STATE_BRACEDWORD = "bracedword" - - -class TclSyntaxError(Exception): - def __init__(self, message, start: Tuple[int, int], end: Tuple[int, int]): - super().__init__(message) - self.start = start - self.end = end - - -class _LexTable: - tokens = ( - TOK_BACKSLASH_NEWLINE, - TOK_BACKSLASH_SUB, - TOK_NEWLINE, - TOK_SEMI, - TOK_WS, - TOK_QUOTE, - TOK_ARG_EXPANSION, - TOK_LBRACE, - TOK_RBRACE, - TOK_STAR, - TOK_LBRACKET, - TOK_RBRACKET, - TOK_DOLLAR, - TOK_LPAREN, - TOK_RPAREN, - TOK_HASH, - TOK_ALPHA_CHARS, - TOK_NUM_CHARS, - TOK_NAMESPACE_SEP, - TOK_CHAR, - TOK_CONTENTS, - ) - - # This defines a conditional lexing state for parsing braced words. This is a - # performance optimization; since there are few special characters in this context, - # we can use a smaller set of tokens to parse them faster. This has a large impact - # since most Tcl programs have a large number of braced words. Any token with - # `bracedword` in its name is included in this state. Tokens that are included in - # this state and the default state also include `INITIAL` in their name. - states = ((STATE_BRACEDWORD, "exclusive"),) - - def _tok(self, t): - pos = (t.lexer.lineno, t.lexer.colno) - t.lexer.lineno += t.value.count("\n") - index = t.value.rfind("\n") - if index == -1: - t.lexer.colno += len(t.value) - else: - remaining = t.value[index + 1 :] - t.lexer.colno = len(remaining) + 1 - - t.value = (t.value, pos) - return t - - # Priority important - def t_bracedword_INITIAL_BACKSLASH_NEWLINE(self, t): - r"\\\n" - return self._tok(t) - - # Priority important - def t_bracedword_INITIAL_BACKSLASH_SUB(self, t): - r"\\." - return self._tok(t) - - def t_NEWLINE(self, t): - r"\n" - return self._tok(t) - - def t_SEMI(self, t): - r";" - return self._tok(t) - - # TODO: should use \s? - def t_WS(self, t): - r"[\t\v\f\r ]+" - return self._tok(t) - - def t_QUOTE(self, t): - r'"' - return self._tok(t) - - # Must be higher priority than LBRACE - def t_ARG_EXPANSION(self, t): - r"\{\*\}" - return self._tok(t) - - def t_bracedword_INITIAL_LBRACE(self, t): - r"\{" - return self._tok(t) - - def t_bracedword_INITIAL_RBRACE(self, t): - r"\}" - return self._tok(t) - - def t_STAR(self, t): - r"\*" - return self._tok(t) - - def t_LBRACKET(self, t): - r"\[" - return self._tok(t) - - def t_RBRACKET(self, t): - r"\]" - return self._tok(t) - - def t_DOLLAR(self, t): - r"\$" - return self._tok(t) - - def t_LPAREN(self, t): - r"\(" - return self._tok(t) - - def t_RPAREN(self, t): - r"\)" - return self._tok(t) - - def t_HASH(self, t): - r"\#" - return self._tok(t) - - # Valid non-numeric chars in variable names - def t_ALPHA_CHARS(self, t): - r"[A-Za-z_]+" - return self._tok(t) - - # Valid numeric chars in variable names - # This is split up from the above to facilitate expression parsing, since - # e.g. 1eq1 can't be a single token. - def t_NUM_CHARS(self, t): - r"[0-9]+" - return self._tok(t) - - def t_NAMESPACE_SEP(self, t): - r"::+" - return self._tok(t) - - def t_bracedword_CONTENTS(self, t): - r"[^{}\\]+" - return self._tok(t) - - # Catch-all. TODO: inefficient, should probably munch multiple chars - def t_CHAR(self, t): - r"." - return self._tok(t) - - # Error handling rule - # TODO: do we need this? since we have a catch-all... - # there is a warning - def t_bracedword_INITIAL_error(self, t): - print("Illegal character '%s'" % t.value[0]) - t.lexer.skip(1) - - def __init__(self): - self.lexer = lex.lex(object=self) - self.lexer.lineno = 1 - self.lexer.colno = 1 - - def new_lexer(self, pos=None): - lexer = self.lexer.clone() - lexer.lineno = 1 - lexer.colno = 1 - - if pos is not None: - line, col = pos - lexer.lineno = line - lexer.colno = col - - return lexer - - -# Calling `lex.lex()` performs an expensive reflection process to generate the lexer. -# This singleton class holds a preinitialized lexer that can then be cloned to create -# individual instances. -LexTable = _LexTable() - - -class Lexer: - def __init__(self, pos=None): - self.lexer = LexTable.new_lexer(pos) - self.current = None - - def input(self, text): - self.lexer.input(text) - self.current = self.lexer.token() - - def type(self): - if self.current is None: - return TOK_EOF - return self.current.type - - def value(self): - if self.current is None: - return None - return self.current.value[0] - - def pos(self): - if self.current is None: - return (self.lexer.lineno, self.lexer.colno) - return self.current.value[1] - - def next(self): - self.current = self.lexer.token() - - def expect(self, *tokens, message, pos): - if self.type() not in tokens: - self.next() # munch another token to update position - raise TclSyntaxError(message, pos, self.pos()) - - self.next() - - def assert_(self, *tokens): - assert self.current.type in tokens - self.next() diff --git a/server/src/parser/parser.py b/server/src/parser/parser.py deleted file mode 100644 index 5238b69..0000000 --- a/server/src/parser/parser.py +++ /dev/null @@ -1,124 +0,0 @@ -from parser.lexer import ( - TOK_ALPHA_CHARS, - TOK_DOLLAR, - TOK_EOF, - TOK_LBRACE, - TOK_LBRACKET, - TOK_NEWLINE, - TOK_NUM_CHARS, - TOK_RBRACE, - TOK_SEMI, - TOK_WS, - TclSyntaxError, -) -from parser.ast import CommandSubst, Namespace, ProcDef, Script, SetStmt - - -class Parser: - def __init__(self, lexer): - self.lexer = lexer - - def parse(self): - statements = [] - while self.lexer.type() != TOK_EOF: - if self.lexer.type() in (TOK_WS, TOK_NEWLINE, TOK_SEMI): - self.lexer.next() - continue - statements.append(self.parse_statement()) - return Script(statements) - - def parse_statement(self): - if self.lexer.type() == TOK_ALPHA_CHARS: - cmd = self.lexer.value() - if cmd == "proc": - return self.parse_proc() - elif cmd == "set": - return self.parse_set() - elif cmd == "namespace": - return self.parse_namespace() - else: - return self.parse_command() - else: - raise TclSyntaxError( - "Unknown statement", self.lexer.pos(), self.lexer.pos() - ) - - def parse_proc(self): - self.lexer.next() # skip 'proc' - name = self.expect_value(TOK_ALPHA_CHARS) - args = self.parse_arguments() - body = self.parse_body() - return ProcDef(name, args, body) - - def parse_arguments(self): - args = [] - self.expect_token(TOK_LBRACE) - while self.lexer.type() != TOK_RBRACE: - if self.lexer.type() == TOK_LBRACE: - self.lexer.next() - arg_name = self.expect_value(TOK_ALPHA_CHARS) - default = ( - self.expect_value(TOK_ALPHA_CHARS) - if self.lexer.type() != TOK_RBRACE - else None - ) - self.expect_token(TOK_RBRACE) - args.append((arg_name, default)) - else: - args.append((self.expect_value(TOK_ALPHA_CHARS), None)) - self.expect_token(TOK_RBRACE) - return args - - def parse_body(self): - if self.lexer.type() == TOK_LBRACE: - self.lexer.next() - body_tokens = [] - while self.lexer.type() != TOK_RBRACE: - body_tokens.append(self.lexer.value()) - self.lexer.next() - self.lexer.next() # skip RBRACE - return " ".join(body_tokens) - else: - raise TclSyntaxError("Expected body", self.lexer.pos(), self.lexer.pos()) - - def parse_set(self): - self.lexer.next() - varname = self.expect_value(TOK_ALPHA_CHARS) - value = self.expect_value(TOK_ALPHA_CHARS) - return SetStmt(varname, value) - - def parse_namespace(self): - self.lexer.next() # skip 'namespace' - self.expect_token(TOK_ALPHA_CHARS) # 'eval' - name = self.expect_value(TOK_ALPHA_CHARS) - body = self.parse_body() - return Namespace(name, body) - - def parse_command(self): - name = self.expect_value(TOK_ALPHA_CHARS) - args = [] - while self.lexer.type() in ( - TOK_ALPHA_CHARS, - TOK_NUM_CHARS, - TOK_LBRACKET, - TOK_DOLLAR, - ): - args.append(self.lexer.value()) - self.lexer.next() - return CommandSubst(name, args) - - def expect_token(self, token): - if self.lexer.type() != token: - raise TclSyntaxError( - f"Expected {token}", self.lexer.pos(), self.lexer.pos() - ) - self.lexer.next() - - def expect_value(self, token): - if self.lexer.type() != token: - raise TclSyntaxError( - f"Expected {token}", self.lexer.pos(), self.lexer.pos() - ) - value = self.lexer.value() - self.lexer.next() - return value diff --git a/server/src/nx_plugins/__init__.py b/server/src/plugins/__init__.py similarity index 100% rename from server/src/nx_plugins/__init__.py rename to server/src/plugins/__init__.py diff --git a/server/src/plugins/poco_plugin.py b/server/src/plugins/poco_plugin.py new file mode 100644 index 0000000..eac8dd3 --- /dev/null +++ b/server/src/plugins/poco_plugin.py @@ -0,0 +1,48 @@ +from tclint.commands.checks import CommandArgError +from tclint.syntax_tree import BracedWord + + +def _lib_ge_command_buffer_edit(args, parser, command_name, pos_script, len_args): + if len(args) != len_args: + raise CommandArgError( + f"wrong # of args to {command_name}: got {len(args)}, expected {len_args}" + ) + args[pos_script] = parser.parse_script(args[pos_script]) + return args + + +def _lib_ge_command_buffer(args, parser): + if ( + len(args) < 1 + or len(args) > 2 + or (len(args) == 1 and isinstance(args[0], BracedWord)) + ): + raise CommandArgError( + f"wrong # of args to LIB_GE_command_buffer: got {len(args)}, expected 1 or 2" + ) + if len(args) == 1: + return args + args[0] = parser.parse_script(args[0]) + return args + + +def lib_ge_command_buffer_edit_append(args, parser): + _lib_ge_command_buffer_edit(args, parser, "LIB_GE_command_buffer_edit_append", 2, 4) + + +def lib_ge_command_buffer_edit_prepend(args, parser): + _lib_ge_command_buffer_edit( + args, parser, "LIB_GE_command_buffer_edit_prepend", 2, 4 + ) + + +def lib_ge_command_buffer_edit_insert(args, parser): + _lib_ge_command_buffer_edit(args, parser, "LIB_GE_command_buffer_edit_insert", 2, 6) + + +commands = [ + {"LIB_GE_command_buffer_edit_append": lib_ge_command_buffer_edit_append}, + {"LIB_GE_command_buffer_edit_prepend": lib_ge_command_buffer_edit_prepend}, + {"LIB_GE_command_buffer_edit_insert": lib_ge_command_buffer_edit_insert}, + {"LIB_GE_command_buffer": _lib_ge_command_buffer}, +] diff --git a/server/src/tools/checks.py b/server/src/tools/checks.py new file mode 100644 index 0000000..f714128 --- /dev/null +++ b/server/src/tools/checks.py @@ -0,0 +1,4 @@ +def get_checkers(): + checkers = () + + return checkers -- 2.54.0