Skip to content

Commit 10d9a6c

Browse files
committed
Cli for linting and autofixing
1 parent 8e9b57f commit 10d9a6c

File tree

4 files changed

+156
-102
lines changed

4 files changed

+156
-102
lines changed

pyjsx/cli.py

+49-24
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import functools
2+
from collections.abc import Callable, Generator
13
from pathlib import Path
24

35
import click
46

7+
from pyjsx.linter import fix, lint
58
from pyjsx.transpiler import transpile
69

710

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

1619

17-
@cli.command()
18-
@click.argument("sources", type=click.Path(exists=True), nargs=-1)
19-
@click.option("-r", "--recursive", type=bool, is_flag=True, default=False, help="Recurse into directories.")
20-
def compile(sources: list[str], recursive: bool) -> None:
20+
def accept_files_and_dirs(f: Callable) -> Callable:
21+
@click.argument("sources", type=click.Path(exists=True), nargs=-1)
22+
@click.option("-r", "--recursive", type=bool, is_flag=True, default=False, help="Recurse into directories.")
23+
@functools.wraps(f)
24+
def wrapper(*args, **kwargs) -> None:
25+
return f(*args, **kwargs)
26+
27+
return wrapper
28+
29+
30+
@cli.command("compile")
31+
@accept_files_and_dirs
32+
def compile_files(sources: list[str], *, recursive: bool) -> None:
2133
"""Compile .px files to regular .py files."""
34+
paths = [Path(source) for source in sources]
2235
count = 0
23-
for source in sources:
24-
path = Path(source)
25-
count += transpile_dir(path, recursive=recursive)
36+
for path in iter_files(paths, recursive=recursive):
37+
transpile_file(path)
38+
count += 1
2639
msg = f"Compiled {count} file" + ("s" if count != 1 else "") + "."
2740
click.secho(msg, fg="green", bold=True)
2841

2942

30-
def transpile_dir(path: Path, *, recursive: bool = False) -> int:
31-
if path.is_file():
32-
return transpile_file(path)
33-
count = 0
34-
for file in path.iterdir():
35-
if file.is_dir() and recursive:
36-
count += transpile_dir(file)
37-
elif file.is_file() and file.suffix == ".px":
38-
count += transpile_file(file)
39-
return count
40-
41-
42-
def transpile_file(path: Path) -> int:
43-
if path.suffix != ".px":
44-
click.secho(f"Skipping {path} (not a .px file)", fg="yellow")
45-
return 0
43+
@cli.command("lint")
44+
@accept_files_and_dirs
45+
def lint_files(sources: list[str], *, recursive: bool) -> None:
46+
"""Find issues with JSX."""
47+
paths = [Path(source) for source in sources]
48+
for path in iter_files(paths, recursive=recursive):
49+
for error in lint(path.read_text("utf-8")):
50+
click.secho(f"{error[1]}", fg="red")
51+
52+
53+
@cli.command("fix")
54+
@accept_files_and_dirs
55+
def fix_files(sources: list[str], *, recursive: bool) -> None:
56+
"""Fix issues with JSX."""
57+
paths = [Path(source) for source in sources]
58+
for path in iter_files(paths, recursive=recursive):
59+
fixed = fix(path.read_text("utf-8"))
60+
path.write_text(fixed, encoding="utf-8")
61+
62+
63+
def transpile_file(path: Path) -> None:
4664
click.echo(f"Compiling {path}...")
4765
transpiled = transpile(path.read_text())
4866
path.with_suffix(".py").write_text(transpiled)
49-
return 1
5067

68+
69+
def iter_files(sources: list[Path], *, recursive: bool = False) -> Generator[Path, None, None]:
70+
for source in sources:
71+
path = Path(source)
72+
if path.is_file() and path.suffix == ".px":
73+
yield path
74+
elif path.is_dir():
75+
yield from iter_files([path], recursive=recursive)

pyjsx/linter.py

+22-5
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def visit_JSXFragment(self, node: JSXFragment) -> None:
9090
match node.children:
9191
case []:
9292
self.errors.append((node, "Empty JSX fragment"))
93-
case [_]:
93+
case [JSXFragment() | JSXElement()]:
9494
self.errors.append((node, "Fragment with a single child"))
9595
case _:
9696
pass
@@ -115,6 +115,21 @@ def _check_self_closing_empty_body(self, node: JSXElement) -> None:
115115
self.errors.append((node, "Empty components can be self-closing"))
116116

117117

118+
def lint(source: str) -> list[tuple[Node, str]]:
119+
return Linter().lint(source)
120+
121+
122+
def fix(source: str) -> str:
123+
node = Parser(source).parse()
124+
node = remove_empty_jsx_expressions(node)
125+
node = remove_empty_jsx_fragments(node)
126+
node = remove_fragments_with_single_child(node)
127+
node = remove_duplicate_props(node)
128+
node = self_close_empty_components(node)
129+
node = remove_boolean_prop_value(node)
130+
return node.unparse()
131+
132+
118133
def remove_empty_jsx_expressions(ast: Node) -> Node:
119134
class Transformer(NodeTransformer):
120135
def visit_JSXExpression(self, node: JSXExpression) -> Node | None:
@@ -141,9 +156,11 @@ def remove_fragments_with_single_child(ast: Node) -> Node:
141156
class Transformer(NodeTransformer):
142157
def visit_JSXFragment(self, node: JSXFragment) -> Node:
143158
self.generic_visit(node)
144-
if len(node.children) == 1:
145-
return node.children[0]
146-
return node
159+
match node.children:
160+
case [JSXFragment() | JSXElement()]:
161+
return node.children[0]
162+
case _:
163+
return node
147164

148165
return Transformer().visit(ast)
149166

@@ -182,7 +199,7 @@ def visit_JSXNamedAttribute(self, node: JSXNamedAttribute) -> Node:
182199
self.generic_visit(node)
183200
match node.value:
184201
case JSXExpression(children=[PythonData(value="True")]):
185-
return dataclasses.replace(node, value=None)
202+
return dataclasses.replace(node, value=True)
186203
case _:
187204
return node
188205

pyjsx/transpiler.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,20 @@ def unparse(self) -> str:
2424
raise NotImplementedError
2525

2626

27+
28+
@dataclass
29+
class ErrorNode(Node):
30+
pass
31+
32+
33+
@dataclass
34+
class ValidNode:
35+
def unparse(self) -> str:
36+
raise NotImplementedError
37+
38+
2739
@dataclass
28-
class JSXComment:
40+
class JSXComment(Node):
2941
value: str
3042
token: Token
3143

0 commit comments

Comments
 (0)