Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ruff-cgx"
version = "0.1.5"
version = "0.2.0"
description = "Ruff linter and formatter for collagraph single-file components"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
91 changes: 28 additions & 63 deletions ruff_cgx/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
logger = logging.getLogger(__name__)


def format_script(script_node, source_lines, check=False):
def format_script(script_node, check=False):
"""
Format script section using ruff.

Expand All @@ -32,7 +32,7 @@ def format_script(script_node, source_lines, check=False):
# No content to format - return original lines unchanged
start = script_node.location[0] - 1 # Convert to 0-indexed
end = script_node.end[0] - 1 # End tag line
return source_lines[start:end], (start, end)
return [], (start, end)

# Format using ruff
formatted_source = run_ruff_format(script_content.python_code, check=check)
Expand All @@ -42,22 +42,21 @@ def format_script(script_node, source_lines, check=False):

# If Python was on same line as tag, prepend the tag on its own line
if not script_content.starts_on_new_line:
formatted_lines = ["<script>\n", *formatted_lines]
formatted_lines = formatted_lines

# If closing tag was inline with Python code, we need to:
# 1. Append closing tag to formatted output
# 2. Extend replacement range to include that line
if script_content.closing_tag_inline:
formatted_lines.append("</script>\n")
replacement_end = script_content.end_line + 1
replacement_end = script_content.end_line
else:
replacement_end = script_content.end_line

# Return formatted content with range that will be replaced
return formatted_lines, (script_content.start_line, replacement_end)


def format_file(path, check=False, write=True):
def format_file(path: str | Path, check: bool = False, write: bool = True) -> int | str:
"""
Format CGX files (the contents of the script tag) with ruff.

Expand All @@ -73,61 +72,32 @@ def format_file(path, check=False, write=True):
"""
path = Path(path)
if path.suffix != ".cgx":
return
return 1

content = path.read_text(encoding="utf-8")
parsed = parse_cgx_file(content)

lines = content.splitlines(keepends=True)

script_content, script_location = format_script(parsed.script_node, lines)
formatted_template_nodes = [format_template(node) for node in parsed.template_nodes]
formatted_content = format_cgx_content(content, str(path))

changed_script = lines[script_location[0] : script_location[1]] != script_content
changed_template = any(
[
lines[template_location[0] : template_location[1]] != template_content
for template_content, template_location in formatted_template_nodes
]
)
needs_newline_at_end_of_file = not lines[-1].endswith("\n")
changed = changed_script or changed_template or needs_newline_at_end_of_file
changed = content != formatted_content
if check:
if changed:
print(f"Would reformat: {path}") # noqa: T201
return 1
print("1 file already formatted") # noqa: T201
return 0

formatted_parts = reversed(
sorted(
[
(script_content, script_location),
*formatted_template_nodes,
],
key=lambda x: x[1][0],
)
)

for formatted_content, (start, end) in formatted_parts:
lines[start:end] = formatted_content

if needs_newline_at_end_of_file:
lines.append("\n")

# Print status message based on changes
if changed:
print("1 file reformatted") # noqa: T201
else:
print("1 file left unchanged") # noqa: T201

if not write:
# For testing, return the lines instead of writing
return lines
return formatted_content

if changed:
with path.open(mode="w", encoding="utf-8") as fh:
fh.writelines(lines)
fh.write(formatted_content)

return 0

Expand All @@ -147,46 +117,41 @@ def format_cgx_content(content: str, uri: str = "") -> str:
# Parse the CGX file
parsed = parse_cgx_file(content)

# Split content into lines
lines = content.splitlines(keepends=True)

# Check for script node
if not parsed.script_node:
logger.warning(f"Missing script node in {uri}")
return content

# Format script section
script_content, script_location = format_script(parsed.script_node, lines)
script_lines, script_location = format_script(parsed.script_node)
script_node = ["<script>\n", *script_lines, "</script>\n"]

# Format all template nodes
formatted_template_nodes = [
format_template(node) for node in parsed.template_nodes
]

# Check if newline is needed at end of file
needs_newline_at_end_of_file = lines and not lines[-1].endswith("\n")

# Sort all formatted parts by their starting location
# (in reverse order for replacement)
formatted_parts = reversed(
sorted(
[
(script_content, script_location),
*formatted_template_nodes,
],
key=lambda x: x[1][0],
)
# as to keep the same order that was in the original file
formatted_parts = sorted(
[
(script_node, script_location),
*formatted_template_nodes,
],
key=lambda x: x[1][0],
)

# Replace content in reverse order to maintain correct line indices
for formatted_content, (start, end) in formatted_parts:
lines[start:end] = formatted_content
# Flatten the formatted parts
formatted = []
for content, _ in formatted_parts:
formatted += content
# Put one empty line between root elements
formatted += "\n"

# Add newline at end if needed
if needs_newline_at_end_of_file:
lines.append("\n")
# Pop the last added line break
formatted.pop()

return "".join(lines)
return "".join(formatted)

except Exception as e:
logger.error(f"Error formatting {uri}: {e}", exc_info=True)
Expand Down
58 changes: 56 additions & 2 deletions ruff_cgx/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ast
import os
import re
import subprocess
import tempfile
import textwrap
Expand Down Expand Up @@ -129,6 +130,7 @@ def extract_script_content(script_node: Element) -> ScriptContent | None:

script_child = script_node.children[0]
python_content = script_child.content
assert "<script" not in python_content

# Get the line where the <script> tag starts and where it ends
script_tag_line = script_node.location[0] - 1 # Convert to 0-indexed
Expand Down Expand Up @@ -229,6 +231,38 @@ def create_virtual_render_content(original_content: str, modified_content: str)
return modified_content


def is_isort_configured() -> bool:
"""Check if 'unsorted-imports' is both enabled and marked as should_fix."""
result = False
try:
# Print ruff settings
ruff_output = subprocess.run(
["ruff", "check", "--show-settings"], capture_output=True, text=True
).stdout

# Get both the enabled + should_fix sections
enabled_match = re.search(
r"linter\.rules\.enabled = \[(.*?)\]", ruff_output, re.DOTALL
)

should_fix_match = re.search(
r"linter\.rules\.should_fix = \[(.*?)\]", ruff_output, re.DOTALL
)

if not enabled_match or not should_fix_match:
return False

# Check that 'unsorted-imports' rule appears in both sections
in_enabled = "unsorted-imports" in enabled_match.group(1)
in_should_fix = "unsorted-imports" in should_fix_match.group(1)

return in_enabled and in_should_fix

except Exception:
pass
return result


def run_ruff_format(
source: str, *, use_single_quotes: bool = False, check: bool = False
) -> str:
Expand All @@ -247,14 +281,18 @@ def run_ruff_format(
if check:
ruff_command.append("--check")

should_sort_imports = is_isort_configured()

with tempfile.TemporaryDirectory() as directory:
target_file = Path(directory) / "source.py"
target_file.write_text(source)
target_file.write_text(source, encoding="utf-8")

# Create config if single quotes requested
if use_single_quotes:
config_file = Path(directory) / "ruff.toml"
config_file.write_text('[format]\nquote-style = "single"\n')
config_file.write_text(
'[format]\nquote-style = "single"\n', encoding="utf-8"
)
ruff_command.extend(["--config", str(config_file)])

ruff_command.append(str(target_file))
Expand All @@ -264,6 +302,22 @@ def run_ruff_format(
env["CLICOLOR_FORCE"] = "1"

# Run ruff
if should_sort_imports:
# Sort imports with: ruff check --select I --fix .
result = subprocess.run(
[
get_ruff_command(),
"check",
"--select",
"I",
"--fix",
str(target_file),
],
capture_output=True,
text=True,
env=env,
)
# Then do the formatting
result = subprocess.run(ruff_command, capture_output=True, text=True, env=env)

if result.returncode == 0 or not check:
Expand Down
Loading