add updates libs
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
|
||||
from tclint.cli.tclint import main
|
||||
|
||||
sys.exit(main())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={}): {
|
||||
|
||||
@@ -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
@@ -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
@@ -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(")")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user