feat(cli): add highlight command

This commit is contained in:
2026-05-22 22:15:26 +02:00
parent d0c54db33a
commit 9b59058881
3 changed files with 214 additions and 0 deletions

81
midas/cli/highlight.css Normal file
View File

@@ -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;
}
}

115
midas/cli/highlighter.py Normal file
View File

@@ -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] = [
"<!DOCTYPE html>",
'<html lang="en">',
"<head>",
' <meta charset="UTF-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
" <title>Highlighted file</title>",
" <style>",
css,
" </style>",
"</head>",
"<body>",
' <div id="code">',
]
for l, line in enumerate(self.lines):
lineno: int = l + 1
line_buf: str = (
f'<div class="line" id="l{lineno}"><div class="no">{lineno}</div><div class="txt">'
)
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 += "</div></div>"
lines.append(" " + line_buf)
lines.extend(
[
" </div>",
"</body>",
"</html>",
]
)
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'<span class="{cls}" title="{cls}">'
closing: str = "</span>"
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)

View File

@@ -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)