use tclint as parser / formatter

This commit is contained in:
2025-07-27 17:55:48 +02:00
parent c34dac847a
commit c6f0758b97
132 changed files with 10193 additions and 10794 deletions
View File
+4
View File
@@ -0,0 +1,4 @@
import sys
from tclint.cli.tclint import main
sys.exit(main())
+21
View File
@@ -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)
+227
View File
@@ -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
+182
View File
@@ -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())
+173
View File
@@ -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())
+437
View File
@@ -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()
+88
View File
@@ -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
+37
View File
@@ -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
+240
View File
@@ -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
+86
View File
@@ -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()
+35
View File
@@ -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,
)
+91
View File
@@ -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}")
+427
View File
@@ -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
+480
View File
@@ -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
+243
View File
@@ -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()
+900
View File
@@ -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()
+438
View File
@@ -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)
+50
View File
@@ -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