add updates libs

This commit is contained in:
Christoph Brandau
2026-06-18 17:21:58 +02:00
parent 91ce762570
commit 41f331d4c4
150 changed files with 8249 additions and 2550 deletions
+1
View File
@@ -1,4 +1,5 @@
import sys
from tclint.cli.tclint import main
sys.exit(main())
+16 -3
View File
@@ -1,7 +1,14 @@
# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
from typing import Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '0.6.0'
__version_tuple__ = version_tuple = (0, 6, 0)
__version__ = version = '0.8.0'
__version_tuple__ = version_tuple = (0, 8, 0)
__commit_id__ = commit_id = None
+41 -16
View File
@@ -1,16 +1,17 @@
import re
from tclint.commands import get_commands
from tclint.violations import Rule, Violation
from tclint.commands.plugins import PluginManager
from tclint.config import Config
from tclint.syntax_tree import (
Visitor,
BracedExpression,
Expression,
BracedWord,
QuotedWord,
CommandSub,
Expression,
QuotedWord,
Script,
Visitor,
)
from tclint.violations import Rule, Violation
class LineLengthChecker:
@@ -81,11 +82,13 @@ class RedefinedBuiltinChecker(Visitor):
Reports 'redefined-builtin' violations.
"""
def check(self, _, tree, config):
self._violations = []
def __init__(self, plugin_manager: PluginManager):
self._plugin_manager = plugin_manager
plugins = [config.commands] if config.commands is not None else []
commands = get_commands(plugins)
def check(self, _, tree: Script, config: Config) -> list[Violation]:
self._violations: list[Violation] = []
commands = self._plugin_manager.get_commands(config.commands)
self._commands = commands.keys()
tree.accept(self, recurse=True)
@@ -115,8 +118,8 @@ class RedefinedBuiltinChecker(Visitor):
class UnbracedExprChecker(Visitor):
def check(self, _, tree, __):
self._violations = []
def check(self, _, tree, __) -> list[Violation]:
self._violations: list[Violation] = []
tree.accept(self, recurse=True)
return self._violations
@@ -170,8 +173,8 @@ class UnbracedExprChecker(Visitor):
class RedundantExprChecker(Visitor):
def check(self, _, tree, __):
self._violations = []
def check(self, _, tree, __) -> list[Violation]:
self._violations: list[Violation] = []
tree.accept(self, recurse=True)
return self._violations
@@ -215,11 +218,33 @@ class RedundantExprChecker(Visitor):
self._check_operand(arg)
def get_checkers():
class UnopenedQuoteChecker(Visitor):
# Matches a literal " not preceded by backslash (escaped quotes are intentional)
BARE_QUOTE_RE = re.compile(r'(?<!\\)"')
def check(self, _, tree, __) -> list[Violation]:
self._violations: list[Violation] = []
tree.accept(self, recurse=True)
return self._violations
def visit_bare_word(self, word):
if self.BARE_QUOTE_RE.search(word.value):
self._violations.append(
Violation(
Rule.UNOPENED_QUOTE,
'found " without opening quote',
word.pos,
word.end_pos,
)
)
def get_checkers(plugin_manager: PluginManager):
checkers = (
RedefinedBuiltinChecker(),
RedefinedBuiltinChecker(plugin_manager),
UnbracedExprChecker(),
RedundantExprChecker(),
UnopenedQuoteChecker(),
LineLengthChecker(),
TrailingWhitespaceChecker(),
)
+98
View File
@@ -0,0 +1,98 @@
import os
from pathlib import Path
from typing import Optional
from tclint.cli.utils import make_exclude_filter
from tclint.config import Config, load_config_at
class Resolver:
def __init__(self, cli_args=None, global_config: Optional[Config] = None):
# TODO: Figure out a way to annotate cli_args type effectively.
self._config_cache: dict[Path, Config] = {}
self._global_config = global_config
# Instantiate and hang on to a default config so the Resolver can cache many
# instances of it without blowing up memory footprint. (This has not been
# empirically validated!)
self._default_config = Config()
self._cli_args = None
if cli_args is not None:
self._default_config.apply_cli_args(cli_args)
self._cli_args = cli_args
def _find_config(self, directory: Path) -> Config:
config = load_config_at(directory)
if config is not None:
if self._cli_args is not None:
config.apply_cli_args(self._cli_args)
return config
# We're at the root, bail!
if directory.parent == directory:
return self._default_config
return self.find_config(directory.parent)
def find_config(self, directory: Path) -> Config:
if self._global_config is not None:
return self._global_config
if directory in self._config_cache:
return self._config_cache[directory]
config = self._find_config(directory)
self._config_cache[directory] = config
return config
def resolve_sources(
self, paths: list[Path], cwd: Path
) -> list[tuple[Optional[Path], Config]]:
sources: list[tuple[Optional[Path], Config]] = []
for path in paths:
if str(path) == "-":
config = self.find_config(cwd)
sources.append((None, config))
continue
if not path.exists():
raise FileNotFoundError(f"path {path} does not exist")
# We need to slap a .resolve() on the find_config()'s to make sure the
# method traverses upwards to the FS root. It would probably ideal to just
# resolve each path in the main body of the loop, but this actually has
# implications on how the filepath is printed out (it becomes an absolute
# path, symlinks are resolved).
if not path.is_dir():
config = self.find_config(path.resolve().parent)
is_excluded = make_exclude_filter(config.exclude)
if is_excluded(path):
continue
sources.append((path, config))
continue
for dirstr, dirs, filenames in os.walk(path):
dirpath = Path(dirstr)
config = self.find_config(dirpath.resolve())
is_excluded = make_exclude_filter(config.exclude)
extensions = [
f".{ext}" if not ext.startswith(".") else ext
for ext in config.extensions
]
# Update dirs to prune next directories to traverse based on exclude.
to_traverse = []
for dir in dirs:
if not is_excluded(Path(dir)):
to_traverse.append(dir)
dirs[:] = to_traverse
for name in filenames:
_, ext = os.path.splitext(name)
if ext.lower() in extensions:
child = dirpath / name
if not is_excluded(child):
sources.append((child, config))
return sources
+78 -36
View File
@@ -4,16 +4,17 @@ import argparse
import pathlib
import sys
from tclint.cli.utils import resolve_sources, register_codec_warning
from tclint.cli.resolver import Resolver
from tclint.cli.utils import register_codec_warning
from tclint.commands.plugins import PluginManager
from tclint.config import (
get_config,
setup_tclfmt_config_cli_args,
Config,
ConfigError,
RunConfig,
SpacesInBraces,
setup_tclfmt_config_cli_args,
)
from tclint.parser import Parser, TclSyntaxError
from tclint.format import Formatter, FormatterOpts
from tclint.parser import Parser, TclSyntaxError
try:
from tclint._version import __version__ # type: ignore
@@ -27,22 +28,41 @@ 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)
def format(
script: str,
config: Config,
plugins: PluginManager,
debug=False,
debug_whitespace=False,
partial=False,
) -> str:
parser = Parser(debug=debug, commands=plugins.get_commands(config.commands))
formatter = Formatter(
FormatterOpts(
indent=config.get_indent(),
spaces_in_braces=config.style_spaces_in_braces,
indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.ALWAYS
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
balanced_spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
max_blank_lines=config.style_max_blank_lines,
indent_namespace_eval=config.style_indent_namespace_eval,
emacs=config.style_emacs,
debug_whitespace=debug_whitespace,
)
)
return formatter.format_top(script, parser)
if partial:
return formatter.format_partial(script, parser)
else:
return formatter.format_top(script, parser)
def check(path: pathlib.Path, script: str, formatted: str):
def check(path: str, script: str, formatted: str):
parser = Parser()
original_tree = parser.parse(script)
formatted_tree = parser.parse(formatted)
@@ -86,6 +106,12 @@ def main():
" of output (e.g. -dd)"
),
)
parser.add_argument(
"--debug-whitespace",
action="store_true",
default=False,
help="display whitespace in debug mode.",
)
parser.add_argument(
"-c",
"--config",
@@ -94,41 +120,46 @@ def main():
default=None,
metavar="<path>",
)
setup_tclfmt_config_cli_args(parser)
parser.add_argument(
"--partial",
help="treat input as a fragment of a script",
action="store_true",
)
cwd = pathlib.Path.cwd()
setup_tclfmt_config_cli_args(parser, cwd)
args = parser.parse_args()
global_config = None
if args.config is not None:
try:
global_config = Config.from_path(args.config, cwd)
global_config.apply_cli_args(args)
except FileNotFoundError:
print(f"Config file path doesn't exist: {args.config}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
resolver = Resolver(args, global_config)
try:
config = get_config(args.config, pathlib.Path.cwd())
sources = resolver.resolve_sources(args.source, cwd)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
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
plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
retcode = EXIT_OK
register_codec_warning("replace_with_warning")
reformat_count = 0
for path in sources:
for path, config in sources:
if path is None:
script = sys.stdin.read()
out_prefix = "(stdin)"
@@ -139,7 +170,12 @@ def main():
try:
formatted = format(
script, config.get_for_path(path), debug=(args.debug > 1)
script,
config,
plugin_manager,
debug=(args.debug > 1),
debug_whitespace=args.debug_whitespace,
partial=args.partial,
)
if args.in_place and path:
with open(path, "w") as f:
@@ -155,9 +191,15 @@ def main():
print(formatted, end="")
if args.debug > 0:
check(path, script, formatted)
if args.debug_whitespace:
print(
"Warning: --debug-whitespace enabled, disabling original vs."
" formatted syntax tree check"
)
else:
check(out_prefix, script, formatted)
except TclSyntaxError as e:
line, col = e.pos
line, col = e.start
print(f"{out_prefix}:{line}:{col}: syntax error: {e}", file=sys.stderr)
retcode |= EXIT_SYNTAX_ERROR
continue
+39 -43
View File
@@ -3,21 +3,16 @@
import argparse
import pathlib
import sys
from typing import Dict, List, Optional
from typing import 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.cli.resolver import Resolver
from tclint.cli.utils import register_codec_warning
from tclint.commands.plugins import PluginManager
from tclint.comments import CommentVisitor
from tclint.cli.utils import resolve_sources, register_codec_warning
from tclint.config import Config, ConfigError, setup_config_cli_args
from tclint.parser import Parser, TclSyntaxError
from tclint.violations import Rule, Violation
try:
from tclint._version import __version__ # type: ignore
@@ -32,10 +27,10 @@ EXIT_INPUT_ERROR = 4
def filter_violations(
violations: List[Violation],
config_ignore: List[Rule],
inline_ignore: Dict[int, List[Rule]],
) -> List[Violation]:
violations: list[Violation],
config_ignore: list[Rule],
inline_ignore: dict[int, list[Rule]],
) -> list[Violation]:
filtered_violations = []
for violation in violations:
@@ -53,11 +48,11 @@ def filter_violations(
def lint(
script: str,
config: Config,
plugins: PluginManager,
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)
) -> list[Violation]:
parser = Parser(debug=(debug > 0), commands=plugins.get_commands(config.commands))
violations = []
tree = parser.parse(script)
@@ -66,7 +61,7 @@ def lint(
if debug > 0:
print(tree.pretty(positions=(debug > 1)))
for checker in get_checkers():
for checker in get_checkers(plugins):
violations += checker.check(script, tree, config)
v = CommentVisitor()
@@ -105,40 +100,40 @@ def main():
default=None,
metavar="<path>",
)
setup_config_cli_args(parser)
cwd = pathlib.Path.cwd()
setup_config_cli_args(parser, cwd)
args = parser.parse_args()
global_config = None
if args.config is not None:
try:
global_config = Config.from_path(args.config, cwd)
global_config.apply_cli_args(args)
except FileNotFoundError:
print(f"Config file path doesn't exist: {args.config}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
resolver = Resolver(args, global_config)
try:
config = get_config(args.config, pathlib.Path())
sources = resolver.resolve_sources(args.source, cwd)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
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
plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
retcode = EXIT_OK
register_codec_warning("replace_with_warning")
for path in sources:
for path, config in sources:
if path is None:
script = sys.stdin.read()
out_prefix = "(stdin)"
@@ -150,7 +145,8 @@ def main():
try:
violations = lint(
script,
config.get_for_path(path),
config,
plugin_manager,
path,
debug=args.debug,
)
+149 -61
View File
@@ -1,22 +1,27 @@
import argparse
import dataclasses
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import uuid
from pathlib import Path
from typing import Optional
from lsprotocol import types as lsp
from pygls.server import LanguageServer
from pygls.workspace import TextDocument
from pygls.uris import to_fs_path
from pygls.workspace import TextDocument
from tclint.cli import tclint
from tclint.config import get_config, DEFAULT_CONFIGS, RunConfig, Config, ConfigError
from tclint.cli import tclint, utils
from tclint.commands.plugins import PluginManager
from tclint.config import (
DEFAULT_CONFIGS,
Config,
ConfigError,
SpacesInBraces,
load_config_at,
)
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
@@ -25,13 +30,14 @@ except ModuleNotFoundError:
DIAGNOSTIC_SOURCE = "tclint"
_DEFAULT_CONFIG = Config()
def lint(source, config, path):
def lint(source, config, plugin_manager, path):
diagnostics = []
try:
violations = tclint.lint(source, config, path)
violations = tclint.lint(source, config, plugin_manager, path)
except TclSyntaxError as e:
return [
lsp.Diagnostic(
@@ -62,7 +68,7 @@ def lint(source, config, path):
start=start,
end=end,
),
code=violation.id,
code=str(violation.id),
source=DIAGNOSTIC_SOURCE,
)
)
@@ -83,15 +89,26 @@ class TclspServer(LanguageServer):
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] = {}
# There are many config caches!!
# Caches loaded config files specified in LSP settings.
self.workspace_configs: dict[Path, Config] = {}
# Caches loaded config files present in open workspaces.
self.config_files: dict[Path, Config] = {}
# Caches which config is used by each open file.
self.source_configs: dict[Path, Config] = {}
# Tracks which invalid configs we've already displayed an error for, to avoid
# spam.
self.invalid_configs: set[Path] = set()
self.client_supports_refresh = False
self.global_settings = ExtensionSettings()
self.workspace_settings: Dict[Path, ExtensionSettings] = {}
self.workspace_settings: dict[Path, ExtensionSettings] = {}
def get_roots(self) -> List[Path]:
self.plugin_manager = PluginManager()
def get_roots(self) -> list[Path]:
"""Returns root folders currently open in the workspace."""
roots = []
for uri in self.workspace.folders.keys():
@@ -108,10 +125,8 @@ class TclspServer(LanguageServer):
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.
"""Returns workspace root if path is in a workspace folder. Otherwise, returns
None.
"""
roots = self.get_roots()
closest_root = None
@@ -124,55 +139,98 @@ class TclspServer(LanguageServer):
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]
def get_config_file(self, root: Path) -> Optional[Path]:
if root in self.workspace_settings:
settings = self.workspace_settings[root]
return settings.config_file
return self.global_settings.config_file
def load_configs(self):
self.configs = {}
def show_config_error(self, msg: str, path: Path):
if path not in self.invalid_configs:
self.show_message(f"Error loading config file: {msg}")
self.invalid_configs.add(path)
def load_config(self, path: Path, root: Path) -> Optional[Config]:
try:
return Config.from_path(path, root)
except FileNotFoundError:
self.show_config_error(f"{path} doesn't exist", path)
return None
except ConfigError as e:
self.show_config_error(str(e), path)
return None
def load_workspace_setting_configs(self):
"""These may be used a lot if specified, so cache specially."""
for root in self.get_roots():
try:
path = self.get_config_file(root)
config = get_config(path, root)
path = self.get_config_file(root)
if path is None:
continue
config = self.load_config(path, root)
if config is None:
continue
self.workspace_configs[root] = config
def _get_config(self, path: Path) -> Config:
workspace_root = self.get_root(path)
# First, check for configs specified in the LSP settings.
# If not in a workspace, our only shot is to use a global config. Otherwise, we
# bail (no searching, since the LSP only searches up to the workspace root).
if workspace_root is None:
global_file = self.global_settings.config_file
if global_file is not None:
config = self.load_config(global_file, path.parent)
if config is not None:
self.configs[root] = config
except ConfigError as e:
self.show_message(f"Error loading config file: {e}")
return config
return _DEFAULT_CONFIG
# 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:
# If file is in a workspace, and we've got a workspace config configured, use
# that (this logic also handles global configs, since these are still
# instantiated once per workspace to resolve relative paths).
if workspace_root is not None and workspace_root in self.workspace_configs:
return self.workspace_configs[workspace_root]
# Otherwise, walk upwards until root.
# path is a file, which is a sneaky trick to guarantee we always run the first
# iteration. It becomes a directory after the first statement in the loop.
while path != workspace_root:
path = path.parent
try:
config = get_config(global_path, global_path.parent)
self.global_config = config
config = load_config_at(path)
except ConfigError as e:
self.show_message(f"Error loading config file: {e}")
self.show_config_error(str(e), path)
return _DEFAULT_CONFIG
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()
if config is not None:
return config
def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
return _DEFAULT_CONFIG
def get_config(self, path: Path) -> Config:
"""Return config object for a given path.
If no config has already been loaded for root (either by calling this function
or load_configs), this function will search for and load a config file if found.
"""
if path in self.source_configs:
return self.source_configs[path]
config = self._get_config(path)
self.source_configs[path] = config
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
config = self.get_config(path)
is_excluded = utils.make_exclude_filter(config.exclude)
if is_excluded(path, root):
if is_excluded(path):
return []
return lint(document.source, config, path)
return lint(document.source, config, self.plugin_manager, path)
def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not
@@ -189,11 +247,10 @@ class TclspServer(LanguageServer):
self,
document: TextDocument,
options: lsp.FormattingOptions,
range: Optional[Tuple[int, int]] = None,
range: Optional[tuple[int, int]] = None,
):
path = Path(document.path)
root = self.get_root(path)
config = self.get_config(path, root)
config = self.get_config(path)
parser = Parser()
@@ -205,9 +262,19 @@ class TclspServer(LanguageServer):
formatter = Formatter(
FormatterOpts(
indent=indent,
spaces_in_braces=config.style_spaces_in_braces,
indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.ALWAYS
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
balanced_spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
max_blank_lines=config.style_max_blank_lines,
indent_namespace_eval=config.style_indent_namespace_eval,
emacs=False,
debug_whitespace=False,
)
)
@@ -230,13 +297,30 @@ def did_open(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
def did_change(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
def did_change(ls: TclspServer, params: lsp.DidChangeTextDocumentParams):
"""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_DID_CLOSE)
def did_close(ls: TclspServer, params: lsp.DidCloseTextDocumentParams):
"""Free up resources when a document is closed."""
logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CLOSE, params)
doc = ls.workspace.get_text_document(params.text_document.uri)
try:
del ls.diagnostics[doc.uri]
except KeyError:
pass
try:
del ls.source_configs[Path(doc.path)]
except KeyError:
pass
@server.feature(
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
lsp.DiagnosticOptions(
@@ -275,7 +359,11 @@ def change_watched_files(ls: TclspServer, params: lsp.DidChangeWatchedFilesParam
# Clear diagnostics cache so they get recalculated when requested
ls.diagnostics = {}
ls.load_configs()
# Config files changed, clear the many caches!
ls.config_files = {}
ls.source_configs = {}
ls.invalid_configs = set()
if ls.client_supports_refresh:
ls.lsp.send_request(lsp.WORKSPACE_DIAGNOSTIC_REFRESH, None)
@@ -369,7 +457,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
capabilities = ls.client_capabilities.workspace
try:
ls.client_supports_refresh = (
ls.client_supports_refresh = bool(
capabilities.diagnostics.refresh_support # type: ignore[union-attr]
)
except AttributeError:
@@ -391,7 +479,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
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)
lsp.FileSystemWatcher(glob_pattern=str(settings.config_file))
)
ls.register_capability(
@@ -408,7 +496,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
)
)
ls.load_configs()
ls.load_workspace_setting_configs()
def main():
+33 -65
View File
@@ -1,11 +1,14 @@
import codecs
import os
import pathlib
import re
from typing import List, Optional
from collections import defaultdict
from pathlib import Path
from typing import Callable
import pathspec
from tclint.config import ExcludePattern
def register_codec_warning(name):
def replace_with_warning_handler(e):
@@ -16,73 +19,38 @@ def register_codec_warning(name):
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 make_exclude_filter(
exclude_patterns: list[ExcludePattern],
) -> Callable[[Path], bool]:
# Transform patterns into a data structure keyed on root.
patterns_by_root = defaultdict(list)
for pattern, root in exclude_patterns:
# I think this is escaping #, which would otherwise be treated like a comment.
# Not 100% sure though, I originally wrote this a while ago.
pattern = re.sub(r"^\s*#", r"\#", pattern)
patterns_by_root[root.resolve()].append(pattern)
def is_excluded(path: pathlib.Path, root: pathlib.Path) -> bool:
compiled_patterns = {}
for root in patterns_by_root.keys():
patterns = patterns_by_root[root]
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
compiled_patterns[root] = spec
def is_excluded(path: 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
for root, exclude_spec in compiled_patterns.items():
try:
relpath = 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
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
+2 -35
View File
@@ -1,37 +1,4 @@
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
# 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
__all__ = ["CommandArgError"]
+233 -117
View File
@@ -32,13 +32,9 @@ these would be helpful for your use case, please file an issue.
- https://www.tcl.tk/man/tcl/TclCmd/mathop.html
"""
from tclint.commands.checks import (
CommandArgError,
check_count,
eval,
)
from tclint.commands.checks import CommandArgError, check_arg_spec, check_count, eval
from tclint.commands.schema import commands_schema
from tclint.syntax_tree import BareWord
from tclint.syntax_tree import BareWord, Node
def _check_code(arg):
@@ -77,7 +73,7 @@ def _after(args, parser):
def _after_cancel(args, parser):
"""after id|(script...)"""
# ref: https://www.tcl.tk/man/tcl/TclCmd/after.html
check_count("after cancel", 1, None)
check_count("after cancel", 1, None)(args, parser)
# TODO: raise warning about not checking code
@@ -181,20 +177,6 @@ _array = {
}
def _catch(args, parser):
"""catch script [resultVarName] [optionsVarName]"""
if len(args) < 1:
raise CommandArgError(
f"not enough args to catch: got {len(args)}, expected at least 1"
)
if len(args) > 3:
raise CommandArgError(
f"too many args to catch: got {len(args)}, expected no more than 3"
)
return [parser.parse_script(args[0])] + args[1:]
_chan = {
"subcommands": {
"blocked": {
@@ -350,43 +332,37 @@ def _dict_filter(args, parser):
def _dict_map_for(cmd):
def check(args, parser):
if len(args) != 3:
raise CommandArgError(
f"wrong # of args to '{cmd}': got {len(args)}, expected 3"
)
spec = {
"positionals": [
{"name": "keyValueList", "value": {"type": "any"}, "required": True},
{"name": "dictionaryValue", "value": {"type": "any"}, "required": True},
{"name": "body", "value": {"type": "script"}, "required": True},
],
"switches": {},
}
# TODO: might be worth checking that arg[0] is a pair?
return args[0:2] + [parser.parse_script(args[2])]
return check_arg_spec(cmd, args, parser, spec)
return check
def _dict_update(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M25
"""dict update dictionaryVariable key varName ?key varName ...? body
if len(args) < 4:
raise CommandArgError(
f"not enough args to 'dict update': got {len(args)}, expected at least 4"
)
if len(args) % 2 != 0:
raise CommandArgError(
"invalid # of args to 'dict update': expected an even number"
)
return args[0:-1] + [parser.parse_script(args[-1])]
def _dict_with(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M27
if len(args) < 2:
raise CommandArgError(
f"not enough args to 'dict with': got {len(args)}, expected at least 2"
)
return args[0:-1] + [parser.parse_script(args[-1])]
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/dict.htm#M25
"""
spec = {
"positionals": [
{"name": "dictionaryVariable", "value": {"type": "any"}, "required": True},
{"name": "key", "value": {"type": "any"}, "required": True},
{"name": "varName", "value": {"type": "any"}, "required": True},
{"name": "key varName", "value": {"type": "variadic"}, "required": False},
{"name": "body", "value": {"type": "script"}, "required": True},
],
"switches": {},
}
# TODO: Check that number of variadic words is even.
return check_arg_spec("dict update", args, parser, spec)
def _eval(args, parser):
@@ -434,53 +410,100 @@ def _fileevent(args, parser):
)
def _for(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/for.html
if len(args) != 4:
raise CommandArgError(f"wrong # of args to for: got {len(args)}, expected 4")
def foreach(args, parser):
"""
foreach varname list ?varlist list ...? body
return [
parser.parse_script(args[0]),
parser.parse_expression(args[1]),
parser.parse_script(args[2]),
parser.parse_script(args[3]),
]
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/foreach.htm
"""
spec = {
"positionals": [
{"name": "varname", "value": {"type": "any"}, "required": True},
{"name": "list", "value": {"type": "any"}, "required": True},
{"name": "varlist list", "value": {"type": "variadic"}, "required": False},
{"name": "body", "value": {"type": "script"}, "required": True},
],
"switches": {},
}
# TODO: check that "varlist list" comes in pairs.
return check_arg_spec("foreach", args, parser, spec)
def _foreach(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/foreach.html
if len(args) < 3:
raise CommandArgError(
f"insufficient args to foreach: got {len(args)}, expected at least 3"
)
def _if(args, parser) -> list[Node]:
# ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/if.htm
# last argument is script body
return args[0:-1] + [parser.parse_script(args[-1])]
def _if(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/if.html
# TODO: make arg checking strict
new_args = []
new_args: list[Node] = []
# Parse if condition.
if len(new_args) == len(args):
raise CommandArgError("Expected condition argument in 'if'")
new_args.append(parser.parse_expression(args[0]))
while len(new_args) < len(args):
arg = args[len(new_args)]
if arg.contents == "then" or arg.contents == "else":
new_args.append(arg)
continue
if arg.contents == "elseif":
new_args.append(arg)
new_args.append(parser.parse_expression(args[len(new_args)]))
continue
arg = parser.parse_script(arg)
# Parse optional noise word then.
if len(new_args) == len(args):
raise CommandArgError("Expected then or body argument in 'if'")
arg = args[len(new_args)]
if arg.contents == "then":
new_args.append(arg)
return new_args
# Parse if body.
if len(new_args) == len(args):
raise CommandArgError("Expected body argument in 'if'")
new_args.append(parser.parse_script(args[len(new_args)]))
# Parse elseif.
while (
len(new_args) < len(args)
and (arg := args[len(new_args)])
and arg.contents == "elseif"
):
new_args.append(arg)
# Parse elseif condition.
if len(new_args) == len(args):
raise CommandArgError(
"Expected condition argument in 'elseif' part of 'if'"
)
new_args.append(parser.parse_expression(args[len(new_args)]))
# Parse optional noise word then.
if len(new_args) == len(args):
raise CommandArgError(
"Expected then or body argument in 'elseif' part of 'if'"
)
arg = args[len(new_args)]
if arg.contents == "then":
new_args.append(arg)
# Parse elseif body.
if len(new_args) == len(args):
raise CommandArgError("Expected body argument in 'elseif' part of 'if'")
new_args.append(parser.parse_script(args[len(new_args)]))
if len(new_args) == len(args):
# No else part, we're done.
return new_args
# Parse optional noise word else.
arg = args[len(new_args)]
if arg.contents == "else":
new_args.append(arg)
# Parse else body.
if len(new_args) == len(args):
raise CommandArgError("Expected body argument to 'else' part of 'if'")
new_args.append(parser.parse_script(args[len(new_args)]))
if len(new_args) == len(args):
# Else part parsed, we're done.
return new_args
# Handle superfluous args.
arg = args[len(new_args)]
if arg.contents is None or not arg.contents:
raise CommandArgError("Argument after complete 'if'")
else:
raise CommandArgError(f"Argument after complete 'if': {arg.contents}")
def _interp_eval(args, parser):
@@ -492,13 +515,22 @@ def _interp_eval(args, parser):
def _lmap(args, parser):
# ref: https://www.tcl.tk/man/tcl/TclCmd/lmap.html
if len(args) < 3:
raise CommandArgError(
f"not enough args to lmap: got {len(args)}, expected at least 3"
)
"""
lmap varlist1 list1 ?varlist2 list2 ...? body
return args[:-1] + [parser.parse_script(args[-1])]
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/lmap.htm
"""
spec = {
"positionals": [
{"name": "varlist1", "value": {"type": "any"}, "required": True},
{"name": "list1", "value": {"type": "any"}, "required": True},
{"name": "varlist list", "value": {"type": "variadic"}, "required": False},
{"name": "body", "value": {"type": "script"}, "required": True},
],
"switches": {},
}
# TODO: Check that number of variadic words is even.
return check_arg_spec("lmap", args, parser, spec)
def _namespace_code(args, parser):
@@ -656,6 +688,9 @@ def _switch(args, parser):
for i, node in enumerate(pattern_and_commands):
if i % 2 == 0:
parsed_patterns_and_commands.append(node)
elif node.contents == "-":
# Detect passthrough.
parsed_patterns_and_commands.append(node)
else:
parsed_patterns_and_commands.append(parser.parse_script(node))
@@ -795,16 +830,6 @@ def _try(args, parser):
return new_args
def _while(args, parser):
if len(args) != 2:
raise CommandArgError(f"wrong # of args to while: got {len(args)}, expected 2")
return [
parser.parse_expression(args[0]),
parser.parse_script(args[1]),
]
commands = commands_schema({
"after": {
"subcommands": {
@@ -828,14 +853,63 @@ commands = commands_schema({
"array": _array,
"binary": {
"subcommands": {
"decode": check_count("binary decode", 2, None),
"encode": check_count("binary encode", 2, None),
"format": check_count("binary format", 1, None),
"scan": check_count("binary scan", 2, None),
"decode": {
"positionals": [
{"name": "format", "value": {"type": "any"}, "required": True},
{
"name": "options",
"value": {"type": "variadic"},
"required": False,
},
{"name": "data", "value": {"type": "any"}, "required": True},
],
},
"encode": {
"positionals": [
{"name": "format", "value": {"type": "any"}, "required": True},
{
"name": "options",
"value": {"type": "variadic"},
"required": False,
},
{"name": "data", "value": {"type": "any"}, "required": True},
],
},
"format": {
"positionals": [
{
"name": "formatString",
"value": {"type": "any"},
"required": True,
},
{"name": "args", "value": {"type": "variadic"}, "required": False},
],
},
"scan": {
"positionals": [
{"name": "string", "value": {"type": "any"}, "required": True},
{
"name": "formatString",
"value": {"type": "any"},
"required": True,
},
{
"name": "varName",
"value": {"type": "variadic"},
"required": False,
},
],
},
},
},
"break": check_count("break", 0, 0),
"catch": _catch,
"break": {},
"catch": {
"positionals": [
{"name": "script", "value": {"type": "script"}, "required": True},
{"name": "resultVarName", "value": {"type": "any"}, "required": False},
{"name": "optionsVarName", "value": {"type": "any"}, "required": False},
]
},
"cd": {
"positionals": [
{"name": "dirName", "value": {"type": "any"}, "required": False}
@@ -884,7 +958,17 @@ commands = commands_schema({
"unset": check_count("dict unset", 2, None),
"update": _dict_update,
"values": check_count("dict values", 1, 2),
"with": _dict_with,
"with": {
"positionals": [
{
"name": "dictionaryVariable",
"value": {"type": "any"},
"required": True,
},
{"name": "key", "value": {"type": "variadic"}, "required": False},
{"name": "script", "value": {"type": "script"}, "required": True},
]
},
},
},
"encoding": {
@@ -909,8 +993,15 @@ commands = commands_schema({
"file": check_count("file", 1, None),
"fileevent": _fileevent,
"flush": check_count("flush", 1, 1),
"for": _for,
"foreach": _foreach,
"for": {
"positionals": [
{"name": "start", "value": {"type": "script"}, "required": True},
{"name": "test", "value": {"type": "expression"}, "required": True},
{"name": "next", "value": {"type": "script"}, "required": True},
{"name": "body", "value": {"type": "script"}, "required": True},
],
},
"foreach": foreach,
"format": check_count("format", 1, None),
"gets": check_count("gets", 1, 2),
"glob": check_count("glob"),
@@ -974,8 +1065,28 @@ commands = commands_schema({
"inscope": _namespace_inscope,
"origin": check_count("namespace origin", 1, 1),
"parent": check_count("namespace parent", 0, 1),
"path": {
"positionals": [
{
"name": "namespaceList",
"value": {"type": "any"},
"required": False,
},
]
},
"qualifiers": check_count("namespace qualifiers", 1, 1),
"tail": check_count("namespace tail", 1, 1),
"unknown": {
"positionals": [
{"name": "script", "value": {"type": "script"}, "required": False}
]
},
"upvar": {
"positionals": [
{"name": "namespace", "value": {"type": "any"}, "required": True},
{"name": "var", "value": {"type": "variadic"}, "required": False},
]
},
"which": check_count("namespace which", 1, 2),
"ensemble": {
"subcommands": {
@@ -1028,7 +1139,7 @@ commands = commands_schema({
"source": check_count("source", 1, 3),
"split": check_count("split", 1, 2),
# TODO: check subcommands
"string": check_count("string", 2, None),
"string": check_count("string", 1, None),
"subst": check_count("subst", 1, 4),
"switch": _switch,
"tailcall": check_count("tailcall", 1, None),
@@ -1061,7 +1172,12 @@ commands = commands_schema({
"upvar": check_count("upvar", 2, None),
"variable": check_count("variable", 1, None),
"vwait": check_count("vwait", 1, 1),
"while": _while,
"while": {
"positionals": [
{"name": "test", "value": {"type": "expression"}, "required": True},
{"name": "body", "value": {"type": "script"}, "required": True},
],
},
"yield": {
"positionals": [
{"name": "value", "value": {"type": "any"}, "required": False},
@@ -1074,5 +1190,5 @@ commands = commands_schema({
]
},
# TODO: check subcommands
"zlib": check_count("zlib", 3, None),
"zlib": check_count("zlib", 2, None),
})
+256 -105
View File
@@ -1,25 +1,45 @@
"""Helpers for checking command arguments."""
from collections.abc import Callable
from typing import List, Optional, Union
from __future__ import annotations
from tclint.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node
from collections.abc import Callable
from typing import TYPE_CHECKING, Optional
from tclint.syntax_tree import ArgExpansion, BareWord, BracedWord, Node, QuotedWord
# This lets us use Parser in type annotations without introducing a cyclic dependency.
if TYPE_CHECKING:
from tclint.parser import Parser
class CommandArgError(Exception):
"""Exception raised by command handlers to indicate invalid arguments."""
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
#
def arg_count(args: list[Node], parser: Parser) -> tuple[int, bool]:
"""Returns the number of arguments in args, taking {*} into account.
If an argument list contains an argument expansion operator that cannot be
statically expanded, the second return value is True, and the count is the minimum
possible number of arguments. Otherwise, the return value is False and the count
reflects the exact number of arguments.
This function should always be used for validating argument count, rather than
relying on `len(args)`.
"""
# TODO: Replace this with or add a similar `expand_args` function that returns an
# expanded argument list. One tricky thing is we want to handle cases like:
# - foreach {*}$iters { ...body... }
# - catch {puts "my script"} {*}$catchopts
# With something like a list structure we can either forward or reverse index to
# still parse the body even with an unexpanded {*}.
# TODO: Add a violation that flags `{*}{a b c}` for rewriting as `a b c`. A future
# version of tclfmt that allows rewrites that break the syntax tree could do this
# automatically.
arg_count = 0
has_arg_expansion = False
@@ -28,14 +48,14 @@ def arg_count(args, parser):
if arg.contents is None:
has_arg_expansion = True
continue
arg_count += len(parser.parse_list(arg.contents))
arg_count += len(parser.parse_list(arg).children)
else:
arg_count += 1
return arg_count, has_arg_expansion
def check_count(command, min=None, max=None, args_name="args"):
def check_count(command, min=None, max=None):
def check(args, parser):
if min is None and max is None:
return None
@@ -44,19 +64,17 @@ def check_count(command, min=None, max=None, args_name="args"):
if not has_arg_expansion and min == max and count != min:
raise CommandArgError(
f"wrong # of {args_name} for {command}: got {count}, expected {min}"
f"wrong # of args 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}"
f"not enough args for {command}: got {count}, expected at least {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}"
f"too many args for {command}: got {count}, expected no more than {max}"
)
return None
@@ -64,7 +82,7 @@ def check_count(command, min=None, max=None, args_name="args"):
return check
def eval(args, parser, command):
def eval(args: list[Node], parser: Parser, command: str) -> list[Node]:
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
@@ -106,15 +124,18 @@ def eval(args, parser, command):
prev_arg_end_pos = arg.end_pos
script = parser.parse(eval_script, pos=(args[0].pos))
script = parser.parse(eval_script, pos=(args[0].contents_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]]:
command: str,
args: list[Node],
parser: Parser,
command_spec: Callable | dict | None,
) -> Optional[list[Node]]:
if command_spec is None:
return None
@@ -124,42 +145,97 @@ def check_command(
return command_spec(args, parser)
def _positional_has_type(type: str, arg_spec: dict, indices: list[int]) -> bool:
return any([arg_spec["positionals"][i]["value"]["type"] == type for i in indices])
def check_arg_spec(
command: str, args: List[Node], parser, arg_spec: dict
) -> Optional[List[Node]]:
command: str, args: list[Node], parser: 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())}")
return dispatch_subcommands(command, args, parser, arg_spec["subcommands"])
switches = arg_spec["switches"]
args_allowed = set(switches)
mapped, positional_args = map_switches(args, switches, command)
args_required = {switch for switch in switches if switches[switch]["required"]}
missing_required = args_required.difference(mapped)
if len(missing_required) > 1:
raise CommandArgError(
f"missing required arguments for {command}: {', '.join(missing_required)}"
)
elif len(missing_required) == 1:
raise CommandArgError(
f"missing required argument for {command}: {missing_required.pop()}"
)
positionals = [args[i] for i in positional_args]
mapping = map_positionals(positionals, arg_spec["positionals"], command)
args = list(args)
for arg_i, map_to_spec in zip(positional_args, mapping):
if _positional_has_type("script", arg_spec, map_to_spec):
args[arg_i] = parser.parse_script(args[arg_i])
elif _positional_has_type("expression", arg_spec, map_to_spec):
args[arg_i] = parser.parse_expression(args[arg_i])
return args
def dispatch_subcommands(
command: str, args: list[Node], parser: Parser, spec: dict
) -> Optional[list[Node]]:
try:
subcommand = args[0].contents
except IndexError:
subcommand = None
if subcommand in spec:
new_args = check_command(
f"{command} {subcommand}", args[1:], parser, spec[subcommand]
)
if new_args is None:
return new_args
return args[0:1] + new_args
if "" in spec:
return check_command(command, args, parser, spec[""])
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(spec.keys())}")
def map_switches(
args: list[Node], switches: dict, command_name: str
) -> tuple[set[str], list[int]]:
"""Separates switch arguments from positional arguments in a command's argument
list.
`switches` represents the "switches" entry of the spec for the given command. The
return value is a tuple of (mapped_switches, positional_indices). mapped_switches is
a set of switch names that were found in args. positional_indices is a list of
indices into args for arguments that are not switches.
If the switches found do not map correctly to the spec, this function raises
CommandArgError.
The `command_name` argument is used to generate descriptive error messages.
"""
mapped: set[str] = set()
if not switches:
return mapped, list(range(len(args)))
positional_args = []
args = list(args)
while len(args) > 0:
arg = args.pop(0)
arg_i = 0
while arg_i < len(args):
arg = args[arg_i]
arg_i += 1
# To facilitate better error messages, we expect that switches are always
# specified as BareWords that start with "-" or ">". This lets us throw an
@@ -170,71 +246,146 @@ def check_arg_spec(
# any switches should be BareWords.
contents = arg.contents
if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}):
positional_args.append(arg)
positional_args.append(arg_i - 1)
continue
# TODO check required arguments
if contents in args_allowed:
if contents in switches:
if contents in mapped and not switches[contents]["repeated"]:
raise CommandArgError(
f"duplicate argument for {command_name}: {contents}"
)
if switches[contents]["value"]:
try:
args.pop(0)
except IndexError:
arg_i += 1
if arg_i > len(args):
raise CommandArgError(
f"invalid arguments for {command}: expected value after"
f"invalid arguments for {command_name}: 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)
mapped.add(contents)
continue
if len(prefix_matches) == 1:
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_name}: expand {contents} to"
f" {prefix_matches[0]}"
)
if len(prefix_matches) > 1:
raise CommandArgError(
f"ambiguous argument for {command_name}: {contents} could be any of"
f" {', '.join(prefix_matches)}"
)
raise CommandArgError(f"unrecognized argument for {command_name}: {contents}")
return mapped, positional_args
def map_positionals(
args: list[Node], spec: list[dict], command_name: str
) -> list[list[int]]:
"""Maps a list of nodes representing positional command arguments to the specific
positional arguments of a command. spec represents the "positionals" entry of the
spec for the given command.
The return value is a list whose entries correspond one-to-one to the entries in
`args`. Each item in the return value is a list of indices into `spec`, indicating
which argument(s) in the spec the corresponding argument maps to.
A given index into `spec` may appear multiple times in the list (e.g. if it's a
variadic argument), and a list may contain more than one index for the mapping of an
arg expansion.
If the arguments do not map correctly to the spec, this function raises
CommandArgError.
Given a set of args and a spec, there may be multiple possible mappings. This
function will return some mapping if one exists.
The `command_name` argument is used to generate descriptive error messages.
"""
if len(args) == len(spec):
# Self explanatory: a 1:1 match in argument count should be a legal mapping.
return [[i] for i in range(len(args))]
mapping: list[list[int]] = []
i = 0
if len(args) > len(spec):
# If there are more arguments than specified positionals, we map every argument
# greedily and assign the extra # of arguments to the first variadic we find.
extra = len(args) - len(spec)
for arg in args:
if i >= len(spec):
# We never found a variadic to save us, raise an error.
raise CommandArgError(
f"shortened argument for {command}: expand {contents} to"
f" {prefix_matches[0]}"
f"too many arguments for {command_name}: got {len(args)}, expected"
f" no more than {len(spec)}"
)
if len(prefix_matches) > 1:
raise CommandArgError(
f"ambiguous argument for {command}: {contents} could be any of"
f" {', '.join(prefix_matches)}"
)
mapping.append([i])
if spec[i]["value"]["type"] == "variadic" and extra > 0:
extra -= 1
else:
i += 1
raise CommandArgError(f"unrecognized argument for {command}: {contents}")
return mapping
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()}"
)
required = []
for argspec in spec:
if argspec["required"]:
required.append(argspec["name"])
num_required = len(required)
min_positionals = 0
max_positionals: Optional[int] = 0
for positional in arg_spec["positionals"]:
if positional["value"]["type"] == "variadic":
max_positionals = None
if len(args) < num_required:
# If there are fewer arguments than required positionals, we map only required
# arguments and expand the first arg expansion we find to account for what's
# missing.
missing = num_required - len(args)
for arg in args:
while not spec[i]["required"]:
i += 1
if positional["required"]:
min_positionals += 1
if max_positionals is not None:
max_positionals += 1
mapping.append([i])
i += 1
check = check_count(
command,
min=min_positionals,
max=max_positionals,
args_name="positional args",
)
check(positional_args, None)
if isinstance(arg, ArgExpansion):
# Map missing arguments.
while missing > 0:
if spec[i]["required"]:
mapping[-1] += [i]
missing -= 1
i += 1
return None
if missing > 0:
missing_names = ", ".join(required[-missing:])
raise CommandArgError(
f"missing required argument{'s' if missing > 1 else ''} for"
f" {command_name}: {missing_names}"
)
return mapping
optionals = len(args) - num_required
for arg in args:
# If our argument count falls somewhere in between the required and total
# specified numbers of positionals, we map all required arguments and map as
# many optionals as needed (as we find them).
if not spec[i]["required"] and optionals > 0:
mapping.append([i])
i += 1
optionals -= 1
continue
while not spec[i]["required"]:
i += 1
mapping.append([i])
i += 1
return mapping
+90 -19
View File
@@ -1,25 +1,31 @@
from importlib_metadata import entry_points
import json
import pathlib
from typing import Dict, Optional
from collections.abc import Sequence
from importlib.util import module_from_spec, spec_from_file_location
from types import ModuleType
from typing import Optional
import voluptuous
from importlib_metadata import EntryPoint, entry_points
from tclint.commands.schema import schema as command_schema
from tclint.commands import builtin as _builtin
from tclint.commands import schema
class _PluginManager:
def __init__(self):
self._loaded = {}
self._installed = {}
self._loaded_specs = {}
class PluginManager:
def __init__(self, trust_uninstalled=False) -> None:
self._loaded: dict[str, Optional[dict]] = {}
self._installed: dict[str, EntryPoint] = {}
self._loaded_specs: dict[pathlib.Path, Optional[dict]] = {}
self._loaded_py: dict[pathlib.Path, Optional[dict]] = {}
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]:
self._trust_uninstalled = trust_uninstalled
def load(self, name: str) -> Optional[dict]:
if name in self._loaded:
return self._loaded[name]
@@ -27,7 +33,7 @@ class _PluginManager:
self._loaded[name] = mod
return mod
def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
def load_from_spec(self, path: pathlib.Path) -> Optional[dict]:
if path in self._loaded_specs:
return self._loaded_specs[path]
@@ -35,17 +41,21 @@ class _PluginManager:
self._loaded_specs[path] = spec
return spec
def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
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):
# expanduser() may raise RuntimeError
print(f"Warning: command spec {path} not found, skipping...")
return None
except (json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"Warning: {path} contains invalid JSON: {e}, skipping...")
return None
try:
# Apply defaults and validate the spec.
spec = command_schema(spec)
spec = schema.schema(spec)
except voluptuous.Invalid as e:
print(f"Warning: invalid command spec {path}: {e}")
return None
@@ -67,8 +77,7 @@ class _PluginManager:
return module
def _load(self, name: str):
module = self.get_mod(name)
def _load_module(self, name, module):
if module is None:
print(f"Skipping requested plugin {name}")
return None
@@ -77,10 +86,72 @@ class _PluginManager:
print(f"Warning: skipping plugin {name} since it does not define commands")
return None
return getattr(module, "commands")
spec = getattr(module, "commands")
try:
# Apply defaults and validate the spec.
spec = schema.commands_schema(spec)
except voluptuous.Invalid as e:
print(f"Warning: invalid plugin {name}: {e}")
return None
return spec
# 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()
def _load(self, name: str):
module = self.get_mod(name)
return self._load_module(name, module)
def load_from_py(self, path: pathlib.Path) -> Optional[dict]:
if path in self._loaded_py:
return self._loaded_py[path]
spec = self._load_from_py(path)
self._loaded_py[path] = spec
return spec
def _load_from_py(self, path: pathlib.Path) -> Optional[dict]:
mod = None
name = path.stem
# By default, reject paths to dynamic plugins. This restriction is designed to
# make it explicit when tclint is executing external code.
if not self._trust_uninstalled:
print(
f"Warning: skipping untrusted plugin {path}. If you trust the code at"
" this path, re-run with --trust-plugins to load the plugin"
)
return None
try:
spec = spec_from_file_location(name, path)
if spec is not None:
mod = module_from_spec(spec)
if spec.loader is not None:
spec.loader.exec_module(mod)
except FileNotFoundError:
print(f"Warning: command spec {path} not found, skipping...")
return None
except Exception as e:
print(f"Warning: error loading plugin {path}: {e}")
return None
return self._load_module(name, mod)
def get_commands(self, plugins: Sequence[str | pathlib.Path]) -> dict:
commands = {}
commands.update(_builtin.commands)
for plugin in plugins:
if isinstance(plugin, str):
plugin_commands = self.load(plugin)
elif isinstance(plugin, pathlib.Path):
if plugin.suffix == ".py":
plugin_commands = self.load_from_py(plugin)
else:
plugin_commands = self.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
+8 -2
View File
@@ -1,5 +1,6 @@
from collections.abc import Callable
from voluptuous import Schema, Optional, Or, Self
from voluptuous import Optional, Or, Schema, 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.
@@ -9,7 +10,12 @@ _command_args = Schema(
{
"name": str,
"required": bool,
"value": Or({"type": "any"}, {"type": "variadic"}),
"value": Or(
{"type": "any"},
{"type": "variadic"},
{"type": "script"},
{"type": "expression"},
),
}
],
Optional("switches", default={}): {
+9 -8
View File
@@ -37,24 +37,25 @@ class CommentVisitor(Visitor):
command = split[0]
rule_strs = []
rule_strs: list[str] = []
if len(split) > 1:
rest = split[-1]
rule_strs = rest.split("--", 1)[0]
rule_strs = rule_strs.replace(" ", "")
rule_strs = rule_strs.split(",")
s = rest.split("--", 1)[0]
s = s.replace(" ", "")
rule_strs = s.split(",")
rules = []
rules: list[Rule] = []
if not rule_strs:
# default if no rules specified is all violation types
rules = ALL_RULES
else:
for rule in rule_strs:
for rule_str in rule_strs:
try:
rules.append(Rule(rule))
rules.append(Rule(rule_str))
except ValueError:
self._warning(
f"unknown rule '{rule}' provided to '{command}'", comment.pos
f"unknown rule '{rule_str}' provided to '{command}'",
comment.pos,
)
if command == "tclint-disable":
+352 -297
View File
@@ -1,20 +1,38 @@
import argparse
import pathlib
from typing import Union, List
from typing import Optional as OptionalType
import dataclasses
import pathlib
import sys
from enum import IntEnum
from typing import Callable, NamedTuple
from typing import Optional as OptionalType
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
from voluptuous import Schema, Optional, And, Coerce, Invalid, Range
from voluptuous import And, Coerce, Invalid, Optional, Range, Schema
from tclint.violations import Rule
class SpacesInBraces(IntEnum):
"""Enum listing valid values for --spaces-in-braces."""
NEVER = 0
ALWAYS = 1
BALANCED_NO = 2
BALANCED_YES = 3
class ExcludePattern(NamedTuple):
"""Exclude patterns are applied relative to a certain root. This dataclass is used
to bundle the two."""
pattern: str
root: pathlib.Path
@dataclasses.dataclass
class Config:
"""This dataclass defines the supported Config fields and their default
@@ -24,17 +42,20 @@ class Config:
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(
exclude: list[ExcludePattern] = dataclasses.field(default_factory=list)
ignore: list[Rule] = dataclasses.field(default_factory=list)
commands: list[str | pathlib.Path] = dataclasses.field(default_factory=list)
extensions: list[str] = dataclasses.field(
default_factory=lambda: ["tcl", "sdc", "xdc", "upf"]
)
style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None)
style_indent: OptionalType[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)
style_spaces_in_braces: SpacesInBraces = dataclasses.field(
default=SpacesInBraces.NEVER
)
style_emacs: bool = dataclasses.field(default=False)
def apply_cli_args(self, args):
args_dict = vars(args)
@@ -63,273 +84,42 @@ class Config:
return "\t"
elif isinstance(self.style_indent, int):
return " " * self.style_indent
elif isinstance(self.style_indent, tuple):
return " " * self.style_indent[0]
# Should be unreachable, validated on ingestion of config
raise ValueError(
f"unexpected value for config.style_indent: {self.style_indent}"
)
def get_indent_mixed_tab_size(self) -> int:
if isinstance(self.style_indent, tuple):
return self.style_indent[1]
return 0
# 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
def diff(self) -> str:
"""Return string representation of Config only showing fields that differ from
default instance."""
default_config = Config()
values = []
for field in dataclasses.fields(self):
value = getattr(self, field.name)
default = getattr(default_config, field.name)
if value != default:
values.append(f"{field.name}={value}")
return f"Config({', '.join(values)})"
@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 = _validate_config(config_dict, root)
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)
return cls(**config_dict)
@classmethod
def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path):
def from_path(cls, path: str | pathlib.Path, root: pathlib.Path):
path = pathlib.Path(path)
if not path.exists():
if not path.exists() or path.is_dir():
raise FileNotFoundError
with open(path, "rb") as f:
@@ -365,22 +155,306 @@ class RunConfig:
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
# Validators using `voluptuous` library that check and normalize config inputs.
# Used for checking both config files as well as config-related CLI args.
return self._global_config
# 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 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)
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
def _add_root(root: pathlib.Path) -> Callable[[pathlib.Path], pathlib.Path]:
"""Resolve path relative to `root.`"""
def _path(path: pathlib.Path) -> pathlib.Path:
path = path.expanduser()
if not path.is_absolute():
path = root / path
return path
return _path
def parse_mixed(v: str) -> tuple[int, int]:
"""Parse --indent=mixed,<s>,<t>."""
s = v.split(",")
if not (len(s) == 3 and s[0] == "mixed" and s[1].isdigit() and s[2].isdigit()):
raise ValueError()
return (int(s[1]), int(s[2]))
# Define validators as module variables so they can be reused for config file schema
# validation and CLI argument parsing.
def _validate_exclude(root):
"""Along with parsing the list, bundles exclude patterns with their root."""
return And(_str2list, [lambda p: ExcludePattern(p, root)])
_validate_ignore = And(
_str2list,
[
Coerce(Rule, msg="invalid rule ID"),
],
)
def _validate_commands(root):
add_root = _add_root(root)
def _process(command: str):
path = add_root(pathlib.Path(command))
if path.exists():
return path
return command
return And(_str2list, [_process])
_validate_extensions = _str2list
_validate_style_indent = Coerce(
lambda v: (
v
if v == "tab"
else (
int(v)
if isinstance(v, int) or (isinstance(v, str) and v.isdigit())
else parse_mixed(v)
)
),
msg="expected integer, 'tab', or 'mixed',integer,integer",
)
_validate_style_line_length = Coerce(int)
_validate_style_max_blank_lines = And(
Coerce(int),
Range(min=1),
)
_validate_style_indent_namespace_eval = bool
_validate_style_emacs = bool
def parse_spaces_in_braces(v: str | bool) -> SpacesInBraces:
if isinstance(v, bool):
# Handle spaces-in-braces = true/false in config file.
return SpacesInBraces.ALWAYS if v else SpacesInBraces.NEVER
if v == "never":
return SpacesInBraces.NEVER
if v == "always":
return SpacesInBraces.ALWAYS
if v == "balanced-no":
return SpacesInBraces.BALANCED_NO
if v == "balanced-yes":
return SpacesInBraces.BALANCED_YES
raise ValueError()
_validate_style_spaces_in_braces = Coerce(
lambda v: (parse_spaces_in_braces(v)),
msg="always, never, balanced-yes, or balanced-no",
)
_validate_style_no_spaces_in_braces = Coerce(lambda v: (v))
def _validate_config(config: dict, root: pathlib.Path):
"""Validates dictionary read from TOML config file. Individual value validators
are implemented in the module-level variables above; this defines the actual
structure of the schema.
root is used to resolve values that may be relative to a certain path.
"""
schema = Schema({
Optional("exclude"): _validate_exclude(root),
Optional("extensions"): _validate_extensions,
Optional("ignore"): _validate_ignore,
Optional("commands"): _validate_commands(root),
Optional("style"): {
Optional("indent"): _validate_style_indent,
Optional("line-length"): _validate_style_line_length,
Optional("max-blank-lines"): _validate_style_max_blank_lines,
Optional("indent-namespace-eval"): _validate_style_indent_namespace_eval,
Optional("spaces-in-braces"): _validate_style_spaces_in_braces,
Optional("emacs"): _validate_style_emacs,
},
})
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: list[str] = []
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 _argparsify(validator: Callable) -> Callable:
"""Wrapper that applies a voluptuous-style validator and is compatible with
argparse's type argument."""
def func(s):
try:
return Schema(validator)(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, cwd: pathlib.Path):
"""
This method defines config-related CLI arguments common to both tclint and tclfmt.
The destvars of these switches should match the fields of Config.
Relative paths and exclude patterns will be resolved relative to `cwd`. It may seem
weird to specify this in a "setup" function (as opposed to Config.apply_cli_args),
but the paths are resolved by the validator functions configured here. For our use
case, this is fine since the setup and application of the CLI args are close
together in the application code.
"""
config_group.add_argument(
"--trust-plugins",
action="store_true",
help="enables execution of uninstalled Python-based command plugins",
)
config_group.add_argument(
"--exclude",
type=_argparsify(_validate_exclude(cwd)),
metavar='"path1, path2, ..."',
)
config_group.add_argument(
"--extend-exclude",
type=_argparsify(_validate_exclude(cwd)),
metavar='"path1, path2, ..."',
)
config_group.add_argument(
"--extensions",
type=_argparsify(_validate_extensions),
metavar='"tcl, xdc, ..."',
)
config_group.add_argument(
"--commands", type=_argparsify(_validate_commands(cwd)), metavar="<path>"
)
def setup_config_cli_args(parser, cwd: pathlib.Path):
"""This method defines config-related CLI arguments used by tclint.
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=_argparsify(_validate_ignore), metavar='"rule1, rule2, ..."'
)
config_group.add_argument(
"--extend-ignore",
type=_argparsify(_validate_ignore),
metavar='"rule1, rule2, ..."',
)
setup_common_config_cli_args(config_group, cwd)
config_group.add_argument(
"--style-line-length",
type=_argparsify(_validate_style_line_length),
metavar="<line_length>",
)
def setup_tclfmt_config_cli_args(parser, cwd: pathlib.Path):
"""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, cwd)
config_group.add_argument(
"--indent",
type=_argparsify(_validate_style_indent),
metavar="<indent>",
dest="style_indent",
)
config_group.add_argument(
"--max-blank-lines",
type=_argparsify(_validate_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",
)
config_group.add_argument(
"--spaces-in-braces",
type=_argparsify(_validate_style_spaces_in_braces),
metavar="<always|never|balanced-yes|balanced-no>",
dest="style_spaces_in_braces",
)
# Alias for --spaces-in-braces never.
config_group.add_argument(
"--no-spaces-in-braces",
action="store_const",
const=SpacesInBraces.NEVER,
dest="style_spaces_in_braces",
)
_add_bool(
config_group,
parser,
"style_emacs",
"--emacs",
"--no-emacs",
)
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 ConfigError(Exception):
@@ -390,34 +464,15 @@ class ConfigError(Exception):
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")
def load_config_at(directory: pathlib.Path) -> OptionalType[Config]:
for path in DEFAULT_CONFIGS:
try:
return RunConfig.from_path(root / path, root)
return Config.from_path(directory / path, directory)
except FileNotFoundError:
pass
try:
return RunConfig.from_pyproject(directory=root)
return Config.from_pyproject(directory=directory)
except ConfigError as e:
raise e
except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError):
+195 -74
View File
@@ -1,53 +1,56 @@
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
from tclint.syntax_tree import (
ArgExpansion,
BareWord,
BinaryOp,
BracedExpression,
BracedWord,
Command,
CommandSub,
Comment,
CompoundBareWord,
Expression,
Function,
List,
Node,
ParenExpression,
QuotedWord,
Script,
TernaryOp,
UnaryOp,
VarSub,
)
@dataclasses.dataclass
class LiteralBlock:
block: List[str]
pos: Tuple[int, int]
end_pos: Tuple[int, int]
block: list[str]
pos: tuple[int, int]
end_pos: tuple[int, int]
@dataclasses.dataclass
class FormatterOpts:
indent: str
spaces_in_braces: bool
balanced_spaces_in_braces: bool
max_blank_lines: int
indent_namespace_eval: bool
indent_mixed_tab_size: int
emacs: bool
debug_whitespace: bool
class Formatter:
def __init__(self, opts: FormatterOpts):
self.opts = opts
self.indent_mixed_tab_size = opts.indent_mixed_tab_size
def _indent(self, lines: List[str], indent: str) -> List[str]:
def _indent(self, lines: list[str], indent: str) -> list[str]:
indented = []
for line in lines:
if line == "":
@@ -57,17 +60,70 @@ class Formatter:
return indented
def _brace(self, lines: List[str]) -> List[str]:
spaces_in_braces = " " if self.opts.spaces_in_braces else ""
def space(self, debug_char, space=None):
"""Returns a string required for indentation or separation.
By default, just returns a string of space. If enabled using
`--debug-whitespace`, returns a string of debug_char.
"""
assert len(debug_char) == 1
if space is None:
space = self.opts.indent
if self.opts.debug_whitespace:
# Enable this to return a string of debug_char.
return len(space) * debug_char
return space
def get_spaces_in_braces(self, space: tuple[int, int]):
spaces_in_braces = self.space("A", " ") if self.opts.spaces_in_braces else ""
if not self.opts.balanced_spaces_in_braces:
# No balancing.
return spaces_in_braces
if space[0] == -1 and space[1] == -1:
# No info to do balancing.
return spaces_in_braces
assert not (space[0] == -1 and space[1] != -1)
if space[0] != -1 and space[1] == -1:
# we've got empty braces. Keep "{}" and "{ }" as is, but normalize
# more than one space to a single space.
return min(space[0], 1) * self.space("B", " ")
# Normalize more than one space to a single space.
before = min(space[0], 1)
after = min(space[1], 1)
if before + after == 1:
# If we have an unbalanced expression like "{1 }" or "{ 1}",
# transform it to either "{1}" or "{ 1 }", using spaces_in_braces.
return spaces_in_braces
# Check that we have a balanced expression.
assert before == after
# Keep either "{1}" or "{ 1 }".
return before * self.space("C", " ")
def _brace(self, lines: list[str], space: tuple[int, int]) -> list[str]:
"""Format content between braces.
The space argument indicates the amount of space in the input, for
instance:
- (1, 0) to represent 1 space before and no space after, for "{ 1}", and
- (0, -1) to represent no space, for "{}".
"""
spaces_in_braces = self.get_spaces_in_braces(space)
if lines == [""]:
# Empty braces.
return ["{" + spaces_in_braces + "}"]
# Not empty 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]:
def format(self, *nodes: Node | LiteralBlock) -> list[str]:
formatted = []
for node in nodes:
if isinstance(node, Script):
@@ -90,7 +146,7 @@ class Formatter:
formatted += self.format_var_sub(node)
elif isinstance(node, ArgExpansion):
formatted += self.format_arg_expansion(node)
elif isinstance(node, ListNode):
elif isinstance(node, List):
formatted += self.format_list(node)
elif isinstance(node, Expression):
formatted += self.format_expression(node)
@@ -113,10 +169,47 @@ class Formatter:
return formatted
def reindent(self, lines: list[str]) -> list[str]:
"""Apply mixed space/tab indentation scheme.
Apply the mixed space/tab indentation scheme as requested by
--indent=mixed,<s>,<t>.
The input is lines with indentation in the form of spaces and/or tabs.
This function transforms the indentation into a number of tabs,
followed by a number of spaces.
A more structural way of doing this would be to model input lines as a
tuple of an indentation level and a string, and use this function to
expand the indentation level, but that requires broader changes.
"""
tab_size = self.indent_mixed_tab_size
if tab_size == 0:
return lines
fixed_lines = []
for line in lines:
# Split line into leading whitespace, and the rest.
after = line.lstrip()
split_pos = len(line) - len(after)
leading = line[0:split_pos]
# Expand tabs.
leading = leading.expandtabs(tab_size)
# Tabify.
leading = leading.replace(" " * tab_size, "\t")
fixed_lines.append(leading + after)
return fixed_lines
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"
lines = self.format_script_contents(tree)
lines = self.reindent(lines)
return "\n".join(lines) + "\n"
def format_partial(self, script: str, parser: Parser) -> str:
"""Formats a partial Tcl script.
@@ -139,11 +232,13 @@ class Formatter:
tree = parser.parse(script)
self.script = script.split("\n")
formatted = "\n".join(self.format_script_contents(tree))
lines = self._indent(self.format_script_contents(tree), indent)
lines = self.reindent(lines)
formatted = "\n".join(lines)
return leading + textwrap.indent(formatted, indent) + trailing
return leading + formatted + trailing
def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]:
def format_script_contents(self, script: Script | CommandSub) -> list[str]:
to_format = []
skip_formatting_start = None
for child in script.children:
@@ -210,10 +305,17 @@ class Formatter:
return formatted
def format_script(self, script: Script, should_indent=True) -> List[str]:
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)
space_before = -1
space_after = -1
if len(script.children) != 0:
space_before = script.children[0].pos[1] - script.pos[1] - 1
space_after = script.end_pos[1] - script.children[-1].end_pos[1] - 1
else:
space_before = script.end_pos[1] - script.pos[1] - 2
return self._brace(lines, (space_before, space_after))
# 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
@@ -225,15 +327,15 @@ class Formatter:
and isinstance(script.children[0], Comment)
and script.pos[0] == script.children[0].pos[0]
):
open_brace += " " + lines[0]
open_brace += self.space("D", " ") + lines[0]
lines = lines[1:]
if should_indent:
return [open_brace] + self._indent(lines, self.opts.indent) + ["}"]
return [open_brace] + self._indent(lines, self.space("E")) + ["}"]
else:
return [open_brace] + lines + ["}"]
def format_command(self, command: Command) -> List[str]:
def format_command(self, command: Command) -> list[str]:
is_namespace_eval = (
command.routine.contents == "namespace"
and len(command.args) > 0
@@ -251,23 +353,27 @@ class Formatter:
child_lines = self.format(child)
if last_line == child.pos[0]:
formatted[-1] += " "
formatted[-1] += self.space("F", " ")
if self.opts.emacs and child_lines[0][-1] == "\\":
base_indent = (len(formatted[-1])) * self.space("G", " ")
else:
base_indent = ""
formatted[-1] += child_lines[0]
else:
formatted[-1] += " \\"
formatted.append(self.opts.indent + child_lines[0])
formatted[-1] += self.space("H", " ") + "\\"
formatted.append(self.space("I") + child_lines[0])
hanging_indent = True
if hanging_indent:
formatted.extend(self._indent(child_lines[1:], self.opts.indent))
formatted.extend(self._indent(child_lines[1:], self.space("J")))
else:
formatted.extend(child_lines[1:])
formatted.extend(self._indent(child_lines[1:], base_indent))
last_line = child.end_pos[0]
return formatted
def format_comment(self, comment: Comment) -> List[str]:
def format_comment(self, comment: Comment) -> list[str]:
return [f"#{comment.value}"]
def format_command_sub(self, command_sub):
@@ -278,21 +384,25 @@ class Formatter:
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.extend(self._indent(contents, self.space("K")))
formatted.append("]")
else:
formatted.append("[" + contents[0])
formatted.extend(contents[1:])
if self.opts.emacs:
indent = self.space("L", " ")
else:
indent = ""
formatted.extend(self._indent(contents[1:], indent))
formatted[-1] += "]"
return formatted
def format_bare_word(self, word) -> List[str]:
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]:
def format_quoted_word(self, word) -> list[str]:
if word.contents is not None:
return [f'"{word.contents}"']
@@ -302,11 +412,11 @@ class Formatter:
return [f'"{formatted}"']
def format_braced_word(self, word) -> List[str]:
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]:
def format_compound_bare_word(self, word) -> list[str]:
formatted = [""]
for child in word.children:
child_lines = self.format(child)
@@ -315,7 +425,7 @@ class Formatter:
return formatted
def format_var_sub(self, varsub) -> List[str]:
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.
@@ -337,20 +447,20 @@ class Formatter:
return formatted
def format_arg_expansion(self, arg_expansion) -> List[str]:
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]:
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] += " "
contents[-1] += self.space("M", " ")
else:
newlines = child.pos[0] - last_line
newlines = min(newlines, 3)
@@ -363,18 +473,27 @@ class Formatter:
last_line = child.end_pos[0]
if list_node.pos[0] == list_node.end_pos[0]:
return self._brace(contents)
space_before = -1
space_after = -1
if len(list_node.children) != 0:
space_before = list_node.children[0].pos[1] - list_node.pos[1] - 1
space_after = (
list_node.end_pos[1] - list_node.children[-1].end_pos[1] - 1
)
else:
space_before = list_node.end_pos[1] - list_node.pos[1] - 2
return self._brace(contents, (space_before, space_after))
return ["{"] + self._indent(contents, self.opts.indent) + ["}"]
return ["{"] + self._indent(contents, self.space("N")) + ["}"]
def format_expression(self, expr) -> List[str]:
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)
formatted += self._indent([line], self.space("O"))
# Trick: we know there are quotes around the expression if the start of the
# expression is a different column than its first child.
@@ -385,7 +504,7 @@ class Formatter:
return formatted
def format_braced_expression(self, expr) -> List[str]:
def format_braced_expression(self, expr) -> list[str]:
formatted = [""]
for child in expr.children:
lines = self.format(child)
@@ -393,11 +512,13 @@ class Formatter:
formatted.extend(lines[1:])
if expr.pos[0] == expr.end_pos[0]:
return self._brace(formatted)
space_before = expr.children[0].pos[1] - expr.pos[1] - 1
space_after = expr.end_pos[1] - expr.children[-1].end_pos[1] - 1
return self._brace(formatted, (space_before, space_after))
return ["{"] + self._indent(formatted, self.opts.indent) + ["}"]
return ["{"] + self._indent(formatted, self.space("P")) + ["}"]
def format_paren_expression(self, expr) -> List[str]:
def format_paren_expression(self, expr) -> list[str]:
body = expr.body
formatted = ["("]
@@ -408,7 +529,7 @@ class Formatter:
formatted[-1] += lines[0]
formatted.extend(lines[1:])
formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent)
formatted = formatted[0:1] + self._indent(formatted[1:], self.space("Q"))
if expr.end_pos[0] != body.end_pos[0]:
formatted.append(")")
@@ -425,7 +546,7 @@ class Formatter:
lines[0] = op[0] + lines[0]
return lines
def _format_op(self, expr) -> List[str]:
def _format_op(self, expr) -> list[str]:
nodes = expr.children
formatted = self.format(nodes[0])
@@ -435,23 +556,23 @@ class Formatter:
if last.end_pos[0] != next.pos[0]:
formatted.extend(lines)
else:
formatted[-1] += " "
formatted[-1] += self.space("R", " ")
formatted[-1] += lines[0]
formatted.extend(lines[1:])
last = next
return formatted
def format_binary_op(self, expr) -> List[str]:
def format_binary_op(self, expr) -> list[str]:
return self._format_op(expr)
def format_ternary_op(self, expr) -> List[str]:
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]
name_parts = self.format(function.name)
assert len(name_parts) == 1
name = name_parts[0]
formatted = [f"{name}("]
@@ -464,13 +585,13 @@ class Formatter:
formatted.extend(lines)
else:
if i > 0:
formatted[-1] += " "
formatted[-1] += self.space("S", " ")
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)
formatted = formatted[0:1] + self._indent(formatted[1:], self.space("T"))
if last.end_pos[0] != function.end_pos[0]:
formatted.append(")")
+2 -3
View File
@@ -1,5 +1,4 @@
import ply.lex as lex
from typing import Tuple
TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE"
TOK_BACKSLASH_SUB = "BACKSLASH_SUB"
@@ -28,7 +27,7 @@ STATE_BRACEDWORD = "bracedword"
class TclSyntaxError(Exception):
def __init__(self, message, start: Tuple[int, int], end: Tuple[int, int]):
def __init__(self, message, start: tuple[int, int], end: tuple[int, int]):
super().__init__(message)
self.start = start
self.end = end
@@ -239,5 +238,5 @@ class Lexer:
self.next()
def assert_(self, *tokens):
assert self.current.type in tokens
assert self.type() in tokens
self.next()
+56 -42
View File
@@ -1,51 +1,55 @@
import string
import io
import re
import string
from typing import Optional, Tuple
from tclint.commands import CommandArgError
from tclint.commands import builtin as _builtin
from tclint.commands.checks import check_command
from tclint.lexer import (
Lexer,
TclSyntaxError,
STATE_BRACEDWORD,
TOK_ALPHA_CHARS,
TOK_ARG_EXPANSION,
TOK_BACKSLASH_NEWLINE,
TOK_DOLLAR,
TOK_EOF,
TOK_HASH,
TOK_LBRACE,
TOK_LBRACKET,
TOK_LPAREN,
TOK_NAMESPACE_SEP,
TOK_NEWLINE,
TOK_NUM_CHARS,
TOK_QUOTE,
TOK_RBRACE,
TOK_RBRACKET,
TOK_RPAREN,
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,
Lexer,
TclSyntaxError,
)
from tclint.syntax_tree import (
Script,
Comment,
ArgExpansion,
BareWord,
BinaryOp,
BracedExpression,
BracedWord,
Command,
CommandSub,
ArgExpansion,
VarSub,
BareWord,
BracedWord,
QuotedWord,
Comment,
CompoundBareWord,
List,
Expression,
BracedExpression,
ParenExpression,
UnaryOp,
BinaryOp,
TernaryOp,
Function,
List,
Node,
ParenExpression,
QuotedWord,
Script,
TernaryOp,
UnaryOp,
VarSub,
)
from tclint.commands import CommandArgError, get_commands
from tclint.commands.checks import check_command
from tclint.violations import Rule, Violation
@@ -98,22 +102,26 @@ class _Word:
class Parser:
def __init__(self, debug=False, command_plugins=None):
def __init__(self, debug=False, commands: Optional[dict] = None):
self._debug = debug
self._debug_indent = 0
# TODO: better way to handle this?
self.violations = []
self.violations: list[Violation] = []
if command_plugins is None:
command_plugins = []
self._commands = get_commands(command_plugins)
if commands is None:
commands = _builtin.commands
self._commands = commands
# Used to normalize newlines consistently with open()'s universal newlines mode.
self._decoder = io.IncrementalNewlineDecoder(None, True)
def debug(self, *msg):
if self._debug:
print(" " * self._debug_indent, end="")
print(*msg)
def parse(self, script, pos=None):
def parse(self, script: str, pos: Optional[Tuple[int, int]] = None):
script = self._decoder.decode(script, True)
lexer = Lexer(pos=pos)
lexer.input(script)
tree = self._parse_script(lexer, in_command_sub=False)
@@ -191,6 +199,7 @@ class Parser:
self._debug_indent += 1
pos = ts.pos()
script: Script | CommandSub
if in_command_sub:
script = CommandSub(pos=pos)
else:
@@ -520,7 +529,7 @@ class Parser:
self._debug_indent -= 1
return script
def parse_list(self, node):
def parse_list(self, node: Node) -> List:
"""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.
@@ -756,9 +765,14 @@ class Parser:
operator = ts.value()
ts.next()
else:
raise TclSyntaxError(
f"invalid operator in expression: {ts.value()}", pos, ts.pos()
)
message = "invalid operator in expression: "
if ts.value() == "\\ ":
message += (
"\\ (check for trailing whitespace if it's the end of the line)"
)
else:
message += ts.value()
raise TclSyntaxError(message, pos, ts.pos())
return BareWord(operator, pos=pos, end_pos=ts.pos())
+64
View File
@@ -0,0 +1,64 @@
"""Plugin for validating expect commands.
See https://www.tcl-lang.org/man/expect5.31/expect.1.html for reference.
"""
from tclint.commands.builtin import commands as builtins
from tclint.commands.checks import (
CommandArgError,
check_arg_spec,
map_switches,
)
def close(args, parser):
"""close [-slave] [-onexec 0|1] [-i spawn_id]"""
# Fancy handling since it seems like expect will fall back to Tcl's built-in close
# if none of its switches match, and we can't express this in a static arg spec.
# Try to replicate the logic here, reusing the functions used by `check_arg_spec`.
if len(args) == 0:
# No args is okay.
return None
expect_switches = {
"-slave": {"required": False, "repeated": False, "value": None},
"-onexec": {
"required": False,
"repeated": False,
"value": {"type": "any"},
"metavar": "0|1",
},
"-i": {
"required": False,
"repeated": False,
"value": {"type": "any"},
"metavar": "spawn_id",
},
}
mapped, positionals = map_switches(args, expect_switches, "close")
if len(mapped) > 0:
if len(positionals) > 0:
raise CommandArgError(
f"too many arguments for close: got {len(positionals)}, expected"
" no more than 0"
)
return None
return check_arg_spec("close", args, parser, builtins["close"])
commands = {
"close": close,
"exit": {
"positionals": [
# TODO: break out into switches once we have a way to support -onexit's
# optional value.
{"name": "opts", "value": {"type": "variadic"}, "required": False},
# TODO: add (positive) integer type.
{"name": "status", "value": {"type": "any"}, "required": False},
],
},
}
+45
View File
@@ -0,0 +1,45 @@
import logging
from collections import defaultdict
from tclint.syntax_tree import Command, CommandSub, Node, Script, Visitor
class SymbolTable:
"""Holds a symbol table (links symbols to nodes)."""
def __init__(self) -> None:
self.proc_def: defaultdict[str, list[Node]] = defaultdict(list)
def add_proc_definition(self, command: Command) -> None:
"""Add definition of procedure"""
# command holds the "proc" keyword, so the proc name is 1st argument
proc_name_node = command.args[0]
proc_name = proc_name_node.contents
if not proc_name:
return
logging.debug(
f"Definition of proc '{proc_name}' at {proc_name_node._pos_str()}"
)
self.proc_def[proc_name].append(proc_name_node)
def lookup_proc_definitions(self, symbol_text: str) -> list[Node]:
"""Lookup definitions of the procedure pointed at by node"""
if symbol_text is None or symbol_text not in self.proc_def:
return []
return self.proc_def[symbol_text]
class SymbolTableBuilder(Visitor):
"""Builds a symbol table."""
def __init__(self):
self.table = SymbolTable()
def build(self, tree: CommandSub | Script) -> SymbolTable:
"""Run the builder visitor through the syntax tree, building a table."""
tree.accept(self, recurse=True)
return self.table
def visit_command(self, command: Command) -> None:
if command.routine.contents == "proc":
self.table.add_proc_definition(command)
+43 -3
View File
@@ -1,4 +1,6 @@
"""Classes for representing and interacting with Tcl syntax trees. """
"""Classes for representing and interacting with Tcl syntax trees."""
from __future__ import annotations
class Visitor:
@@ -222,6 +224,31 @@ class Node:
for child in self.children:
child.accept(visitor, recurse=True)
def _pos_match(self, line: int, col: int) -> bool:
"""Return True if pos is within this node's block"""
if self.pos is None:
return False
if self.end_pos is None:
return line == self.pos[0] and col == self.pos[1]
return (
line >= self.pos[0]
and line <= self.end_pos[0]
and (col >= self.pos[1] or line > self.pos[0])
and (col < self.end_pos[1] or line < self.end_pos[0])
)
def find_by_pos(self, line: int, col: int) -> Node | None:
"""Find the deepest child node in the tree (i.e. most granular match) that
matches the given position."""
if not self._pos_match(line, col):
return None
for child in self.children:
if child._pos_match(line, col):
return child.find_by_pos(line, col)
return self
class Script(Node):
def __init__(self, *args, **kwargs):
@@ -236,6 +263,11 @@ class Script(Node):
class Comment(Node):
value: str
def __init__(self, value: str, pos=None, end_pos=None):
super().__init__(value, pos=pos, end_pos=end_pos)
def accept(self, visitor, recurse=False):
if recurse:
self._recurse(visitor)
@@ -243,7 +275,7 @@ class Comment(Node):
class Command(Node):
def __init__(self, routine, *args, pos=None, end_pos=None):
def __init__(self, routine: Node, *args: Node, pos=None, end_pos=None):
self.routine = routine
self.args = args
super().__init__(routine, *args, pos=pos, end_pos=end_pos)
@@ -288,6 +320,8 @@ class BracedWord(Node):
@property
def contents_pos(self):
if self.line is None or self.col is None:
return None
return (self.line, self.col + 1)
@@ -315,6 +349,8 @@ class QuotedWord(Node):
def contents_pos(self):
if self.contents is None:
return None
if self.line is None or self.col is None:
return None
return (self.line, self.col + 1)
@@ -337,7 +373,7 @@ class VarSub(Node):
class ArgExpansion(Node):
def __init__(self, list, pos=None, end_pos=None):
def __init__(self, list: Node, pos=None, end_pos=None):
self.list = list
super().__init__(list, pos=pos, end_pos=end_pos)
@@ -346,6 +382,10 @@ class ArgExpansion(Node):
self._recurse(visitor)
visitor.visit_arg_expansion(self)
@property
def contents(self):
return self.list.contents
class List(Node):
"""This Node currently exists exclusively for implementing the switch
+3 -3
View File
@@ -1,5 +1,4 @@
from enum import Enum
from typing import Tuple
class Rule(Enum):
@@ -16,6 +15,7 @@ class Rule(Enum):
REDEFINED_BUILTIN = "redefined-builtin"
UNBRACED_EXPR = "unbraced-expr"
REDUNDANT_EXPR = "redundant-expr"
UNOPENED_QUOTE = "unopened-quote"
def __str__(self):
return self.value
@@ -26,7 +26,7 @@ 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: Rule, message: str, start: tuple[int, int], end: tuple[int, int]
):
self.id = id
self.message = message
@@ -44,7 +44,7 @@ class Violation:
@classmethod
def create(cls, id):
def func(message: str, start: Tuple[int, int], end: Tuple[int, int]):
def func(message: str, start: tuple[int, int], end: tuple[int, int]):
return cls(id, message, start, end)
return func