Skip to content

Commit

Permalink
Cli for linting and autofixing
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasr8 committed Dec 31, 2024
1 parent 8e9b57f commit 10d9a6c
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 102 deletions.
73 changes: 49 additions & 24 deletions pyjsx/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import functools
from collections.abc import Callable, Generator
from pathlib import Path

import click

from pyjsx.linter import fix, lint
from pyjsx.transpiler import transpile


Expand All @@ -14,37 +17,59 @@ def cli(*, version: bool) -> None:
click.echo(pyjsx.__version__)


@cli.command()
@click.argument("sources", type=click.Path(exists=True), nargs=-1)
@click.option("-r", "--recursive", type=bool, is_flag=True, default=False, help="Recurse into directories.")
def compile(sources: list[str], recursive: bool) -> None:
def accept_files_and_dirs(f: Callable) -> Callable:
@click.argument("sources", type=click.Path(exists=True), nargs=-1)
@click.option("-r", "--recursive", type=bool, is_flag=True, default=False, help="Recurse into directories.")
@functools.wraps(f)
def wrapper(*args, **kwargs) -> None:
return f(*args, **kwargs)

return wrapper


@cli.command("compile")
@accept_files_and_dirs
def compile_files(sources: list[str], *, recursive: bool) -> None:
"""Compile .px files to regular .py files."""
paths = [Path(source) for source in sources]
count = 0
for source in sources:
path = Path(source)
count += transpile_dir(path, recursive=recursive)
for path in iter_files(paths, recursive=recursive):
transpile_file(path)
count += 1
msg = f"Compiled {count} file" + ("s" if count != 1 else "") + "."
click.secho(msg, fg="green", bold=True)


def transpile_dir(path: Path, *, recursive: bool = False) -> int:
if path.is_file():
return transpile_file(path)
count = 0
for file in path.iterdir():
if file.is_dir() and recursive:
count += transpile_dir(file)
elif file.is_file() and file.suffix == ".px":
count += transpile_file(file)
return count


def transpile_file(path: Path) -> int:
if path.suffix != ".px":
click.secho(f"Skipping {path} (not a .px file)", fg="yellow")
return 0
@cli.command("lint")
@accept_files_and_dirs
def lint_files(sources: list[str], *, recursive: bool) -> None:
"""Find issues with JSX."""
paths = [Path(source) for source in sources]
for path in iter_files(paths, recursive=recursive):
for error in lint(path.read_text("utf-8")):
click.secho(f"{error[1]}", fg="red")


@cli.command("fix")
@accept_files_and_dirs
def fix_files(sources: list[str], *, recursive: bool) -> None:
"""Fix issues with JSX."""
paths = [Path(source) for source in sources]
for path in iter_files(paths, recursive=recursive):
fixed = fix(path.read_text("utf-8"))
path.write_text(fixed, encoding="utf-8")


def transpile_file(path: Path) -> None:
click.echo(f"Compiling {path}...")
transpiled = transpile(path.read_text())
path.with_suffix(".py").write_text(transpiled)
return 1


def iter_files(sources: list[Path], *, recursive: bool = False) -> Generator[Path, None, None]:
for source in sources:
path = Path(source)
if path.is_file() and path.suffix == ".px":
yield path
elif path.is_dir():
yield from iter_files([path], recursive=recursive)
27 changes: 22 additions & 5 deletions pyjsx/linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def visit_JSXFragment(self, node: JSXFragment) -> None:
match node.children:
case []:
self.errors.append((node, "Empty JSX fragment"))
case [_]:
case [JSXFragment() | JSXElement()]:
self.errors.append((node, "Fragment with a single child"))
case _:
pass
Expand All @@ -115,6 +115,21 @@ def _check_self_closing_empty_body(self, node: JSXElement) -> None:
self.errors.append((node, "Empty components can be self-closing"))


def lint(source: str) -> list[tuple[Node, str]]:
return Linter().lint(source)


def fix(source: str) -> str:
node = Parser(source).parse()
node = remove_empty_jsx_expressions(node)
node = remove_empty_jsx_fragments(node)
node = remove_fragments_with_single_child(node)
node = remove_duplicate_props(node)
node = self_close_empty_components(node)
node = remove_boolean_prop_value(node)
return node.unparse()


def remove_empty_jsx_expressions(ast: Node) -> Node:
class Transformer(NodeTransformer):
def visit_JSXExpression(self, node: JSXExpression) -> Node | None:
Expand All @@ -141,9 +156,11 @@ def remove_fragments_with_single_child(ast: Node) -> Node:
class Transformer(NodeTransformer):
def visit_JSXFragment(self, node: JSXFragment) -> Node:
self.generic_visit(node)
if len(node.children) == 1:
return node.children[0]
return node
match node.children:
case [JSXFragment() | JSXElement()]:
return node.children[0]
case _:
return node

return Transformer().visit(ast)

Expand Down Expand Up @@ -182,7 +199,7 @@ def visit_JSXNamedAttribute(self, node: JSXNamedAttribute) -> Node:
self.generic_visit(node)
match node.value:
case JSXExpression(children=[PythonData(value="True")]):
return dataclasses.replace(node, value=None)
return dataclasses.replace(node, value=True)
case _:
return node

Expand Down
14 changes: 13 additions & 1 deletion pyjsx/transpiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@ def unparse(self) -> str:
raise NotImplementedError



@dataclass
class ErrorNode(Node):
pass


@dataclass
class ValidNode:
def unparse(self) -> str:
raise NotImplementedError


@dataclass
class JSXComment:
class JSXComment(Node):
value: str
token: Token

Expand Down
Loading

0 comments on commit 10d9a6c

Please sign in to comment.