feat(cli): add midas highlighter

This commit is contained in:
2026-05-25 12:14:55 +02:00
parent e94db2181f
commit 0e0a1b26f2
5 changed files with 270 additions and 62 deletions

View File

@@ -50,32 +50,4 @@ span {
--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;
}
}

View File

@@ -1,19 +1,29 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TextIO
from typing import Generic, Optional, Protocol, TextIO, TypeVar
from midas.ast.python import (
BaseType,
ConstraintType,
Expr,
FrameColumn,
FrameType,
Function,
FunctionArgument,
)
from midas.ast.location import Location
import midas.ast.midas as m
import midas.ast.python as p
H = TypeVar("H", bound="Highlighter", contravariant=True)
class PythonHighlighter(Expr.Visitor[None]):
CSS_PATH: Path = Path(__file__).parent / "highlight.css"
class Highlightable(Protocol, Generic[H]):
def accept(self, visitor: H): ...
class Locatable(Protocol):
@property
@abstractmethod
def location(self) -> Optional[Location]: ...
class Highlighter(ABC):
BASE_CSS_PATH: Path = Path(__file__).parent / "highlight.css"
EXTRA_CSS_PATH: Optional[Path] = None
def __init__(self, source: str) -> None:
self.source: str = source
@@ -21,12 +31,22 @@ class PythonHighlighter(Expr.Visitor[None]):
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 format_css(self, path: Path) -> list[str]:
css: str = path.read_text()
css = "\n".join((" " + line).rstrip() for line in css.splitlines())
return [
" <style>",
css,
" </style>",
]
def dump(self, buf: TextIO):
css: str = self.CSS_PATH.read_text()
css = "\n".join((" " + line).rstrip() for line in css.splitlines())
base_css: list[str] = self.format_css(self.BASE_CSS_PATH)
extra_css: list[str] = (
self.format_css(self.EXTRA_CSS_PATH)
if self.EXTRA_CSS_PATH is not None
else []
)
lines: list[str] = [
"<!DOCTYPE html>",
'<html lang="en">',
@@ -34,9 +54,8 @@ class PythonHighlighter(Expr.Visitor[None]):
' <meta charset="UTF-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
" <title>Highlighted file</title>",
" <style>",
css,
" </style>",
*base_css,
*extra_css,
"</head>",
"<body>",
' <div id="code">',
@@ -64,7 +83,7 @@ class PythonHighlighter(Expr.Visitor[None]):
buf.write("\n".join(lines))
def wrap(self, node: Expr, cls: str):
def wrap(self, node: Locatable, cls: str):
if node.location is None:
return
if node.location.end_lineno is None or node.location.end_col_offset is None:
@@ -84,32 +103,125 @@ class PythonHighlighter(Expr.Visitor[None]):
self.closings.setdefault((l, c), []).insert(0, closing)
self.openings.setdefault((l + 1, 0), []).append(opening)
def visit_base_type(self, node: BaseType) -> None:
class PythonHighlighter(Highlighter, p.Expr.Visitor[None]):
EXTRA_CSS_PATH: Optional[Path] = Path(__file__).parent / "hl_python.css"
def highlight(self, node: Highlightable[PythonHighlighter]):
node.accept(self)
def visit_base_type(self, node: p.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:
def visit_constraint_type(self, node: p.ConstraintType) -> None:
self.wrap(node, "constraint-type")
node.type.accept(self)
def visit_frame_column(self, node: FrameColumn) -> None:
def visit_frame_column(self, node: p.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:
def visit_frame_type(self, node: p.FrameType) -> None:
self.wrap(node, "frame-type")
for column in node.columns:
column.accept(self)
def visit_function(self, node: Function) -> None:
def visit_function(self, node: p.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:
def visit_function_argument(self, node: p.FunctionArgument) -> None:
self.wrap(node, "argument")
if node.type is not None:
node.type.accept(self)
class MidasHighlighter(Highlighter, m.Stmt.Visitor[None], m.Expr.Visitor[None]):
EXTRA_CSS_PATH: Optional[Path] = Path(__file__).parent / "hl_midas.css"
def highlight(self, node: Highlightable[MidasHighlighter]):
node.accept(self)
def visit_simple_type_stmt(self, stmt: m.SimpleTypeStmt) -> None:
self.wrap(stmt, "simple-type")
if stmt.template is not None:
stmt.template.accept(self)
stmt.base.accept(self)
if stmt.constraint is not None:
self.wrap(stmt.constraint, "constraint")
stmt.constraint.accept(self)
def visit_complex_type_stmt(self, stmt: m.ComplexTypeStmt) -> None:
self.wrap(stmt, "complex-type")
if stmt.template is not None:
stmt.template.accept(self)
for prop in stmt.properties:
prop.accept(self)
def visit_property_stmt(self, stmt: m.PropertyStmt) -> None:
self.wrap(stmt, "property")
stmt.type.accept(self)
if stmt.constraint is not None:
self.wrap(stmt.constraint, "constraint")
stmt.constraint.accept(self)
def visit_extend_stmt(self, stmt: m.ExtendStmt) -> None:
self.wrap(stmt, "extend")
stmt.type.accept(self)
for op in stmt.operations:
op.accept(self)
def visit_op_stmt(self, stmt: m.OpStmt) -> None:
self.wrap(stmt, "op")
stmt.operand.accept(self)
stmt.result.accept(self)
def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None:
self.wrap(stmt, "predicate")
stmt.type.accept(self)
stmt.condition.accept(self)
def visit_simple_type_expr(self, expr: m.SimpleTypeExpr) -> None:
self.wrap(expr, "simple-type-expr")
def visit_logical_expr(self, expr: m.LogicalExpr) -> None:
self.wrap(expr, "logical-expr")
expr.left.accept(self)
expr.right.accept(self)
def visit_binary_expr(self, expr: m.BinaryExpr) -> None:
self.wrap(expr, "binary-expr")
expr.left.accept(self)
expr.right.accept(self)
def visit_unary_expr(self, expr: m.UnaryExpr) -> None:
self.wrap(expr, "unary-expr")
expr.right.accept(self)
def visit_get_expr(self, expr: m.GetExpr) -> None:
self.wrap(expr, "get-expr")
expr.expr.accept(self)
def visit_variable_expr(self, expr: m.VariableExpr) -> None:
self.wrap(expr, "variable")
def visit_grouping_expr(self, expr: m.GroupingExpr) -> None:
expr.expr.accept(self)
def visit_literal_expr(self, expr: m.LiteralExpr) -> None: ...
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> None: ...
def visit_template_expr(self, expr: m.TemplateExpr) -> None:
self.wrap(expr, "template")
expr.type.accept(self)
def visit_type_expr(self, expr: m.TypeExpr) -> None:
self.wrap(expr, "type")
if expr.template is not None:
expr.template.accept(self)

55
midas/cli/hl_midas.css Normal file
View File

@@ -0,0 +1,55 @@
span {
&.comment {
--col: 200, 200, 200;
color: rgb(110, 110, 110);
font-style: italic;
}
&.simple-type {
--col: 108, 233, 108;
}
&.complex-type {
--col: 233, 206, 108;
}
&.constraint {
--col: 233, 108, 108;
}
&.property {
--col: 233, 108, 176;
}
&.extend {
--col: 108, 197, 233;
}
&.op {
--col: 108, 148, 233;
}
&.predicate {
--col: 193, 108, 233;
}
&.simple-type-expr {
--col: 150, 150, 150;
}
&.logical-expr,
&.binary-expr,
&.unary-expr,
&.get-expr {
--col: 123, 215, 193;
}
&.template {
--col: 163, 117, 71;
}
&.type {
--col: 200, 200, 200;
font-weight: bold;
}
}

29
midas/cli/hl_python.css Normal file
View File

@@ -0,0 +1,29 @@
span {
&.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;
}
}

View File

@@ -3,8 +3,13 @@ from typing import Optional, TextIO
import click
from midas.ast.location import Location
import midas.ast.midas as m
from midas.ast.printer import PythonAstPrinter
from midas.cli.highlighter import PythonHighlighter
from midas.cli.highlighter import Highlighter, MidasHighlighter, PythonHighlighter
from midas.lexer.midas import MidasLexer
from midas.lexer.token import Token, TokenType
from midas.parser.midas import MidasParser
from midas.parser.python import PythonParser
@@ -59,18 +64,53 @@ def dump_ast(output: Optional[TextIO], parse: bool, file: TextIO):
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)
def highlight_python(source: str, path: str) -> Highlighter:
tree: ast.Module = ast.parse(source, filename=path)
parser = PythonParser()
parser.visit(tree)
highlighter: PythonHighlighter = PythonHighlighter(source)
highlighter = PythonHighlighter(source)
for _, annotation in parser.annotations:
if annotation is not None:
highlighter.highlight(annotation)
for func in parser.functions:
highlighter.highlight(func)
return highlighter
def highlight_midas(source: str, path: str) -> Highlighter:
lexer = MidasLexer(source, file=path)
tokens: list[Token] = lexer.process()
parser = MidasParser(tokens)
stmts: list[m.Stmt] = parser.parse()
highlighter = MidasHighlighter(source)
class LocatableToken:
def __init__(self, token: Token):
self.token: Token = token
@property
def location(self) -> Location:
return self.token.get_location()
for token in tokens:
if token.type == TokenType.COMMENT:
highlighter.wrap(LocatableToken(token), "comment")
for stmt in stmts:
highlighter.highlight(stmt)
return highlighter
@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()
highlighter: Highlighter
if file.name.endswith(".py"):
highlighter = highlight_python(source, file.name)
elif file.name.endswith(".midas"):
highlighter = highlight_midas(source, file.name)
else:
raise ValueError("Unsupported file type")
highlighter.dump(output)