Files
nx_post_support/server/libs/tclint/format.py
T
2026-06-18 17:21:58 +02:00

602 lines
21 KiB
Python

import dataclasses
import itertools
import sys
from tclint.parser import Parser
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]
@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]:
indented = []
for line in lines:
if line == "":
indented.append("")
else:
indented.append(indent + line)
return indented
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: 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, List):
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 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")
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.
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")
lines = self._indent(self.format_script_contents(tree), indent)
lines = self.reindent(lines)
formatted = "\n".join(lines)
return leading + formatted + trailing
def format_script_contents(self, script: 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]:
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
# 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 += self.space("D", " ") + lines[0]
lines = lines[1:]
if should_indent:
return [open_brace] + self._indent(lines, self.space("E")) + ["}"]
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] += 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] += 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.space("J")))
else:
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]:
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.space("K")))
formatted.append("]")
else:
formatted.append("[" + contents[0])
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]:
# 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] += self.space("M", " ")
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]:
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.space("N")) + ["}"]
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.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.
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]:
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.space("P")) + ["}"]
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.space("Q"))
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] += self.space("R", " ")
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_parts = self.format(function.name)
assert len(name_parts) == 1
name = name_parts[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] += 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.space("T"))
if last.end_pos[0] != function.end_pos[0]:
formatted.append(")")
else:
formatted[-1] += ")"
return formatted