From 9b59058881b4094989303fe05cb4a162bdf0904d Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Fri, 22 May 2026 22:15:26 +0200 Subject: [PATCH] feat(cli): add highlight command --- midas/cli/highlight.css | 81 +++++++++++++++++++++++++++ midas/cli/highlighter.py | 115 +++++++++++++++++++++++++++++++++++++++ midas/cli/main.py | 18 ++++++ 3 files changed, 214 insertions(+) create mode 100644 midas/cli/highlight.css create mode 100644 midas/cli/highlighter.py diff --git a/midas/cli/highlight.css b/midas/cli/highlight.css new file mode 100644 index 0000000..a2f378c --- /dev/null +++ b/midas/cli/highlight.css @@ -0,0 +1,81 @@ +html, +body { + margin: 0; + font-size: 14pt; +} + +* { + box-sizing: border-box; +} + +#code { + display: flex; + flex-direction: column; + font-family: monospace; + white-space: pre-wrap; +} + +.line { + display: flex; + + &:nth-child(odd) { + background-color: rgb(247, 247, 247); + } + + .no { + width: 4em; + text-align: right; + padding: 0.2em 0.4em; + border-right: solid black 1px; + flex-shrink: 0; + } + + .txt { + flex-grow: 1; + padding: 0.2em 0.8em; + } +} + +span { + --col: transparent; + --opacity: 0.1; + --border: 0px; + background-color: rgba(var(--col), var(--opacity)); + outline: solid rgb(var(--col)) var(--border); + outline-offset: 2px; + border-radius: 2px; + + &:hover:not(:has(*:hover)) { + --opacity: 0.8; + --border: 2px; + z-index: 10; + } + + &.base-type { + --col: 108, 233, 108; + } + + &.param { + --col: 103, 192, 224; + } + + &.constraint-type { + --col: 174, 200, 195; + } + + &.frame-column { + --col: 216, 231, 81; + } + + &.frame-type { + --col: 231, 46, 40; + } + + &.function { + --col: 215, 103, 224; + } + + &.argument { + --col: 103, 192, 224; + } +} \ No newline at end of file diff --git a/midas/cli/highlighter.py b/midas/cli/highlighter.py new file mode 100644 index 0000000..e302c3a --- /dev/null +++ b/midas/cli/highlighter.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import TextIO + +from midas.ast.python import ( + BaseType, + ConstraintType, + Expr, + FrameColumn, + FrameType, + Function, + FunctionArgument, +) + + +class PythonHighlighter(Expr.Visitor[None]): + CSS_PATH: Path = Path(__file__).parent / "highlight.css" + + def __init__(self, source: str) -> None: + self.source: str = source + self.lines: list[str] = self.source.splitlines() + self.openings: dict[tuple[int, int], list[str]] = {} + self.closings: dict[tuple[int, int], list[str]] = {} + + def highlight(self, node: Expr): + node.accept(self) + + def dump(self, buf: TextIO): + css: str = self.CSS_PATH.read_text() + css = "\n".join((" " + line).rstrip() for line in css.splitlines()) + lines: list[str] = [ + "", + '', + "", + ' ', + ' ', + " Highlighted file", + " ", + "", + "", + '
', + ] + for l, line in enumerate(self.lines): + lineno: int = l + 1 + line_buf: str = ( + f'
{lineno}
' + ) + for c, char in enumerate(line): + pos: tuple[int, int] = (lineno, c) + closings: list[str] = self.closings.get(pos, []) + openings: list[str] = self.openings.get(pos, []) + line_buf += "".join(closings + openings) + line_buf += char + line_buf += "
" + lines.append(" " + line_buf) + lines.extend( + [ + "
", + "", + "", + ] + ) + + buf.write("\n".join(lines)) + + def wrap(self, node: Expr, cls: str): + if node.location is None: + return + if node.location.end_lineno is None or node.location.end_col_offset is None: + return + start_pos: tuple[int, int] = (node.location.lineno, node.location.col_offset) + end_pos: tuple[int, int] = ( + node.location.end_lineno, + node.location.end_col_offset, + ) + opening: str = f'' + closing: str = "" + self.openings.setdefault(start_pos, []).append(opening) + self.closings.setdefault(end_pos, []).insert(0, closing) + if start_pos[0] != end_pos[0]: + for l in range(start_pos[0], end_pos[0]): + c: int = len(self.lines[l - 1]) + self.closings.setdefault((l, c), []).insert(0, closing) + self.openings.setdefault((l + 1, 0), []).append(opening) + + def visit_base_type(self, node: BaseType) -> None: + self.wrap(node, "base-type") + if node.param is not None: + self.wrap(node.param, "param") + node.param.accept(self) + + def visit_constraint_type(self, node: ConstraintType) -> None: + self.wrap(node, "constraint-type") + node.type.accept(self) + + def visit_frame_column(self, node: FrameColumn) -> None: + self.wrap(node, "frame-column") + if node.type is not None: + node.type.accept(self) + + def visit_frame_type(self, node: FrameType) -> None: + self.wrap(node, "frame-type") + for column in node.columns: + column.accept(self) + + def visit_function(self, node: Function) -> None: + self.wrap(node, "function") + for arg in node.posonlyargs + node.args + node.kwonlyargs: + arg.accept(self) + + def visit_function_argument(self, node: FunctionArgument) -> None: + self.wrap(node, "argument") + if node.type is not None: + node.type.accept(self) diff --git a/midas/cli/main.py b/midas/cli/main.py index 65ed210..4dd79c0 100644 --- a/midas/cli/main.py +++ b/midas/cli/main.py @@ -4,6 +4,7 @@ from typing import Optional, TextIO import click from midas.ast.printer import PythonAstPrinter +from midas.cli.highlighter import PythonHighlighter from midas.parser.python import PythonParser @@ -56,3 +57,20 @@ def dump_ast(output: Optional[TextIO], parse: bool, file: TextIO): click.echo(dump) else: output.write(dump) + + +@utils.command() +@click.option("-o", "--output", type=click.File("w"), default="-") +@click.argument("file", type=click.File("r")) +def highlight(output: TextIO, file: TextIO): + source: str = file.read() + tree: ast.Module = ast.parse(source, filename=file.name) + parser = PythonParser() + parser.visit(tree) + highlighter: PythonHighlighter = PythonHighlighter(source) + for _, annotation in parser.annotations: + if annotation is not None: + highlighter.highlight(annotation) + for func in parser.functions: + highlighter.highlight(func) + highlighter.dump(output)