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)