481 lines
16 KiB
Python
481 lines
16 KiB
Python
import dataclasses
|
|
import itertools
|
|
import textwrap
|
|
from typing import List, Tuple, Union
|
|
import sys
|
|
|
|
from tclint.syntax_tree import (
|
|
Node,
|
|
Script,
|
|
Command,
|
|
Comment,
|
|
CommandSub,
|
|
BareWord,
|
|
QuotedWord,
|
|
BracedWord,
|
|
CompoundBareWord,
|
|
VarSub,
|
|
ArgExpansion,
|
|
Expression,
|
|
BracedExpression,
|
|
ParenExpression,
|
|
UnaryOp,
|
|
BinaryOp,
|
|
TernaryOp,
|
|
Function,
|
|
)
|
|
from tclint.parser import Parser
|
|
from tclint.syntax_tree import List as ListNode
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class LiteralBlock:
|
|
block: List[str]
|
|
pos: Tuple[int, int]
|
|
end_pos: Tuple[int, int]
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class FormatterOpts:
|
|
indent: str
|
|
spaces_in_braces: bool
|
|
max_blank_lines: int
|
|
indent_namespace_eval: bool
|
|
|
|
|
|
class Formatter:
|
|
def __init__(self, opts: FormatterOpts):
|
|
self.opts = opts
|
|
|
|
def _indent(self, lines: List[str], indent: str) -> List[str]:
|
|
indented = []
|
|
for line in lines:
|
|
if line == "":
|
|
indented.append("")
|
|
else:
|
|
indented.append(indent + line)
|
|
|
|
return indented
|
|
|
|
def _brace(self, lines: List[str]) -> List[str]:
|
|
spaces_in_braces = " " if self.opts.spaces_in_braces else ""
|
|
if lines == [""]:
|
|
return ["{" + spaces_in_braces + "}"]
|
|
|
|
braced_lines = lines[:]
|
|
braced_lines[0] = "{" + spaces_in_braces + lines[0]
|
|
braced_lines[-1] += spaces_in_braces + "}"
|
|
return braced_lines
|
|
|
|
def format(self, *nodes: Union[Node, LiteralBlock]) -> List[str]:
|
|
formatted = []
|
|
for node in nodes:
|
|
if isinstance(node, Script):
|
|
formatted += self.format_script(node)
|
|
elif isinstance(node, Command):
|
|
formatted += self.format_command(node)
|
|
elif isinstance(node, Comment):
|
|
formatted += self.format_comment(node)
|
|
elif isinstance(node, CommandSub):
|
|
formatted += self.format_command_sub(node)
|
|
elif isinstance(node, BareWord):
|
|
formatted += self.format_bare_word(node)
|
|
elif isinstance(node, QuotedWord):
|
|
formatted += self.format_quoted_word(node)
|
|
elif isinstance(node, BracedWord):
|
|
formatted += self.format_braced_word(node)
|
|
elif isinstance(node, CompoundBareWord):
|
|
formatted += self.format_compound_bare_word(node)
|
|
elif isinstance(node, VarSub):
|
|
formatted += self.format_var_sub(node)
|
|
elif isinstance(node, ArgExpansion):
|
|
formatted += self.format_arg_expansion(node)
|
|
elif isinstance(node, ListNode):
|
|
formatted += self.format_list(node)
|
|
elif isinstance(node, Expression):
|
|
formatted += self.format_expression(node)
|
|
elif isinstance(node, BracedExpression):
|
|
formatted += self.format_braced_expression(node)
|
|
elif isinstance(node, ParenExpression):
|
|
formatted += self.format_paren_expression(node)
|
|
elif isinstance(node, UnaryOp):
|
|
formatted += self.format_unary_op(node)
|
|
elif isinstance(node, BinaryOp):
|
|
formatted += self.format_binary_op(node)
|
|
elif isinstance(node, TernaryOp):
|
|
formatted += self.format_ternary_op(node)
|
|
elif isinstance(node, Function):
|
|
formatted += self.format_function(node)
|
|
elif isinstance(node, LiteralBlock):
|
|
formatted += node.block
|
|
else:
|
|
assert False, f"unrecognized node: {type(node)}"
|
|
|
|
return formatted
|
|
|
|
def format_top(self, script: str, parser: Parser) -> str:
|
|
tree = parser.parse(script)
|
|
self.script = script.split("\n")
|
|
return "\n".join(self.format_script_contents(tree)) + "\n"
|
|
|
|
def format_partial(self, script: str, parser: Parser) -> str:
|
|
"""Formats a partial Tcl script.
|
|
|
|
This function formats a partial script according to the gofmt partial formatting
|
|
rules, "[preserving] leading indentation as well as leading and trailing spaces"
|
|
(ref: https://pkg.go.dev/cmd/gofmt#pkg-overview). Unlike Go, we have no way of
|
|
detecting if a given script is a program fragment, hence the distinct method
|
|
from `format_top` .
|
|
"""
|
|
leading = "".join(itertools.takewhile(str.isspace, script))
|
|
try:
|
|
leading, indent = leading.rsplit("\n", 1)
|
|
leading += "\n"
|
|
except ValueError:
|
|
leading, indent = "", leading
|
|
trailing = "".join(itertools.takewhile(str.isspace, reversed(script)))[::-1]
|
|
|
|
script = script.strip()
|
|
tree = parser.parse(script)
|
|
self.script = script.split("\n")
|
|
|
|
formatted = "\n".join(self.format_script_contents(tree))
|
|
|
|
return leading + textwrap.indent(formatted, indent) + trailing
|
|
|
|
def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]:
|
|
to_format = []
|
|
skip_formatting_start = None
|
|
for child in script.children:
|
|
if skip_formatting_start is None:
|
|
to_format.append(child)
|
|
|
|
if isinstance(child, Comment):
|
|
if child.value.strip() == "tclfmt-disable":
|
|
if skip_formatting_start is not None:
|
|
print(
|
|
"Warning: encountered 'tclint-disable' while formatting is"
|
|
" already disabled, ignoring...",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
skip_formatting_start = child.pos[0]
|
|
elif child.value.strip() == "tclfmt-enable":
|
|
if skip_formatting_start is None:
|
|
print(
|
|
"Warning: encountered 'tclint-enable' while formatting is"
|
|
" already disabled, ignoring...",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
skip_formatting_end = child.pos[0]
|
|
block = self.script[skip_formatting_start:skip_formatting_end]
|
|
to_format.append(
|
|
LiteralBlock(
|
|
block,
|
|
pos=(skip_formatting_start + 1, 1),
|
|
end_pos=(skip_formatting_end, 1),
|
|
)
|
|
)
|
|
skip_formatting_start = None
|
|
|
|
if skip_formatting_start is not None:
|
|
print("Warning: missing 'tclint-enable'", file=sys.stderr)
|
|
to_format.append(
|
|
LiteralBlock(
|
|
self.script[skip_formatting_start:],
|
|
pos=(skip_formatting_start + 1, 1),
|
|
end_pos=script.end_pos,
|
|
)
|
|
)
|
|
|
|
formatted = [""]
|
|
last_line = None
|
|
for child in to_format:
|
|
if last_line is not None:
|
|
if last_line == child.pos[0]:
|
|
if isinstance(child, Comment):
|
|
formatted[-1] += " ;"
|
|
else:
|
|
formatted[-1] += "; "
|
|
else:
|
|
newlines = child.pos[0] - last_line
|
|
newlines = min(newlines, self.opts.max_blank_lines + 1)
|
|
formatted.extend([""] * newlines)
|
|
last_line = child.end_pos[0]
|
|
|
|
lines = self.format(child)
|
|
formatted[-1] += lines[0]
|
|
formatted.extend(lines[1:])
|
|
|
|
return formatted
|
|
|
|
def format_script(self, script: Script, should_indent=True) -> List[str]:
|
|
lines = self.format_script_contents(script)
|
|
if script.pos[0] == script.end_pos[0]:
|
|
return self._brace(lines)
|
|
|
|
# Usually, we enforce that multi-line scripts start on a new line after the open
|
|
# brace. However, if a comment was originally on the same line as the open brace
|
|
# we preserve it, since it's probably meant to be associated with this line
|
|
# (e.g. a tclint-disable-line).
|
|
open_brace = "{"
|
|
if (
|
|
len(script.children) > 0
|
|
and isinstance(script.children[0], Comment)
|
|
and script.pos[0] == script.children[0].pos[0]
|
|
):
|
|
open_brace += " " + lines[0]
|
|
lines = lines[1:]
|
|
|
|
if should_indent:
|
|
return [open_brace] + self._indent(lines, self.opts.indent) + ["}"]
|
|
else:
|
|
return [open_brace] + lines + ["}"]
|
|
|
|
def format_command(self, command: Command) -> List[str]:
|
|
is_namespace_eval = (
|
|
command.routine.contents == "namespace"
|
|
and len(command.args) > 0
|
|
and command.args[0].contents == "eval"
|
|
)
|
|
should_indent = not is_namespace_eval or self.opts.indent_namespace_eval
|
|
|
|
hanging_indent = False
|
|
formatted = self.format(command.routine)
|
|
last_line = command.routine.end_pos[0]
|
|
for child in command.args:
|
|
if isinstance(child, Script):
|
|
child_lines = self.format_script(child, should_indent=should_indent)
|
|
else:
|
|
child_lines = self.format(child)
|
|
|
|
if last_line == child.pos[0]:
|
|
formatted[-1] += " "
|
|
formatted[-1] += child_lines[0]
|
|
else:
|
|
formatted[-1] += " \\"
|
|
formatted.append(self.opts.indent + child_lines[0])
|
|
hanging_indent = True
|
|
|
|
if hanging_indent:
|
|
formatted.extend(self._indent(child_lines[1:], self.opts.indent))
|
|
else:
|
|
formatted.extend(child_lines[1:])
|
|
|
|
last_line = child.end_pos[0]
|
|
|
|
return formatted
|
|
|
|
def format_comment(self, comment: Comment) -> List[str]:
|
|
return [f"#{comment.value}"]
|
|
|
|
def format_command_sub(self, command_sub):
|
|
if len(command_sub.children) == 0:
|
|
return ["[]"]
|
|
|
|
formatted = []
|
|
contents = self.format_script_contents(command_sub)
|
|
if len(command_sub.children) > 1 and len(contents) > 1:
|
|
formatted.append("[")
|
|
formatted.extend(self._indent(contents, self.opts.indent))
|
|
formatted.append("]")
|
|
else:
|
|
formatted.append("[" + contents[0])
|
|
formatted.extend(contents[1:])
|
|
formatted[-1] += "]"
|
|
|
|
return formatted
|
|
|
|
def format_bare_word(self, word) -> List[str]:
|
|
# Property enforced by parser
|
|
assert word.contents is not None
|
|
return [word.contents]
|
|
|
|
def format_quoted_word(self, word) -> List[str]:
|
|
if word.contents is not None:
|
|
return [f'"{word.contents}"']
|
|
|
|
formatted = ""
|
|
for child in word.children:
|
|
formatted += "\n".join(self.format(child))
|
|
|
|
return [f'"{formatted}"']
|
|
|
|
def format_braced_word(self, word) -> List[str]:
|
|
assert word.contents is not None
|
|
return [f"{{{word.contents}}}"]
|
|
|
|
def format_compound_bare_word(self, word) -> List[str]:
|
|
formatted = [""]
|
|
for child in word.children:
|
|
child_lines = self.format(child)
|
|
formatted[-1] += child_lines[0]
|
|
formatted.extend(child_lines[1:])
|
|
|
|
return formatted
|
|
|
|
def format_var_sub(self, varsub) -> List[str]:
|
|
# We might be able to make the formatter infer whether braces are required, and
|
|
# remove them from the syntax tree. For now it's easier to just mimic the
|
|
# original format.
|
|
if varsub.braced:
|
|
formatted = [f"${{{varsub.value}}}"]
|
|
else:
|
|
formatted = [f"${varsub.value}"]
|
|
|
|
if varsub.children:
|
|
# We just concatenate everything as is, since changes in whitespace are
|
|
# semantically meaningful in this context. Any newlines are captured by
|
|
# BareWords.
|
|
formatted[-1] += "("
|
|
for child in varsub.children:
|
|
child_lines = self.format(child)
|
|
formatted[-1] += child_lines[0]
|
|
formatted.extend(child_lines[1:])
|
|
formatted[-1] += ")"
|
|
|
|
return formatted
|
|
|
|
def format_arg_expansion(self, arg_expansion) -> List[str]:
|
|
lines = self.format(arg_expansion.list)
|
|
lines[0] = "{*}" + lines[0]
|
|
|
|
return lines
|
|
|
|
def format_list(self, list_node) -> List[str]:
|
|
# Similar to Script, but the contents are a bit more straightforward.
|
|
contents = [""]
|
|
last_line = None
|
|
for child in list_node.children:
|
|
if last_line is not None:
|
|
if last_line == child.pos[0]:
|
|
contents[-1] += " "
|
|
else:
|
|
newlines = child.pos[0] - last_line
|
|
newlines = min(newlines, 3)
|
|
contents.extend([""] * newlines)
|
|
|
|
lines = self.format(child)
|
|
contents[-1] += lines[0]
|
|
contents.extend(lines[1:])
|
|
|
|
last_line = child.end_pos[0]
|
|
|
|
if list_node.pos[0] == list_node.end_pos[0]:
|
|
return self._brace(contents)
|
|
|
|
return ["{"] + self._indent(contents, self.opts.indent) + ["}"]
|
|
|
|
def format_expression(self, expr) -> List[str]:
|
|
formatted = [""]
|
|
for child in expr.children:
|
|
lines = self.format(child)
|
|
formatted[-1] += lines[0]
|
|
for line in lines[1:]:
|
|
formatted[-1] += " \\"
|
|
formatted += self._indent([line], self.opts.indent)
|
|
|
|
# Trick: we know there are quotes around the expression if the start of the
|
|
# expression is a different column than its first child.
|
|
quoted = expr.pos[1] != expr.children[0].pos[1]
|
|
if quoted:
|
|
formatted[0] = '"' + formatted[0]
|
|
formatted[-1] += '"'
|
|
|
|
return formatted
|
|
|
|
def format_braced_expression(self, expr) -> List[str]:
|
|
formatted = [""]
|
|
for child in expr.children:
|
|
lines = self.format(child)
|
|
formatted[-1] += lines[0]
|
|
formatted.extend(lines[1:])
|
|
|
|
if expr.pos[0] == expr.end_pos[0]:
|
|
return self._brace(formatted)
|
|
|
|
return ["{"] + self._indent(formatted, self.opts.indent) + ["}"]
|
|
|
|
def format_paren_expression(self, expr) -> List[str]:
|
|
body = expr.body
|
|
|
|
formatted = ["("]
|
|
lines = self.format(body)
|
|
if expr.pos[0] != body.pos[0]:
|
|
formatted.extend(lines)
|
|
else:
|
|
formatted[-1] += lines[0]
|
|
formatted.extend(lines[1:])
|
|
|
|
formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent)
|
|
|
|
if expr.end_pos[0] != body.end_pos[0]:
|
|
formatted.append(")")
|
|
else:
|
|
formatted[-1] += ")"
|
|
|
|
return formatted
|
|
|
|
def format_unary_op(self, expr):
|
|
op = self.format(expr.operator)
|
|
assert len(op) == 1
|
|
|
|
lines = self.format(expr.operand)
|
|
lines[0] = op[0] + lines[0]
|
|
return lines
|
|
|
|
def _format_op(self, expr) -> List[str]:
|
|
nodes = expr.children
|
|
formatted = self.format(nodes[0])
|
|
|
|
last = nodes[0]
|
|
for next in nodes[1:]:
|
|
lines = self.format(next)
|
|
if last.end_pos[0] != next.pos[0]:
|
|
formatted.extend(lines)
|
|
else:
|
|
formatted[-1] += " "
|
|
formatted[-1] += lines[0]
|
|
formatted.extend(lines[1:])
|
|
last = next
|
|
|
|
return formatted
|
|
|
|
def format_binary_op(self, expr) -> List[str]:
|
|
return self._format_op(expr)
|
|
|
|
def format_ternary_op(self, expr) -> List[str]:
|
|
return self._format_op(expr)
|
|
|
|
def format_function(self, function):
|
|
name = self.format(function.name)
|
|
assert len(name) == 1
|
|
name = name[0]
|
|
|
|
formatted = [f"{name}("]
|
|
|
|
last = function.name
|
|
for i, child in enumerate(function.args):
|
|
if i > 0:
|
|
formatted[-1] += ","
|
|
lines = self.format(child)
|
|
if last.end_pos[0] != child.pos[0]:
|
|
formatted.extend(lines)
|
|
else:
|
|
if i > 0:
|
|
formatted[-1] += " "
|
|
formatted[-1] += lines[0]
|
|
formatted.extend(lines[1:])
|
|
last = child
|
|
|
|
# indent any continuation lines, but we leave the closing paren dedented
|
|
formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent)
|
|
|
|
if last.end_pos[0] != function.end_pos[0]:
|
|
formatted.append(")")
|
|
else:
|
|
formatted[-1] += ")"
|
|
|
|
return formatted
|