use tclint as parser / formatter
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from tclint.cli.tclint import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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="<path>",
|
||||
)
|
||||
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())
|
||||
@@ -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="<path>",
|
||||
)
|
||||
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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
"""Helpers for checking command arguments."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from tclint.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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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="<path>"
|
||||
)
|
||||
|
||||
|
||||
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="<line_length>",
|
||||
)
|
||||
|
||||
|
||||
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="<indent>",
|
||||
dest="style_indent",
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--max-blank-lines",
|
||||
type=_validator("style_max_blank_lines"),
|
||||
metavar="<max_blank_lines>",
|
||||
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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,438 @@
|
||||
"""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
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user