Add flask website
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
<<<<<<< HEAD
|
||||
# mojofmt
|
||||
|
||||
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
||||
|
||||
=======
|
||||
# Mojolicious Template Formatter
|
||||
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
||||
|
||||
>>>>>>> 59bfbde88e2686d25b7f6a14692814da8f440e05
|
||||
mojofmt formats HTML and Mojolicious EP templates without breaking embedded Perl. It understands line directives (% ...), inline tags (<% ... %>), raw HTML blocks, and can reformat multi-line Perl blocks inside <% ... %> using perltidy (with a safe fallback if perltidy isn’t available).
|
||||
|
||||
## Features
|
||||
|
182
manus request
182
manus request
@@ -1,182 +0,0 @@
|
||||
I am wanting you to create a python3 programm to take as input a Mojolicious Template file and output the same contents, but formatted in a way to make it structure easily understandable by a human.
|
||||
This involves identifying all html tags, mojolicious command and helper commands and also all perl constructs and indenting them in a such a way that subservient html and other statements are indented like an html prettifier, taking into account that the file may also include perl program statements which also should be properly formatted and indented to show the structure like perltidy. All "%" in the first non space character position in the line should be followed by a space unless followed by a specific mojolicious character such as "=".
|
||||
you can use any python3 libraries that you see fit.
|
||||
here is an example of a mojolicious template file which needs re-structuring:
|
||||
```
|
||||
% layout 'default', title => "Sme server 2 - yum_install";
|
||||
% content_for 'module' => begin
|
||||
<div id='module' class='module yuminstall-panel'>
|
||||
% if (config->{debug} == 1) {
|
||||
<p>
|
||||
%= dumper $c->current_route
|
||||
%= dumper $yum_datas
|
||||
</p>
|
||||
% }
|
||||
|
||||
<h1><%= $title%></h1>
|
||||
|
||||
% if ( $notif ) {
|
||||
<br><div class=sme-error>
|
||||
%= $notif
|
||||
</div>
|
||||
%}
|
||||
<br>
|
||||
|
||||
%= form_for 'yumd' => (method => 'POST') => begin
|
||||
|
||||
%=l 'yum_HEADER_AVAILABLE_SOFTWARE'
|
||||
<br><br>
|
||||
% if ($c->non_empty('available','group')) {
|
||||
%=l 'yum_DESC_AVAILABLE_GROUPS'
|
||||
<p><span class=label>
|
||||
%=l 'yum_LABEL_AVAILABLE_GROUPS'
|
||||
</span><span class=data>
|
||||
% param 'SelectedGroups' => $c->get_names2('updates','group') unless param 'SelectedGroups';
|
||||
%= select_field 'SelectedGroups' => $c->get_options2('available', 'group'), class => 'input', multiple => "1"
|
||||
</span></p>
|
||||
%}
|
||||
<br>
|
||||
% if ($c->non_empty('available','packages')) {
|
||||
%=l 'yum_DESC_AVAILABLE_PACKAGES'
|
||||
<p><span class=label>
|
||||
%=l 'yum_LABEL_AVAILABLE_PACKAGES'
|
||||
</span><span class=data>
|
||||
% param 'SelectedPackages' => $c->get_names2('updates','package') unless param 'SelectedPackages';
|
||||
%= select_field 'SelectedPackages' => $c->get_options2('available', 'package'), class => 'input', multiple => "1"
|
||||
</span></p>
|
||||
%}
|
||||
<!-- $c->print_skip_header() -->
|
||||
%= hidden_field 'trt' => 'INST'
|
||||
<br><br>
|
||||
<!-- <div class='center'>-->
|
||||
%= submit_button $c->l('yum_INSTALL_SOFTWARE'), class => 'action'
|
||||
<!--</div>-->
|
||||
% end
|
||||
</div>
|
||||
% end
|
||||
|
||||
```
|
||||
and here is the same example restructured:
|
||||
```
|
||||
% layout 'default', title => "Sme server 2 - yum_install";
|
||||
% content_for 'module' => begin
|
||||
<div id='module' class='module yuminstall-panel'>
|
||||
% if (config->{debug} == 1) {
|
||||
<p>
|
||||
%= dumper $c->current_route
|
||||
%= dumper $yum_datas
|
||||
</p>
|
||||
% }
|
||||
<h1><%= $title%></h1>
|
||||
% if ( $notif ) {<br>
|
||||
<div class=sme-error>
|
||||
%= $notif
|
||||
</div>
|
||||
%}
|
||||
<br>
|
||||
%= form_for 'yumd' => (method => 'POST') => begin
|
||||
%=l 'yum_HEADER_AVAILABLE_SOFTWARE'
|
||||
<br><br>
|
||||
% if ($c->non_empty('available','group')) {
|
||||
%=l 'yum_DESC_AVAILABLE_GROUPS'
|
||||
<p><span class=label>
|
||||
%=l 'yum_LABEL_AVAILABLE_GROUPS'
|
||||
</span><span class=data>
|
||||
% param 'SelectedGroups' => $c->get_names2('updates','group') unless param 'SelectedGroups';
|
||||
%= select_field 'SelectedGroups' => $c->get_options2('available', 'group'), class => 'input', multiple => "1"
|
||||
</span></p>
|
||||
%}
|
||||
<br>
|
||||
% if ($c->non_empty('available','packages')) {
|
||||
%=l 'yum_DESC_AVAILABLE_PACKAGES'
|
||||
<p><span class=label>
|
||||
%=l 'yum_LABEL_AVAILABLE_PACKAGES'
|
||||
</span><span class=data>
|
||||
% param 'SelectedPackages' => $c->get_names2('updates','package') unless param 'SelectedPackages';
|
||||
%= select_field 'SelectedPackages' => $c->get_options2('available', 'package'), class => 'input', multiple => "1"
|
||||
</span></p>
|
||||
%}
|
||||
<!-- $c->print_skip_header() -->
|
||||
%= hidden_field 'trt' => 'INST'
|
||||
<br><br>
|
||||
<!-- <div class='center'>-->
|
||||
%= submit_button $c->l('yum_INSTALL_SOFTWARE'), class => 'action'
|
||||
<!--</div>-->
|
||||
% end
|
||||
</div>
|
||||
% end
|
||||
```
|
||||
Give me the whole program with the following second example unformatted template file ready to run as a test
|
||||
|
||||
```
|
||||
<div id='dom_upd'>
|
||||
% my $btn = l('ADD');
|
||||
%= form_for '/domains2' => (method => 'POST') => begin
|
||||
<p>
|
||||
<h2>
|
||||
% if ( $dom_datas->{trt} eq "ADD" ) {
|
||||
%=l 'dom_CREATE_TITLE'
|
||||
% } else {
|
||||
%=l 'dom_MODIFY_TITLE'
|
||||
% $btn = l('MODIFY');
|
||||
% }
|
||||
</h2>
|
||||
</p>
|
||||
<p><br>
|
||||
<span class=label>
|
||||
%=l 'DOMAIN_NAME', class => 'label'
|
||||
</span><span class=data>
|
||||
% if ( $dom_datas->{trt} eq "ADD" ) {
|
||||
% param 'Domain' => $dom_datas->{domain} unless param 'Domain';
|
||||
%= text_field 'Domain', class => 'input'
|
||||
% } else {
|
||||
%= hidden_field 'Domain' => $dom_datas->{domain}
|
||||
%= $dom_datas->{domain}, class => 'data'
|
||||
% }
|
||||
</span>
|
||||
</p>
|
||||
<p><br>
|
||||
<span class=label>
|
||||
%=l 'DESCRIPTION_BRIEF', class => 'label'
|
||||
</span><span class=data>
|
||||
% param 'Description' => $dom_datas->{description} unless param 'Description';
|
||||
%= text_field 'Description', class => 'input'
|
||||
</span>
|
||||
</p>
|
||||
<p><br>
|
||||
%=l 'dom_CONTENT_FIELD_DESCRIPTION'
|
||||
<br>
|
||||
<span class=label>
|
||||
%= $c->l('dom_CONTENT', '');
|
||||
</span><span class=data>
|
||||
% param 'Content' => $dom_datas->{content} unless param 'Content';
|
||||
%= select_field 'Content', $c->content_options_list(), class => 'input'
|
||||
</span>
|
||||
</p>
|
||||
<p><br>
|
||||
%=l 'dom_DESC_NAMESERVERS'
|
||||
<br>
|
||||
<span class=label>
|
||||
%=l 'dom_LABEL_NAMESERVERS', class => 'label'
|
||||
</span><span class=data>
|
||||
% param 'Nameservers' => $dom_datas->{nameservers} unless param 'Nameservers';
|
||||
%= select_field 'Nameservers', $c->nameserver_options_list(), class => 'input'
|
||||
</span>
|
||||
</p>
|
||||
<p><br>
|
||||
%= submit_button "$btn", class => 'action'
|
||||
</p>
|
||||
%= hidden_field 'trt' => $dom_datas->{trt}
|
||||
%end
|
||||
</div>
|
||||
```
|
||||
|
||||
Further thoughts:
|
||||
|
||||
Some files have this iosrt of structure are <%
|
||||
...perl...
|
||||
%>
|
||||
The perl needs to be formatted accordingly.
|
||||
|
||||
and Delete all blank lines and put all final tags (where the content is indented) on a line on its own.
|
||||
Example is</p> </span> </dev> and others
|
@@ -1,788 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mojolicious Template Formatter
|
||||
|
||||
This program formats Mojolicious template files to make their structure
|
||||
easily understandable by humans. It properly indents HTML tags, Mojolicious
|
||||
commands, helper commands, and Perl constructs.
|
||||
|
||||
Uses perltidy for formatting embedded Perl code and can output perltidy results
|
||||
to a separate file for inspection.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import platform
|
||||
|
||||
|
||||
# Version information
|
||||
VERSION = "1.0"
|
||||
PROGRAM_NAME = "Mojolicious Template Formatter"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stderr)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('mojo_formatter')
|
||||
|
||||
|
||||
def get_python_version():
|
||||
"""Get the current Python version."""
|
||||
return f"{platform.python_version()}"
|
||||
|
||||
|
||||
def get_perltidy_version():
|
||||
"""Get the installed perltidy version."""
|
||||
try:
|
||||
# Run the perltidy command
|
||||
result = subprocess.run(['perltidy', '-v'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
# Extract version from stdout
|
||||
version_match = re.search(r'This is perltidy, (v[\d\.]+)', result.stdout)
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
return "Unknown version"
|
||||
else:
|
||||
return "Not available"
|
||||
except Exception:
|
||||
return "Not installed"
|
||||
|
||||
def log_system_info():
|
||||
"""Log system information including program version and dependencies."""
|
||||
python_version = get_python_version()
|
||||
perltidy_version = get_perltidy_version()
|
||||
|
||||
logger.info(f"{PROGRAM_NAME} v{VERSION}")
|
||||
logger.info(f"Running with Python {python_version}")
|
||||
logger.info(f"Perltidy {perltidy_version}")
|
||||
|
||||
class MojoTemplateFormatter:
|
||||
"""
|
||||
A formatter for Mojolicious template files that makes their structure
|
||||
easily understandable by humans.
|
||||
"""
|
||||
|
||||
def __init__(self, indent_size=4, perltidy_output_dir=None):
|
||||
"""Initialize the formatter with default settings."""
|
||||
self.indent_size = indent_size
|
||||
self.current_indent = 0
|
||||
self.output_lines = []
|
||||
self.perl_block_stack = []
|
||||
self.html_tag_stack = []
|
||||
self.in_form_block = False
|
||||
self.in_content_block = False
|
||||
self.remove_blank_lines = True
|
||||
self.perltidy_output_dir = perltidy_output_dir
|
||||
self.perltidy_block_count = 0
|
||||
|
||||
# Patterns for Mojolicious syntax
|
||||
self.mojo_command_pattern = re.compile(r'^(\s*)(%\s*.*?)$')
|
||||
self.perl_block_start_pattern = re.compile(r'%\s*(?:if|for|while|unless|begin)\b.*?{')
|
||||
self.perl_if_pattern = re.compile(r'%\s*if\s*\(.*\)\s*{')
|
||||
self.content_block_start_pattern = re.compile(r'%\s*content_for\b.*?=>\s*begin\b')
|
||||
self.form_block_start_pattern = re.compile(r'%=\s*form_for\b.*?=>\s*begin\b')
|
||||
self.perl_block_end_pattern = re.compile(r'%\s*}')
|
||||
self.perl_end_pattern = re.compile(r'%\s*end\b')
|
||||
|
||||
# Embedded Perl patterns
|
||||
self.embedded_perl_start_pattern = re.compile(r'<%')
|
||||
self.embedded_perl_end_pattern = re.compile(r'%>')
|
||||
self.mojo_expression_pattern = re.compile(r'<%=?=?\s*(.*?)\s*%>')
|
||||
self.mojo_code_pattern = re.compile(r'<%\s*(.*?)\s*%>')
|
||||
self.mojo_comment_pattern = re.compile(r'<%#\s*(.*?)\s*%>')
|
||||
|
||||
# HTML tag patterns
|
||||
self.html_open_tag_pattern = re.compile(r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*(?<!/)>')
|
||||
self.html_close_tag_pattern = re.compile(r'</([a-zA-Z][a-zA-Z0-9]*)>')
|
||||
self.html_self_closing_tag_pattern = re.compile(r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*/>')
|
||||
|
||||
# Pattern for multiple closing tags on a line
|
||||
self.multiple_closing_tags_pattern = re.compile(r'(</[^>]+>)(\s*)(</[^>]+>)')
|
||||
|
||||
# List of tags that shouldn't cause indentation changes
|
||||
self.non_indenting_tags = ['br', 'hr', 'img', 'input', 'link', 'meta']
|
||||
|
||||
# List of tags that should have minimal indentation
|
||||
self.minimal_indent_tags = ['span', 'p']
|
||||
|
||||
# Pattern for lines with multiple closing tags
|
||||
self.multiple_close_tags_pattern = re.compile(r'</[^>]+>.*</[^>]+>')
|
||||
|
||||
# Pattern for lines ending with <br> and closing tags
|
||||
self.br_with_close_tags_pattern = re.compile(r'<br[^>]*>\s*</[^>]+>')
|
||||
|
||||
# Pattern for lines with <br></span></p> pattern or variations
|
||||
self.br_span_p_pattern = re.compile(r'<br[^>]*>\s*</span>\s*</p>')
|
||||
|
||||
# Pattern for lines with <br></span> followed by whitespace and </p>
|
||||
self.br_span_space_p_pattern = re.compile(r'<br[^>]*>\s*</span>\s+</p>')
|
||||
|
||||
def format(self, content):
|
||||
"""
|
||||
Format the given Mojolicious template content.
|
||||
|
||||
Args:
|
||||
content (str): The content of the Mojolicious template file.
|
||||
|
||||
Returns:
|
||||
str: The formatted content.
|
||||
"""
|
||||
logger.info("Starting formatting process")
|
||||
|
||||
# First pass: process embedded Perl blocks
|
||||
logger.info("Processing embedded Perl blocks")
|
||||
content = self._preprocess_embedded_perl(content)
|
||||
|
||||
lines = content.splitlines()
|
||||
self.output_lines = []
|
||||
self.current_indent = 0
|
||||
self.perl_block_stack = []
|
||||
self.html_tag_stack = []
|
||||
self.in_form_block = False
|
||||
self.in_content_block = False
|
||||
|
||||
logger.info("Processing lines for HTML and Mojolicious commands")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
i += 1
|
||||
|
||||
# Skip empty lines if remove_blank_lines is enabled
|
||||
if not line.strip():
|
||||
if not self.remove_blank_lines:
|
||||
self.output_lines.append('')
|
||||
continue
|
||||
|
||||
# Process the line based on its type
|
||||
if self._is_mojo_command_line(line):
|
||||
self._process_mojo_command_line(line)
|
||||
else:
|
||||
self._process_html_line(line)
|
||||
|
||||
# Second pass: handle closing tags on separate lines
|
||||
logger.info("Post-processing closing tags")
|
||||
self._postprocess_closing_tags()
|
||||
|
||||
logger.info("Formatting complete")
|
||||
return '\n'.join(self.output_lines)
|
||||
|
||||
def _preprocess_embedded_perl(self, content):
|
||||
"""
|
||||
Preprocess embedded Perl blocks to format the Perl code inside using perltidy.
|
||||
|
||||
Args:
|
||||
content (str): The content to preprocess.
|
||||
|
||||
Returns:
|
||||
str: The preprocessed content.
|
||||
"""
|
||||
# Find all embedded Perl blocks
|
||||
pattern = re.compile(r'<%\s*(.*?)\s*%>', re.DOTALL)
|
||||
|
||||
def format_perl_code(match):
|
||||
perl_code = match.group(1)
|
||||
if not perl_code.strip():
|
||||
logger.debug("Empty Perl block found")
|
||||
return f"<%\n%>"
|
||||
|
||||
# Format the Perl code by adding indentation
|
||||
lines = perl_code.splitlines()
|
||||
if len(lines) <= 1:
|
||||
# For single-line Perl, just clean up spacing
|
||||
logger.debug("Single-line Perl block found")
|
||||
return f"<% {perl_code.strip()} %>"
|
||||
|
||||
# For multi-line Perl, use perltidy
|
||||
self.perltidy_block_count += 1
|
||||
block_id = self.perltidy_block_count
|
||||
logger.info(f"Found multi-line Perl block #{block_id} with {len(lines)} lines")
|
||||
logger.debug(f"Original Perl code (block #{block_id}):\n{perl_code}")
|
||||
|
||||
formatted_perl = self._run_perltidy(perl_code, block_id)
|
||||
|
||||
# If perltidy fails, fall back to our simple formatter
|
||||
if formatted_perl is None:
|
||||
logger.warning(f"Perltidy failed for block #{block_id}, falling back to simple formatter")
|
||||
formatted_lines = []
|
||||
current_indent = self.indent_size
|
||||
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue # Skip empty lines
|
||||
|
||||
stripped = line.lstrip()
|
||||
|
||||
# Check if this line decreases indentation (closing brace at start)
|
||||
if stripped.startswith('}') or stripped.startswith(');'):
|
||||
current_indent = max(self.indent_size, current_indent - self.indent_size)
|
||||
|
||||
# Add the line with proper indentation
|
||||
if stripped.startswith('#'):
|
||||
# For comments, use the current indentation
|
||||
formatted_lines.append(' ' * current_indent + stripped)
|
||||
else:
|
||||
formatted_lines.append(' ' * current_indent + stripped)
|
||||
|
||||
# Check if this line increases indentation for the next line
|
||||
if (stripped.endswith('{') or
|
||||
stripped.endswith('({') or
|
||||
stripped.endswith('sub {') or
|
||||
stripped.endswith('= {') or
|
||||
stripped.endswith('=> {') or
|
||||
(stripped.endswith('(') and not stripped.startswith(')'))):
|
||||
current_indent += self.indent_size
|
||||
|
||||
# Special case for closing parentheses that decrease indentation
|
||||
if stripped.endswith(');') and not stripped.startswith('('):
|
||||
current_indent = max(self.indent_size, current_indent - self.indent_size)
|
||||
|
||||
# Join the formatted lines with newlines
|
||||
formatted_perl = '\n'.join(formatted_lines)
|
||||
else:
|
||||
logger.info(f"Perltidy successfully formatted block #{block_id}")
|
||||
logger.debug(f"Perltidy formatted code (block #{block_id}):\n{formatted_perl}")
|
||||
|
||||
# Note: No space between % and > in the closing tag
|
||||
# IMPORTANT: Preserve the exact perltidy formatting
|
||||
return f"<%\n{formatted_perl}\n%>"
|
||||
|
||||
# Replace all embedded Perl blocks with formatted versions
|
||||
logger.info("Searching for embedded Perl blocks")
|
||||
result = pattern.sub(format_perl_code, content)
|
||||
logger.info(f"Embedded Perl block processing complete, found {self.perltidy_block_count} blocks")
|
||||
return result
|
||||
|
||||
def _run_perltidy(self, perl_code, block_id):
|
||||
"""
|
||||
Run perltidy on the given Perl code.
|
||||
|
||||
Args:
|
||||
perl_code (str): The Perl code to format.
|
||||
block_id (int): Identifier for this Perl block.
|
||||
|
||||
Returns:
|
||||
str: The formatted Perl code, or None if perltidy fails.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Running perltidy on Perl block #{block_id}")
|
||||
|
||||
# Create temporary files for input and output
|
||||
with tempfile.NamedTemporaryFile(mode='w+', delete=False) as input_file:
|
||||
input_file.write(perl_code)
|
||||
input_file_path = input_file.name
|
||||
logger.debug(f"Created temporary input file for block #{block_id}: {input_file_path}")
|
||||
|
||||
output_file_path = input_file_path + '.tidy'
|
||||
|
||||
# Run perltidy with our desired options
|
||||
cmd = [
|
||||
'perltidy',
|
||||
'-i=' + str(self.indent_size), # Set indentation size
|
||||
'-ci=' + str(self.indent_size), # Set continuation indentation
|
||||
'-l=120', # Line length
|
||||
'-pt=2', # Parenthesis tightness
|
||||
'-bt=2', # Brace tightness
|
||||
'-sbt=2', # Square bracket tightness
|
||||
'-ce', # Cuddled else
|
||||
'-nbl', # No blank lines before comments
|
||||
'-nsfs', # No space for semicolon
|
||||
input_file_path, # Input file
|
||||
'-o', output_file_path # Output file
|
||||
]
|
||||
|
||||
logger.debug(f"Executing perltidy command for block #{block_id}: {' '.join(cmd)}")
|
||||
|
||||
# Execute perltidy
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
# Check if perltidy succeeded
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Perltidy failed for block #{block_id} with return code {result.returncode}")
|
||||
logger.error(f"Stderr: {result.stderr}")
|
||||
return None
|
||||
|
||||
# Read the formatted code
|
||||
if os.path.exists(output_file_path):
|
||||
with open(output_file_path, 'r') as output_file:
|
||||
formatted_code = output_file.read()
|
||||
logger.info(f"Perltidy output file size for block #{block_id}: {len(formatted_code)} bytes")
|
||||
|
||||
# If requested, save the perltidy output to a separate file
|
||||
if self.perltidy_output_dir:
|
||||
self._save_perltidy_output(perl_code, formatted_code, block_id)
|
||||
else:
|
||||
logger.error(f"Perltidy output file not found for block #{block_id}: {output_file_path}")
|
||||
return None
|
||||
|
||||
# Clean up temporary files
|
||||
logger.debug(f"Cleaning up temporary files for block #{block_id}")
|
||||
os.unlink(input_file_path)
|
||||
if os.path.exists(output_file_path):
|
||||
os.unlink(output_file_path)
|
||||
|
||||
return formatted_code.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error running perltidy for block #{block_id}: {e}")
|
||||
return None
|
||||
|
||||
def _save_perltidy_output(self, original_code, formatted_code, block_id):
|
||||
"""
|
||||
Save the original and formatted Perl code to separate files for inspection.
|
||||
|
||||
Args:
|
||||
original_code (str): The original Perl code.
|
||||
formatted_code (str): The formatted Perl code.
|
||||
block_id (int): Identifier for this Perl block.
|
||||
"""
|
||||
try:
|
||||
# Create the output directory if it doesn't exist
|
||||
os.makedirs(self.perltidy_output_dir, exist_ok=True)
|
||||
|
||||
# Create filenames for the original and formatted code
|
||||
original_file = os.path.join(self.perltidy_output_dir, f"perl_block_{block_id}_original.pl")
|
||||
formatted_file = os.path.join(self.perltidy_output_dir, f"perl_block_{block_id}_formatted.pl")
|
||||
|
||||
# Write the original code to a file
|
||||
with open(original_file, 'w') as f:
|
||||
f.write(original_code)
|
||||
|
||||
# Write the formatted code to a file
|
||||
with open(formatted_file, 'w') as f:
|
||||
f.write(formatted_code)
|
||||
|
||||
logger.info(f"Saved perltidy input/output for block #{block_id} to {original_file} and {formatted_file}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving perltidy output for block #{block_id}: {e}")
|
||||
|
||||
def _postprocess_closing_tags(self):
|
||||
"""
|
||||
Postprocess the output lines to put closing tags on separate lines.
|
||||
"""
|
||||
logger.info("Post-processing closing tags")
|
||||
result_lines = []
|
||||
i = 0
|
||||
|
||||
# Track if we're inside an embedded Perl block
|
||||
in_perl_block = False
|
||||
|
||||
while i < len(self.output_lines):
|
||||
line = self.output_lines[i]
|
||||
|
||||
# Check if we're entering an embedded Perl block
|
||||
if line.strip() == '<%':
|
||||
in_perl_block = True
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check if we're exiting an embedded Perl block
|
||||
if line.strip() == '%>':
|
||||
in_perl_block = False
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# If we're inside an embedded Perl block, don't modify the line
|
||||
if in_perl_block:
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for multiple closing tags
|
||||
if self.multiple_closing_tags_pattern.search(line):
|
||||
logger.debug(f"Found multiple closing tags in line: {line}")
|
||||
# Split the line at each closing tag
|
||||
parts = []
|
||||
current = line
|
||||
|
||||
while self.multiple_closing_tags_pattern.search(current):
|
||||
match = self.multiple_closing_tags_pattern.search(current)
|
||||
first_tag = match.group(1)
|
||||
whitespace = match.group(2)
|
||||
second_tag = match.group(3)
|
||||
|
||||
# Split at the second tag
|
||||
before_second = current[:match.start(3)]
|
||||
after_second = current[match.end(3):]
|
||||
|
||||
# Add the part before the second tag
|
||||
parts.append(before_second)
|
||||
|
||||
# Update current to be the second tag and everything after
|
||||
current = second_tag + after_second
|
||||
|
||||
# Add the last part
|
||||
if current:
|
||||
parts.append(current)
|
||||
|
||||
# Add all parts as separate lines
|
||||
base_indent = len(line) - len(line.lstrip())
|
||||
for j, part in enumerate(parts):
|
||||
# For closing tags, reduce indentation
|
||||
if j > 0 and part.strip().startswith('</'):
|
||||
indent = max(0, base_indent - self.indent_size)
|
||||
else:
|
||||
indent = base_indent
|
||||
|
||||
result_lines.append(' ' * indent + part.strip())
|
||||
logger.debug(f"Split line part {j+1}: {' ' * indent + part.strip()}")
|
||||
else:
|
||||
result_lines.append(line)
|
||||
|
||||
i += 1
|
||||
|
||||
self.output_lines = result_lines
|
||||
logger.info(f"Post-processing complete, {len(result_lines)} lines in output")
|
||||
|
||||
def _is_mojo_command_line(self, line):
|
||||
"""
|
||||
Check if the line is a Mojolicious command line (starts with %).
|
||||
|
||||
Args:
|
||||
line (str): The line to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the line is a Mojolicious command line, False otherwise.
|
||||
"""
|
||||
stripped = line.lstrip()
|
||||
return stripped and stripped[0] == '%'
|
||||
|
||||
def _process_mojo_command_line(self, line):
|
||||
"""
|
||||
Process a Mojolicious command line.
|
||||
|
||||
Args:
|
||||
line (str): The Mojolicious command line to process.
|
||||
"""
|
||||
stripped = line.lstrip()
|
||||
logger.debug(f"Processing Mojo command line: {stripped}")
|
||||
|
||||
# Ensure space after % if not followed by specific characters
|
||||
if stripped.startswith('%') and len(stripped) > 1 and stripped[1] not in ['=', '#', '%']:
|
||||
if not stripped[1].isspace():
|
||||
stripped = '%' + ' ' + stripped[1:]
|
||||
|
||||
# Check for content block start
|
||||
if self.content_block_start_pattern.search(stripped):
|
||||
logger.debug("Found content block start")
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
self.in_content_block = True
|
||||
self.current_indent += self.indent_size
|
||||
return
|
||||
|
||||
# Check for form block start
|
||||
if self.form_block_start_pattern.search(stripped):
|
||||
logger.debug("Found form block start")
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
self.in_form_block = True
|
||||
self.current_indent += self.indent_size
|
||||
return
|
||||
|
||||
# Handle Perl block opening
|
||||
if self.perl_block_start_pattern.search(stripped):
|
||||
logger.debug("Found Perl block start")
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
|
||||
# Track the block with its opening indentation level
|
||||
self.perl_block_stack.append(self.current_indent)
|
||||
self.current_indent += self.indent_size
|
||||
return
|
||||
|
||||
# Handle Perl block closing with }
|
||||
if self.perl_block_end_pattern.search(stripped):
|
||||
logger.debug("Found Perl block end with }")
|
||||
if self.perl_block_stack:
|
||||
# Pop the indentation level from the stack
|
||||
self.current_indent = self.perl_block_stack.pop()
|
||||
|
||||
# Apply the indentation to the closing brace (same as opening)
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
else:
|
||||
# If no block stack, just use current indentation
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
return
|
||||
|
||||
# Handle Perl block closing with end
|
||||
if self.perl_end_pattern.search(stripped):
|
||||
logger.debug("Found Perl block end with 'end'")
|
||||
if self.in_form_block and not self.perl_block_stack:
|
||||
self.in_form_block = False
|
||||
self.current_indent = max(0, self.current_indent - self.indent_size)
|
||||
elif self.in_content_block and not self.perl_block_stack:
|
||||
self.in_content_block = False
|
||||
self.current_indent = max(0, self.current_indent - self.indent_size)
|
||||
elif self.perl_block_stack:
|
||||
# Pop the indentation level from the stack
|
||||
self.current_indent = self.perl_block_stack.pop()
|
||||
|
||||
# Apply the indentation to the end statement
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
return
|
||||
|
||||
# Regular Mojolicious command line
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
|
||||
def _process_html_line(self, line):
|
||||
"""
|
||||
Process an HTML line.
|
||||
|
||||
Args:
|
||||
line (str): The HTML line to process.
|
||||
"""
|
||||
# Special handling for embedded Perl blocks
|
||||
if line.strip().startswith('<%'):
|
||||
# For embedded Perl blocks, don't modify the indentation
|
||||
# Just add the line as is to preserve perltidy formatting
|
||||
self.output_lines.append(line)
|
||||
return
|
||||
|
||||
# Special handling for Perl block closing tag
|
||||
if line.strip() == '%>':
|
||||
# For the closing tag, don't add any space after %
|
||||
self.output_lines.append('%>')
|
||||
return
|
||||
|
||||
stripped = line.lstrip()
|
||||
logger.debug(f"Processing HTML line: {stripped[:30]}...")
|
||||
|
||||
# Special handling for lines with <br></span></p> pattern or variations
|
||||
if self.br_span_p_pattern.search(stripped) or self.br_span_space_p_pattern.search(stripped):
|
||||
logger.debug("Found <br></span></p> pattern")
|
||||
# Find the base indentation level for this paragraph
|
||||
base_indent = 0
|
||||
for i in range(len(self.html_tag_stack)):
|
||||
if i < len(self.html_tag_stack) and self.html_tag_stack[i].lower() == 'p':
|
||||
base_indent = i * self.indent_size
|
||||
break
|
||||
|
||||
# If we couldn't find a p tag, use the current indentation minus some offset
|
||||
if base_indent == 0:
|
||||
base_indent = max(0, self.current_indent - (2 * self.indent_size))
|
||||
|
||||
# Format the line with the base indentation
|
||||
indent = ' ' * base_indent
|
||||
|
||||
# Preserve the original spacing between </span> and </p> if it exists
|
||||
if self.br_span_space_p_pattern.search(stripped):
|
||||
# Replace <br></span> with <br></span> but keep the spacing before </p>
|
||||
parts = re.split(r'(</span>\s+</p>)', stripped)
|
||||
if len(parts) >= 3:
|
||||
formatted_line = indent + parts[0] + parts[1]
|
||||
else:
|
||||
formatted_line = indent + stripped
|
||||
else:
|
||||
formatted_line = indent + stripped
|
||||
|
||||
self.output_lines.append(formatted_line)
|
||||
|
||||
# Update the tag stack to reflect the closing tags
|
||||
if 'span' in self.html_tag_stack:
|
||||
self.html_tag_stack.remove('span')
|
||||
if 'p' in self.html_tag_stack:
|
||||
self.html_tag_stack.remove('p')
|
||||
|
||||
# Adjust current indentation
|
||||
self.current_indent = base_indent
|
||||
|
||||
return
|
||||
|
||||
# Special handling for lines with <br> and closing tags
|
||||
if self.br_with_close_tags_pattern.search(stripped):
|
||||
logger.debug("Found <br> with closing tags")
|
||||
# Find appropriate indentation level
|
||||
indent_level = self.current_indent
|
||||
for tag in self.html_close_tag_pattern.findall(stripped):
|
||||
if tag.lower() in self.minimal_indent_tags and tag.lower() in [t.lower() for t in self.html_tag_stack]:
|
||||
indent_level = max(0, indent_level - (self.indent_size // 2))
|
||||
|
||||
indent = ' ' * indent_level
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
|
||||
# Update the tag stack to reflect the closing tags
|
||||
close_tags = self.html_close_tag_pattern.findall(stripped)
|
||||
for tag in close_tags:
|
||||
if tag.lower() in [t.lower() for t in self.html_tag_stack]:
|
||||
self.html_tag_stack.remove(tag)
|
||||
|
||||
return
|
||||
|
||||
# Skip indentation changes for lines with only non-indenting tags
|
||||
if self._contains_only_non_indenting_tags(stripped):
|
||||
logger.debug("Found line with only non-indenting tags")
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
return
|
||||
|
||||
# Special handling for lines with multiple closing tags
|
||||
if self.multiple_close_tags_pattern.search(stripped):
|
||||
logger.debug("Found line with multiple closing tags")
|
||||
# Count the number of closing tags
|
||||
close_count = len(self.html_close_tag_pattern.findall(stripped))
|
||||
# Reduce indentation once for the whole line
|
||||
if close_count > 1 and self.html_tag_stack:
|
||||
for _ in range(min(close_count, len(self.html_tag_stack))):
|
||||
tag = self.html_tag_stack.pop()
|
||||
if tag.lower() not in self.non_indenting_tags:
|
||||
self.current_indent = max(0, self.current_indent - self.indent_size)
|
||||
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
return
|
||||
|
||||
# Check for closing tags first to adjust indentation before adding the line
|
||||
close_tags = self.html_close_tag_pattern.findall(stripped)
|
||||
for tag in close_tags:
|
||||
if tag.lower() in self.non_indenting_tags:
|
||||
continue
|
||||
|
||||
if tag.lower() in [t.lower() for t in self.html_tag_stack]:
|
||||
self.html_tag_stack.remove(tag)
|
||||
|
||||
# Use smaller indentation for minimal indent tags
|
||||
if tag.lower() in self.minimal_indent_tags:
|
||||
self.current_indent = max(0, self.current_indent - (self.indent_size // 2))
|
||||
else:
|
||||
self.current_indent = max(0, self.current_indent - self.indent_size)
|
||||
|
||||
# Apply current indentation
|
||||
indent = ' ' * self.current_indent
|
||||
formatted_line = indent + stripped
|
||||
self.output_lines.append(formatted_line)
|
||||
|
||||
# Check for opening tags to adjust indentation for next lines
|
||||
open_tags = self.html_open_tag_pattern.findall(stripped)
|
||||
self_closing_tags = self.html_self_closing_tag_pattern.findall(stripped)
|
||||
|
||||
# Add only non-self-closing tags to the stack (excluding special tags)
|
||||
for tag in open_tags:
|
||||
if tag.lower() in self.non_indenting_tags:
|
||||
continue
|
||||
|
||||
if tag not in self_closing_tags:
|
||||
self.html_tag_stack.append(tag)
|
||||
|
||||
# Use smaller indentation for minimal indent tags
|
||||
if tag.lower() in self.minimal_indent_tags:
|
||||
self.current_indent += (self.indent_size // 2)
|
||||
else:
|
||||
self.current_indent += self.indent_size
|
||||
|
||||
def _contains_only_non_indenting_tags(self, line):
|
||||
"""
|
||||
Check if the line contains only non-indenting tags.
|
||||
|
||||
Args:
|
||||
line (str): The line to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the line contains only non-indenting tags, False otherwise.
|
||||
"""
|
||||
# Check for <br> tags
|
||||
for tag in self.non_indenting_tags:
|
||||
pattern = re.compile(f'<{tag}[^>]*>')
|
||||
if pattern.search(line):
|
||||
# If the line has a non-indenting tag and not much else
|
||||
other_content = re.sub(f'</?{tag}[^>]*>', '', line).strip()
|
||||
if not other_content or other_content == '</span>' or other_content == '</p>' or '</span>' in other_content:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def format_mojolicious_template(content, indent_size=4, remove_blank_lines=True, log_level=logging.INFO, perltidy_output_dir=None):
|
||||
"""
|
||||
Format a Mojolicious template.
|
||||
|
||||
Args:
|
||||
content (str): The content of the Mojolicious template.
|
||||
indent_size (int): Number of spaces to use for indentation.
|
||||
remove_blank_lines (bool): Whether to remove blank lines.
|
||||
log_level (int): Logging level to use.
|
||||
perltidy_output_dir (str): Directory to save perltidy input/output files.
|
||||
|
||||
Returns:
|
||||
str: The formatted content.
|
||||
"""
|
||||
# Set the logging level
|
||||
logger.setLevel(log_level)
|
||||
|
||||
formatter = MojoTemplateFormatter(indent_size, perltidy_output_dir)
|
||||
formatter.remove_blank_lines = remove_blank_lines
|
||||
return formatter.format(content)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the formatter."""
|
||||
parser = argparse.ArgumentParser(description='Format Mojolicious template files.')
|
||||
parser.add_argument('input_file', nargs='?', type=argparse.FileType('r'),
|
||||
default=sys.stdin, help='Input file (default: stdin)')
|
||||
parser.add_argument('output_file', nargs='?', type=argparse.FileType('w'),
|
||||
default=sys.stdout, help='Output file (default: stdout)')
|
||||
parser.add_argument('--indent', type=int, default=4,
|
||||
help='Number of spaces to use for indentation (default: 4)')
|
||||
parser.add_argument('--keep-blank-lines', action='store_true',
|
||||
help='Keep blank lines in the output (default: remove blank lines)')
|
||||
parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
default='INFO', help='Set the logging level (default: INFO)')
|
||||
parser.add_argument('--perltidy-output-dir', type=str,
|
||||
help='Directory to save perltidy input/output files for inspection')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set the log level based on the command-line argument
|
||||
log_level = getattr(logging, args.log_level)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Log program and version information
|
||||
log_system_info()
|
||||
|
||||
logger.info(f"Starting formatter with indent={args.indent}, keep_blank_lines={args.keep_blank_lines}, log_level={args.log_level}")
|
||||
if args.perltidy_output_dir:
|
||||
logger.info(f"Perltidy output will be saved to: {args.perltidy_output_dir}")
|
||||
|
||||
content = args.input_file.read()
|
||||
logger.info(f"Read {len(content)} bytes from input")
|
||||
|
||||
formatted_content = format_mojolicious_template(
|
||||
content,
|
||||
args.indent,
|
||||
remove_blank_lines=not args.keep_blank_lines,
|
||||
log_level=log_level,
|
||||
perltidy_output_dir=args.perltidy_output_dir
|
||||
)
|
||||
|
||||
logger.info(f"Writing {len(formatted_content)} bytes to output")
|
||||
args.output_file.write(formatted_content)
|
||||
logger.info("Formatting complete")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
801
mojofmt.py.bak
Normal file
801
mojofmt.py.bak
Normal file
@@ -0,0 +1,801 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
mojofmt: Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
||||
|
||||
Features (Phase 1 + additions):
|
||||
- Indent HTML structure and Mojolicious line directives consistently
|
||||
- Preserve chomp markers (<%- ... -%>) and do not alter newline semantics
|
||||
- Handle helper begin/end blocks and Perl brace-based indentation for directives
|
||||
- Treat pre/script/style/textarea content as opaque (unchanged)
|
||||
- Optionally normalize spacing inside <% %> delimiters and after % directives
|
||||
- Integrate with perltidy for Perl code formatting (if available on PATH)
|
||||
- CLI with --write/--check/--diff, --out, --stdin/--stdout modes
|
||||
- --self-test for sanity checks (includes perltidy probe)
|
||||
- Logging: --log-level error|info|debug (and --verbose as shorthand for info)
|
||||
- Optional --perl-keyword-spacing to aggressively insert spaces after Perl keywords
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, replace as dc_replace
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
VERSION = "0.1.7"
|
||||
|
||||
DEFAULT_EXTENSIONS = (".ep", ".htm.ep", ".html.ep")
|
||||
VOID_ELEMENTS = {
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr",
|
||||
}
|
||||
RAW_ELEMENTS = {"pre", "script", "style", "textarea"}
|
||||
|
||||
logger = logging.getLogger("mojofmt")
|
||||
|
||||
TAG_RE = re.compile(
|
||||
r"""
|
||||
<
|
||||
(?P<slash>/)?
|
||||
(?P<name>[A-Za-z][\w:-]*)
|
||||
(?P<attrs>(?:\s+[^<>]*?)?)
|
||||
(?P<self>/)?
|
||||
>
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Mojolicious inline tags on a single line: <%...%>
|
||||
TPL_TAG_RE = re.compile(
|
||||
r"""
|
||||
<%
|
||||
(?P<leftchomp>-)? # optional left chomp
|
||||
(?P<kind>==|=|\#)? # kind: ==, =, or #
|
||||
(?P<body>.*?) # inner code/comment (non-greedy, no newlines)
|
||||
(?P<rightchomp>-)? # optional right chomp
|
||||
%>
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Line directives: starts with % (possibly %= %== %#) after indentation
|
||||
LINE_DIR_RE = re.compile(r"^(?P<indent>\s*)%(?P<kind>==|=|\#)?(?P<body>.*)$")
|
||||
|
||||
# Whitespace condensing for single-line normalization
|
||||
WS_RE = re.compile(r"[ \t]+")
|
||||
|
||||
# begin/end detection (heuristic)
|
||||
BEGIN_RE = re.compile(r"\bbegin\b")
|
||||
END_LINE_RE = re.compile(r"^\s*%\s*end\b")
|
||||
END_TAG_ONLY_RE = re.compile(r"^\s*<%-?\s*end\s*-?%>\s*$")
|
||||
|
||||
# leading } in a directive (e.g., % } or % }} )
|
||||
LEADING_RBRACE_COUNT_RE = re.compile(r"^\s*%\s*(?P<braces>\}+)")
|
||||
|
||||
# <% } %> alone
|
||||
TAG_CLOSING_BRACE_ONLY_RE = re.compile(r"^\s*<%-?\s*\}+\s*-?%>\s*$")
|
||||
|
||||
# Detect raw element opening/closing (as standalone lines)
|
||||
RAW_OPEN_RE = re.compile(r"^\s*<(?P<name>pre|script|style|textarea)\b[^>]*>\s*$", re.I)
|
||||
RAW_CLOSE_RE = re.compile(r"^\s*</(?P<name>pre|script|style|textarea)\s*>\s*$", re.I)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
indent_width: int = 2
|
||||
eol: str = "lf" # lf|crlf|preserve
|
||||
normalize_delimiter_spacing: bool = True
|
||||
perltidy_path: Optional[str] = None # if None, use PATH
|
||||
perltidy_options: Optional[List[str]] = None
|
||||
extensions: Tuple[str, ...] = DEFAULT_EXTENSIONS
|
||||
respect_gitignore: bool = True
|
||||
verbose: bool = False # kept for shorthand with --verbose
|
||||
perl_keyword_spacing: bool = False # optional post-pass
|
||||
|
||||
|
||||
def load_config(cli_args: argparse.Namespace) -> Config:
|
||||
cfg = Config()
|
||||
if cli_args.indent is not None:
|
||||
cfg.indent_width = cli_args.indent
|
||||
if cli_args.eol is not None:
|
||||
cfg.eol = cli_args.eol
|
||||
if cli_args.no_space_in_delims:
|
||||
cfg.normalize_delimiter_spacing = False
|
||||
if cli_args.perltidy:
|
||||
cfg.perltidy_path = cli_args.perltidy
|
||||
cfg.verbose = cli_args.verbose
|
||||
cfg.perl_keyword_spacing = getattr(cli_args, "perl_keyword_spacing", False)
|
||||
return cfg
|
||||
|
||||
|
||||
def setup_logging(level_name: Optional[str], verbose_flag: bool) -> None:
|
||||
# Determine level
|
||||
if level_name:
|
||||
name = level_name.lower()
|
||||
elif verbose_flag:
|
||||
name = "info"
|
||||
else:
|
||||
name = "error"
|
||||
level = {
|
||||
"error": logging.ERROR,
|
||||
"warning": logging.WARNING,
|
||||
"info": logging.INFO,
|
||||
"debug": logging.DEBUG,
|
||||
"critical": logging.CRITICAL,
|
||||
}.get(name, logging.ERROR)
|
||||
|
||||
fmt = "mojofmt: %(levelname)s: %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt)
|
||||
|
||||
|
||||
def detect_eol(text: str) -> str:
|
||||
if "\r\n" in text:
|
||||
return "crlf"
|
||||
return "lf"
|
||||
|
||||
|
||||
def normalize_eol(text: str, eol: str) -> str:
|
||||
if eol == "preserve":
|
||||
return text
|
||||
s = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
if eol == "lf":
|
||||
return s
|
||||
elif eol == "crlf":
|
||||
return s.replace("\n", "\r\n")
|
||||
else:
|
||||
return s
|
||||
|
||||
|
||||
_PERLTIDY_WARNED = False # avoid spamming logs if perltidy missing repeatedly
|
||||
|
||||
|
||||
def run_perltidy(code: str, cfg: Config) -> Tuple[int, str, str]:
|
||||
global _PERLTIDY_WARNED
|
||||
exe = cfg.perltidy_path or shutil.which("perltidy")
|
||||
if not exe:
|
||||
if not _PERLTIDY_WARNED:
|
||||
logger.error("perltidy not found; Perl inside template will not be reformatted")
|
||||
_PERLTIDY_WARNED = True
|
||||
return (127, code, "perltidy not found")
|
||||
args = [exe]
|
||||
default_opts = ["-i=2", "-ci=2", "-l=100", "-q", "-se", "-nbbc", "-noll"]
|
||||
if cfg.perltidy_options:
|
||||
args += cfg.perltidy_options
|
||||
else:
|
||||
args += default_opts
|
||||
logger.debug("Running perltidy: %s", " ".join(args))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
input=code,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.debug("perltidy non-zero exit %s: %s", proc.returncode, (proc.stderr or "").strip())
|
||||
return (proc.returncode, proc.stdout, proc.stderr)
|
||||
except FileNotFoundError:
|
||||
if not _PERLTIDY_WARNED:
|
||||
logger.error("perltidy not found while executing")
|
||||
_PERLTIDY_WARNED = True
|
||||
return (127, code, "perltidy not found")
|
||||
|
||||
|
||||
def perltidy_probe(cfg: Config) -> Tuple[bool, str]:
|
||||
exe = cfg.perltidy_path or shutil.which("perltidy")
|
||||
if not exe:
|
||||
return (False, "perltidy not found on PATH (install Perl::Tidy or pass --perltidy)")
|
||||
snippet = "my $x= {a=>1,b =>2 };"
|
||||
rc, out, err = run_perltidy(snippet, cfg)
|
||||
if rc != 0:
|
||||
return (False, f"perltidy exit {rc}: {(err or '').strip()}")
|
||||
want = ["my $x = {", "a => 1", "b => 2"]
|
||||
if all(w in out for w in want):
|
||||
return (True, f"perltidy OK: {exe}")
|
||||
if out and out.strip() and out.strip() != snippet:
|
||||
return (True, f"perltidy OK (non-default style): {exe}")
|
||||
return (False, "perltidy produced unexpected output")
|
||||
|
||||
|
||||
def tidy_perl_statement_oneline(code: str, cfg: Config) -> str:
|
||||
rc, out, _ = run_perltidy(code, cfg)
|
||||
if rc != 0:
|
||||
out = code
|
||||
out = out.strip()
|
||||
out = " ".join(out.splitlines())
|
||||
out = WS_RE.sub(" ", out).strip()
|
||||
out = enforce_perl_keyword_spacing(out, cfg.perl_keyword_spacing)
|
||||
return out
|
||||
|
||||
|
||||
def tidy_perl_expression(code: str, cfg: Config) -> str:
|
||||
wrapped = f"do {{ {code} }}"
|
||||
rc, out, _ = run_perltidy(wrapped, cfg)
|
||||
if rc != 0:
|
||||
inner = code.strip()
|
||||
return enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
|
||||
text = out
|
||||
try:
|
||||
start = text.index("{")
|
||||
depth = 0
|
||||
end_idx = None
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
inner = code.strip()
|
||||
else:
|
||||
inner = text[start + 1 : end_idx]
|
||||
except ValueError:
|
||||
inner = code.strip()
|
||||
inner = " ".join(line.strip() for line in inner.splitlines())
|
||||
inner = WS_RE.sub(" ", inner).strip()
|
||||
inner = enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
|
||||
return inner
|
||||
|
||||
|
||||
def _split_code_and_strings(s: str):
|
||||
chunks = []
|
||||
buf: List[str] = []
|
||||
in_single = in_double = False
|
||||
i = 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if not in_single and not in_double:
|
||||
if ch == "'":
|
||||
if buf:
|
||||
chunks.append(("code", "".join(buf)))
|
||||
buf = []
|
||||
in_single = True
|
||||
buf.append(ch)
|
||||
elif ch == '"':
|
||||
if buf:
|
||||
chunks.append(("code", "".join(buf)))
|
||||
buf = []
|
||||
in_double = True
|
||||
buf.append(ch)
|
||||
else:
|
||||
buf.append(ch)
|
||||
elif in_single:
|
||||
buf.append(ch)
|
||||
if ch == "\\":
|
||||
if i + 1 < len(s):
|
||||
buf.append(s[i + 1]); i += 1
|
||||
elif ch == "'":
|
||||
chunks.append(("str", "".join(buf))); buf = []; in_single = False
|
||||
elif in_double:
|
||||
buf.append(ch)
|
||||
if ch == "\\":
|
||||
if i + 1 < len(s):
|
||||
buf.append(s[i + 1]); i += 1
|
||||
elif ch == '"':
|
||||
chunks.append(("str", "".join(buf))); buf = []; in_double = False
|
||||
i += 1
|
||||
if buf:
|
||||
chunks.append(("code" if not (in_single or in_double) else "str", "".join(buf)))
|
||||
return chunks
|
||||
|
||||
|
||||
def _split_unquoted_comment(code_chunk: str):
|
||||
idx = code_chunk.find("#")
|
||||
if idx == -1:
|
||||
return code_chunk, None
|
||||
return code_chunk[:idx], code_chunk[idx:]
|
||||
|
||||
|
||||
def enforce_perl_keyword_spacing(s: str, enable: bool) -> str:
|
||||
if not enable or not s:
|
||||
return s
|
||||
# Add space after control keywords before '('
|
||||
ctrl_paren = re.compile(r"\b(?P<kw>if|elsif|unless|while|until|for|foreach|given|when)\s*\(")
|
||||
# Add space after declarators before sigils/paren
|
||||
decl = re.compile(r"\b(?P<kw>my|our|state|local)\s*(?=[\$\@\%\*\&\\\(])")
|
||||
# sub name spacing and brace spacing
|
||||
sub_named = re.compile(r"\bsub\s*([A-Za-z_]\w*)")
|
||||
sub_named_brace = re.compile(r"\bsub\s+([A-Za-z_]\w*)\s*\{")
|
||||
sub_anon = re.compile(r"\bsub\s*\{")
|
||||
# Calls which often appear without space
|
||||
call_paren = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*\(")
|
||||
call_space = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*(?=\S)")
|
||||
# else/continue/do/eval blocks
|
||||
else_brace = re.compile(r"\b(?P<kw>else|continue|do|eval)\s*\{")
|
||||
# Ensure space before a brace after a closing paren: "){" -> ") {"
|
||||
brace_after_paren = re.compile(r"\)\s*\{")
|
||||
# NEW: ensure space between '}' and a following keyword: "}else" -> "} else"
|
||||
brace_then_kw = re.compile(r"\}\s*(?=\b(?:else|elsif|continue|when)\b)")
|
||||
|
||||
out: List[str] = []
|
||||
for kind, chunk in _split_code_and_strings(s):
|
||||
if kind != "code":
|
||||
out.append(chunk)
|
||||
continue
|
||||
code, comment = _split_unquoted_comment(chunk)
|
||||
# Apply transforms on code only
|
||||
code = ctrl_paren.sub(lambda m: f"{m.group('kw')} (", code)
|
||||
code = decl.sub(lambda m: f"{m.group('kw')} ", code)
|
||||
code = sub_named.sub(lambda m: f"sub {m.group(1)}", code)
|
||||
code = sub_named_brace.sub(lambda m: f"sub {m.group(1)} {{", code)
|
||||
code = sub_anon.sub("sub {", code)
|
||||
code = call_paren.sub(lambda m: f"{m.group('kw')} (", code)
|
||||
code = call_space.sub(lambda m: f"{m.group('kw')} ", code)
|
||||
code = brace_then_kw.sub("} ", code) # <- add space after closing brace before keyword
|
||||
code = else_brace.sub(lambda m: f"{m.group('kw')} {{", code)
|
||||
code = brace_after_paren.sub(") {", code)
|
||||
out.append(code + (comment or ""))
|
||||
return "".join(out)
|
||||
|
||||
def normalize_tpl_tag(
|
||||
leftchomp: Optional[str],
|
||||
kind: Optional[str],
|
||||
body: str,
|
||||
rightchomp: Optional[str],
|
||||
cfg: Config,
|
||||
) -> Tuple[str, str, str, str, str]:
|
||||
if not cfg.normalize_delimiter_spacing or (kind == "#"):
|
||||
return ("<%", leftchomp or "", kind or "", body, (rightchomp or "") + "%>")
|
||||
body = body.strip()
|
||||
left_space = " "
|
||||
right_space = " " if rightchomp == "" else ""
|
||||
open_part = "<%" + (leftchomp or "") + (kind or "") + left_space
|
||||
close_part = right_space + (rightchomp or "") + "%>"
|
||||
return (open_part, "", "", body, close_part)
|
||||
|
||||
|
||||
def substitute_tpl_tags_in_line(line: str, cfg: Config) -> str:
|
||||
parts: List[str] = []
|
||||
last = 0
|
||||
for m in TPL_TAG_RE.finditer(line):
|
||||
parts.append(line[last : m.start()])
|
||||
leftchomp = m.group("leftchomp") or ""
|
||||
kind = m.group("kind") or ""
|
||||
body = m.group("body")
|
||||
rightchomp = m.group("rightchomp") or ""
|
||||
open_part, _, _, new_body, close_part = normalize_tpl_tag(
|
||||
leftchomp, kind, body, rightchomp, cfg
|
||||
)
|
||||
if kind == "#":
|
||||
inner = body
|
||||
else:
|
||||
if kind in ("=", "=="):
|
||||
inner = tidy_perl_expression(body, cfg)
|
||||
else:
|
||||
inner = tidy_perl_statement_oneline(body, cfg)
|
||||
parts.append(open_part + inner + close_part)
|
||||
last = m.end()
|
||||
parts.append(line[last:])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def derive_html_tag_deltas(line_wo_tpl: str) -> Tuple[int, int, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Return (pre_dedent, net_total, raw_open, raw_close):
|
||||
- pre_dedent: end tags at beginning of line (dedent before printing)
|
||||
- net_total: total start tags (+1) minus end tags (-1) across the line for non-void, non-self-closing tags
|
||||
- raw_open, raw_close: raw elements opened/closed on this line if they match exactly
|
||||
"""
|
||||
s = line_wo_tpl
|
||||
|
||||
raw_open = None
|
||||
raw_close = None
|
||||
m_open = RAW_OPEN_RE.match(s)
|
||||
if m_open:
|
||||
raw_open = m_open.group("name").lower()
|
||||
m_close = RAW_CLOSE_RE.match(s)
|
||||
if m_close:
|
||||
raw_close = m_close.group("name").lower()
|
||||
|
||||
pre_dedent = 0
|
||||
i = 0
|
||||
while i < len(s) and s[i].isspace():
|
||||
i += 1
|
||||
while True:
|
||||
m = TAG_RE.match(s, i)
|
||||
if not m:
|
||||
break
|
||||
if m.group("slash"):
|
||||
pre_dedent += 1
|
||||
i = m.end()
|
||||
while i < len(s) and s[i].isspace():
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
net = 0
|
||||
for m in TAG_RE.finditer(s):
|
||||
slash = m.group("slash")
|
||||
name = (m.group("name") or "").lower()
|
||||
selfclose = bool(m.group("self"))
|
||||
if slash:
|
||||
net -= 1
|
||||
else:
|
||||
if selfclose or name in VOID_ELEMENTS:
|
||||
pass
|
||||
else:
|
||||
net += 1
|
||||
|
||||
return pre_dedent, net, raw_open, raw_close
|
||||
|
||||
|
||||
def strip_tpl_tags(line: str) -> str:
|
||||
return TPL_TAG_RE.sub(lambda m: " " * (m.end() - m.start()), line)
|
||||
|
||||
|
||||
def is_standalone_statement_tag(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not (s.startswith("<%") and s.endswith("%>")):
|
||||
return False
|
||||
if s.startswith("<%=") or s.startswith("<%=="):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_perl_deltas(line: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Return (perl_dedent_before, perl_delta_after_for_next_line).
|
||||
Only line directives (starting with %) and standalone <% ... %> statement lines
|
||||
affect Perl depth. Also account for % end / <% end %> and begin blocks.
|
||||
"""
|
||||
dedent_before = 0
|
||||
delta_after = 0
|
||||
|
||||
if END_LINE_RE.match(line) or END_TAG_ONLY_RE.match(line):
|
||||
dedent_before += 1
|
||||
|
||||
m = LEADING_RBRACE_COUNT_RE.match(line)
|
||||
if m:
|
||||
braces = m.group("braces") or ""
|
||||
dedent_before += len(braces)
|
||||
|
||||
if TAG_CLOSING_BRACE_ONLY_RE.match(line):
|
||||
dedent_before += 1
|
||||
|
||||
is_dir = bool(LINE_DIR_RE.match(line))
|
||||
is_stmt_tag_only = is_standalone_statement_tag(line)
|
||||
|
||||
if is_dir:
|
||||
body = LINE_DIR_RE.match(line).group("body")
|
||||
open_count = body.count("{")
|
||||
close_count = body.count("}")
|
||||
delta_after += (open_count - close_count)
|
||||
if BEGIN_RE.search(line):
|
||||
delta_after += 1
|
||||
elif is_stmt_tag_only:
|
||||
bodies = [m.group("body") or "" for m in TPL_TAG_RE.finditer(line)]
|
||||
open_count = sum(b.count("{") for b in bodies)
|
||||
close_count = sum(b.count("}") for b in bodies)
|
||||
delta_after += (open_count - close_count)
|
||||
if BEGIN_RE.search(line):
|
||||
delta_after += 1
|
||||
|
||||
return dedent_before, delta_after
|
||||
|
||||
|
||||
def format_line_directive(line: str, cfg: Config) -> Optional[str]:
|
||||
"""
|
||||
If the line is a Mojolicious line directive (% ...), return a formatted
|
||||
directive string WITHOUT leading indentation (indent applied separately).
|
||||
Otherwise return None.
|
||||
"""
|
||||
m = LINE_DIR_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
kind = m.group("kind") or ""
|
||||
body = m.group("body")
|
||||
|
||||
if kind == "#":
|
||||
if cfg.normalize_delimiter_spacing:
|
||||
trimmed = body.strip()
|
||||
return "%#" + ((" " + trimmed) if trimmed else "")
|
||||
else:
|
||||
return "%#" + body
|
||||
|
||||
if kind in ("=", "=="):
|
||||
inner = tidy_perl_expression(body, cfg)
|
||||
else:
|
||||
inner = tidy_perl_statement_oneline(body, cfg)
|
||||
|
||||
if cfg.normalize_delimiter_spacing:
|
||||
return "%" + kind + ((" " + inner) if inner else "")
|
||||
else:
|
||||
return "%" + kind + ((" " + inner) if inner else "")
|
||||
|
||||
|
||||
def rstrip_trailing_ws(line: str) -> str:
|
||||
return line.rstrip(" \t")
|
||||
|
||||
|
||||
def format_string(src: str, cfg: Config) -> str:
|
||||
original_eol = detect_eol(src)
|
||||
text = src.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
lines = text.split("\n")
|
||||
html_depth = 0
|
||||
perl_depth = 0
|
||||
in_raw: Optional[str] = None
|
||||
|
||||
out_lines: List[str] = []
|
||||
|
||||
for orig_line in lines:
|
||||
line = orig_line
|
||||
|
||||
if in_raw:
|
||||
m_close = RAW_CLOSE_RE.match(line)
|
||||
if m_close and m_close.group("name").lower() == in_raw:
|
||||
indent_level = max(0, html_depth - 1) + perl_depth
|
||||
indent = " " * (cfg.indent_width * indent_level)
|
||||
new_line = indent + line.lstrip()
|
||||
out_lines.append(rstrip_trailing_ws(new_line))
|
||||
html_depth = max(0, html_depth - 1)
|
||||
in_raw = None
|
||||
else:
|
||||
out_lines.append(line)
|
||||
continue
|
||||
|
||||
perl_dedent_before, perl_delta_after = compute_perl_deltas(line)
|
||||
line_wo_tpl = strip_tpl_tags(line)
|
||||
html_pre_dedent, html_net, raw_open, raw_close = derive_html_tag_deltas(line_wo_tpl)
|
||||
|
||||
base_html_depth = max(0, html_depth - html_pre_dedent)
|
||||
base_perl_depth = max(0, perl_depth - perl_dedent_before)
|
||||
indent_level = max(0, base_html_depth + base_perl_depth)
|
||||
indent = " " * (cfg.indent_width * indent_level)
|
||||
|
||||
formatted_directive = format_line_directive(line, cfg)
|
||||
if formatted_directive is not None:
|
||||
content = formatted_directive
|
||||
else:
|
||||
content = substitute_tpl_tags_in_line(line, cfg).lstrip()
|
||||
|
||||
new_line = indent + content.lstrip()
|
||||
out_lines.append(rstrip_trailing_ws(new_line))
|
||||
|
||||
html_depth = max(0, base_html_depth + html_net + html_pre_dedent)
|
||||
if raw_open and (raw_open.lower() in RAW_ELEMENTS):
|
||||
in_raw = raw_open.lower()
|
||||
perl_depth = max(0, base_perl_depth + perl_delta_after)
|
||||
|
||||
result = "\n".join(out_lines)
|
||||
if not result.endswith("\n"):
|
||||
result += "\n"
|
||||
|
||||
eol_mode = cfg.eol if cfg.eol != "preserve" else original_eol
|
||||
result = normalize_eol(result, eol_mode)
|
||||
return result
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
with path.open("rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode(errors="replace")
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
with path.open("wb") as f:
|
||||
f.write(text.encode("utf-8"))
|
||||
|
||||
|
||||
def is_supported_file(path: Path, exts: Tuple[str, ...]) -> bool:
|
||||
name = path.name.lower()
|
||||
return any(name.endswith(ext) for ext in exts)
|
||||
|
||||
|
||||
def iter_files(paths: List[str], exts: Tuple[str, ...]) -> Iterable[Path]:
|
||||
for p in paths:
|
||||
pth = Path(p)
|
||||
if pth.is_dir():
|
||||
for root, _, files in os.walk(pth):
|
||||
for fn in files:
|
||||
fp = Path(root) / fn
|
||||
if is_supported_file(fp, exts):
|
||||
logger.debug("Found file: %s", fp)
|
||||
yield fp
|
||||
else:
|
||||
if is_supported_file(pth, exts):
|
||||
logger.debug("Found file: %s", pth)
|
||||
yield pth
|
||||
|
||||
|
||||
def unified_diff(a: str, b: str, path: Path) -> str:
|
||||
a_lines = a.splitlines(keepends=True)
|
||||
b_lines = b.splitlines(keepends=True)
|
||||
return "".join(
|
||||
difflib.unified_diff(
|
||||
a_lines, b_lines, fromfile=str(path), tofile=str(path) + " (formatted)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def process_file(path: Path, cfg: Config, write: bool, show_diff: bool, backup: bool = False) -> Tuple[bool, str]:
|
||||
original = read_text(path)
|
||||
formatted = format_string(original, cfg)
|
||||
changed = original != formatted
|
||||
if changed:
|
||||
logger.info("Formatted: %s", path)
|
||||
if show_diff:
|
||||
sys.stdout.write(unified_diff(original, formatted, path))
|
||||
if write:
|
||||
if backup:
|
||||
bak_path = path.with_name(path.name + ".bak")
|
||||
write_text(bak_path, original)
|
||||
logger.info("Backup written: %s", bak_path)
|
||||
write_text(path, formatted)
|
||||
logger.info("Overwritten: %s", path)
|
||||
else:
|
||||
logger.info("Unchanged: %s", path)
|
||||
return changed, formatted
|
||||
|
||||
|
||||
def process_stdin_stdout(cfg: Config) -> int:
|
||||
data = sys.stdin.read()
|
||||
formatted = format_string(data, cfg)
|
||||
sys.stdout.write(formatted)
|
||||
logger.info("Formatted stdin to stdout")
|
||||
return 0
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Format Mojolicious templates (.ep, .htm.ep, .html.ep)")
|
||||
p.add_argument("paths", nargs="*", help="Files or directories")
|
||||
p.add_argument("-w", "--write", action="store_true", help="Overwrite files in place (writes a .bak backup)")
|
||||
p.add_argument("-o", "--out", help="Write formatted output to this file (single input file or --stdin). Conflicts with --write/--check/--diff")
|
||||
p.add_argument("--check", action="store_true", help="Exit non-zero if any file would change")
|
||||
p.add_argument("--diff", action="store_true", help="Print unified diff for changes")
|
||||
p.add_argument("--stdin", action="store_true", help="Read from stdin")
|
||||
p.add_argument("--stdout", action="store_true", help="Write to stdout (with --stdin)")
|
||||
p.add_argument("--perltidy", help="Path to perltidy executable (defaults to PATH)")
|
||||
p.add_argument("--indent", type=int, help="Indent width (spaces, default 2)")
|
||||
p.add_argument("--eol", choices=["lf", "crlf", "preserve"], default="lf", help="EOL handling (default lf)")
|
||||
p.add_argument("--no-space-in-delims", action="store_true", help="Do not normalize spaces inside <%% %%> delimiters")
|
||||
p.add_argument("--perl-keyword-spacing", action="store_true", help="Aggressively insert a space after Perl keywords (if(...)->if (...), my$->my $, return(...)->return (...), etc.)")
|
||||
p.add_argument("--self-test", dest="self_test", action="store_true", help="Run internal sanity checks and exit 0/1")
|
||||
p.add_argument("--log-level", choices=["error", "info", "debug"], help="Logging level (default error)")
|
||||
p.add_argument("--verbose", action="store_true", help="Shorthand for --log-level info")
|
||||
p.add_argument("--version", action="store_true", help="Print version and exit")
|
||||
return p
|
||||
|
||||
|
||||
def self_test(cfg: Config) -> int:
|
||||
failures: List[str] = []
|
||||
|
||||
def check(name: str, cond: bool, detail: Optional[str] = None):
|
||||
if not cond:
|
||||
failures.append(name + (": " + detail if detail else ""))
|
||||
|
||||
# T0: perltidy availability and behavior
|
||||
ok, msg = perltidy_probe(cfg)
|
||||
if not ok:
|
||||
failures.append("perltidy: " + msg)
|
||||
else:
|
||||
logger.info(msg)
|
||||
|
||||
# T1: idempotence on a mixed template
|
||||
src_a = "% if (1) {\n<ul>\n% for my $i (1..2) {\n<li><%= $i %></li>\n% }\n</ul>\n% }\n"
|
||||
fmt_a1 = format_string(src_a, cfg)
|
||||
fmt_a2 = format_string(fmt_a1, cfg)
|
||||
check("idempotence", fmt_a1 == fmt_a2)
|
||||
|
||||
# T2: chomp markers preserved
|
||||
src_b = "<li><%= $title -%>\n<%= $sub %></li>\n"
|
||||
fmt_b = format_string(src_b, cfg)
|
||||
check("chomp presence", "-%>" in fmt_b)
|
||||
check("no-left-chomp-added", "<%-" not in fmt_b)
|
||||
|
||||
# T3: raw element inner content unchanged
|
||||
src_c = "<script>\n var x=1; // keep spacing\nif(true){console.log(x)}\n</script>\n"
|
||||
fmt_c = format_string(src_c, cfg)
|
||||
c_lines = src_c.splitlines()
|
||||
f_lines = fmt_c.splitlines()
|
||||
if len(c_lines) >= 3 and len(f_lines) >= 3:
|
||||
check("raw inner unchanged", c_lines[1:-1] == f_lines[1:-1], detail=f"got {f_lines[1:-1]!r}")
|
||||
else:
|
||||
check("raw structure", False, "unexpected line count")
|
||||
|
||||
# T4: delimiter spacing normalization for <% %>
|
||||
src_d = "<%my $x=1;%>\n"
|
||||
fmt_d = format_string(src_d, cfg)
|
||||
check("delimiter spacing", "<% " in fmt_d and "%>" in fmt_d)
|
||||
|
||||
# T5: keyword spacing with flag on
|
||||
cfg_kw = dc_replace(cfg, perl_keyword_spacing=True)
|
||||
fmt_k1 = format_string("<% if($x){ %>\n", cfg_kw)
|
||||
check("kw if(...)", "if (" in fmt_k1 and " {" in fmt_k1)
|
||||
fmt_k2 = format_string("<%= return(1) %>\n", cfg_kw)
|
||||
check("kw return(...)", "return (" in fmt_k2)
|
||||
fmt_k3 = format_string('<% say"hi"; %>\n', cfg_kw)
|
||||
check("kw say \"...\"", 'say "' in fmt_k3)
|
||||
fmt_k4 = format_string("<% my($x,$y)=@_; %>\n", cfg_kw)
|
||||
check("kw my $", "my (" in fmt_k4 and " = @_" in fmt_k4)
|
||||
fmt_k5 = format_string("<% sub foo{ %>\n", cfg_kw)
|
||||
check("kw sub foo {", "sub foo {" in fmt_k5)
|
||||
|
||||
if failures:
|
||||
logger.error("SELF-TEST FAILURES:")
|
||||
for f in failures:
|
||||
logger.error(" - %s", f)
|
||||
return 1
|
||||
logger.info("Self-test passed")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
setup_logging(args.log_level, args.verbose)
|
||||
|
||||
if args.version:
|
||||
print(f"mojofmt {VERSION}")
|
||||
return 0
|
||||
|
||||
if args.self_test:
|
||||
cfg = load_config(args)
|
||||
return self_test(cfg)
|
||||
|
||||
# Validate --out usage
|
||||
if args.out:
|
||||
if args.write or args.check or args.diff:
|
||||
parser.error("--out conflicts with --write/--check/--diff")
|
||||
cfg = load_config(args)
|
||||
out_path = Path(args.out)
|
||||
if args.stdin:
|
||||
data = sys.stdin.read()
|
||||
formatted = format_string(data, cfg)
|
||||
write_text(out_path, formatted)
|
||||
logger.info("Wrote %s (from stdin)", out_path)
|
||||
return 0
|
||||
# must be exactly one input file
|
||||
if not args.paths or len(args.paths) != 1:
|
||||
parser.error("--out requires exactly one input file (or use --stdin)")
|
||||
in_path = Path(args.paths[0])
|
||||
original = read_text(in_path)
|
||||
formatted = format_string(original, cfg)
|
||||
write_text(out_path, formatted)
|
||||
logger.info("Wrote %s (from %s)", out_path, in_path)
|
||||
return 0
|
||||
|
||||
cfg = load_config(args)
|
||||
|
||||
if args.stdin:
|
||||
return process_stdin_stdout(cfg)
|
||||
|
||||
if not args.paths:
|
||||
parser.error("No input paths provided (or use --stdin).")
|
||||
|
||||
any_changed = False
|
||||
any_error = False
|
||||
|
||||
for path in iter_files(args.paths, cfg.extensions):
|
||||
try:
|
||||
changed, _ = process_file(path, cfg, write=args.write, show_diff=args.diff, backup=args.write)
|
||||
any_changed = any_changed or changed
|
||||
except Exception as e:
|
||||
any_error = True
|
||||
logger.error("Error processing %s: %s", path, e)
|
||||
|
||||
if args.check and any_changed:
|
||||
return 1
|
||||
return 1 if any_error else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
273
website.py
Normal file
273
website.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from flask import (
|
||||
Flask, request, render_template_string,
|
||||
jsonify, send_file, redirect, url_for, flash
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask_talisman import Talisman
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from functools import wraps
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env (for local dev only)
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py")
|
||||
|
||||
# Get secrets from environment variables
|
||||
SECRET_KEY = os.environ.get('FLASK_SECRET_KEY')
|
||||
API_TOKEN = os.environ.get('FLASK_API_TOKEN')
|
||||
if not SECRET_KEY or not API_TOKEN:
|
||||
raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = SECRET_KEY
|
||||
|
||||
# Secure cookies
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=True, # requires HTTPS in production
|
||||
SESSION_COOKIE_SAMESITE='Lax'
|
||||
)
|
||||
|
||||
# Security headers with Flask‑Talisman (CSP allowing only self + cdn.jsdelivr.net)
|
||||
csp = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
|
||||
'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
|
||||
}
|
||||
Talisman(app, content_security_policy=csp)
|
||||
|
||||
# Rate limiting
|
||||
limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["100/hour"])
|
||||
|
||||
# Token authentication decorator
|
||||
def require_api_token(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth = request.headers.get('Authorization', '')
|
||||
if not auth.startswith('Bearer ') or auth[len('Bearer '):] != API_TOKEN:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
# ----------------------------- HTML TEMPLATE -----------------------------
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mojolicious Template Code Formatter</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0;
|
||||
background:linear-gradient(90deg, #fdeff9 0%, #ecb6ff 100%);
|
||||
height:100vh; display:flex; flex-direction:column; }
|
||||
header { background:#8247c2; color:#fff; display:flex; align-items:center;
|
||||
justify-content:space-between; padding:1em; }
|
||||
header h1 { margin:0; font-size:1.6em; }
|
||||
.icon-links { display:flex; align-items:center; }
|
||||
.icon-links a { display:inline-flex; align-items:center; margin-left:16px; color:white; text-decoration:none; }
|
||||
.icon-links a svg { width:28px; height:28px; fill:white; }
|
||||
.flash-messages ul { margin:0; padding:0; list-style:none; color:#d91454; text-align:center; }
|
||||
form { flex:1; display:flex; flex-direction:column; margin:0; }
|
||||
.container { display:flex; flex-direction:row; gap:16px; padding:20px; flex:1;
|
||||
box-sizing:border-box; height:calc(100vh - 140px); }
|
||||
.panel { background:#fff; border-radius:10px; box-shadow:0 8px 18px -7px #ac83ce44;
|
||||
padding:22px; flex:1 1 50%; min-width:300px; display:flex; flex-direction:column; height:100%; }
|
||||
label { font-weight:bold; margin-bottom:5px; }
|
||||
.controls { display:flex; gap:8px; margin:14px 0 8px 0; }
|
||||
button { background:#a950e6; border:none; color:#fff; border-radius:5px;
|
||||
padding:9px 16px; font-size:15px; cursor:pointer; box-shadow:0 2px 7px -3px #bb76c1; }
|
||||
button:hover { background:#7634a2; }
|
||||
input[type="file"] { margin-bottom:10px; }
|
||||
textarea { width:100%; flex:1 1 auto; min-height:0; font-family:'Fira Mono', monospace;
|
||||
font-size:15px; border:2px solid #bdb0da; background:#f6eafe;
|
||||
border-radius:7px; padding:8px; color:#432d67; resize:vertical; }
|
||||
select { padding:6px 10px; border-radius:5px; border:1px solid #b993d6;
|
||||
background:#eee0f6; color:#6d378d; font-size:15px; }
|
||||
#output_block, #output_code, pre[class*="language-"], code[class*="language-"] {
|
||||
font-family:'Fira Mono', monospace !important; font-size:15px !important; line-height:1 !important;
|
||||
}
|
||||
#output_block { background:#16151a !important; color:white !important; border-radius:8px; padding:1em;
|
||||
margin-top:10px; overflow-y:auto; resize:vertical; white-space:pre-wrap;
|
||||
border:2px solid #bdb0da; flex:1 1 auto; min-height:0; }
|
||||
@media (max-width:800px) {
|
||||
.container { flex-direction:column; height:auto; }
|
||||
.panel { height:auto; min-width:0; }
|
||||
}
|
||||
</style>
|
||||
<link href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-perl.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-markup.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Mojolicious Template Code Formatter</h1>
|
||||
<div class="icon-links">
|
||||
<a href="https://github.com/brianread108" target="_blank" aria-label="GitHub">
|
||||
<svg viewBox="0 0 16 16"><path fill-rule="evenodd"
|
||||
d="M8 0C3.58 0 0 3.58 0 8a8..."/></svg>
|
||||
</a>
|
||||
<a href="https://mojolicious.org" target="_blank" aria-label="Mojolicious">
|
||||
<svg viewBox="0 0 64 64"><path d="M32 2C20 18..."/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flash-messages">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}<ul>{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<form method="post" action="/" enctype="multipart/form-data">
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<label for="input_text">Input data:</label>
|
||||
<textarea name="input_text" id="input_text">{{ input_text|default('') }}</textarea>
|
||||
<label for="input_file">Upload a file:</label>
|
||||
<input type="file" name="input_file" id="input_file" accept=".txt,.mojo,.pl,.html,.tmpl,.tt,.tt2,.template,text/plain">
|
||||
<div class="controls">
|
||||
<button type="submit" name="action" value="format">Format</button>
|
||||
<button type="submit" name="action" value="download">Download</button>
|
||||
<button type="button" onclick="clearFields()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<label>Formatted Output:</label>
|
||||
<div style="margin-top:8px;">
|
||||
<label for="syntaxmode">Output Syntax:</label>
|
||||
<select id="syntaxmode" onchange="highlightOutput()">
|
||||
<option value="none">Plain Text</option>
|
||||
<option value="perl">Perl</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
</div>
|
||||
<pre id="output_block"><code id="output_code" class="language-none">{% if formatted_text is defined %}{{ formatted_text|e }}{% endif %}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const inputTextEl = document.getElementById("input_text");
|
||||
const inputFileEl = document.getElementById("input_file");
|
||||
const outputCodeEl = document.getElementById("output_code");
|
||||
|
||||
function clearOutput() {
|
||||
outputCodeEl.textContent = '';
|
||||
Prism.highlightElement(outputCodeEl);
|
||||
}
|
||||
function clearFields() {
|
||||
inputTextEl.value = '';
|
||||
clearOutput();
|
||||
}
|
||||
window.clearFields = clearFields;
|
||||
|
||||
inputTextEl.addEventListener("input", clearOutput);
|
||||
inputFileEl.addEventListener("change", e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
inputTextEl.value = ev.target.result;
|
||||
clearOutput();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
highlightOutput();
|
||||
});
|
||||
|
||||
function highlightOutput() {
|
||||
const mode = document.getElementById("syntaxmode").value;
|
||||
const code = document.getElementById("output_code");
|
||||
code.className = "";
|
||||
if (mode === "perl") code.classList.add("language-perl");
|
||||
else if (mode === "html") code.classList.add("language-markup");
|
||||
else code.classList.add("language-none");
|
||||
Prism.highlightElement(code);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# ----------------------------- Core logic -----------------------------
|
||||
def run_mojofmt(input_text: str) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
in_path = os.path.join(tmpdir, "input.txt")
|
||||
out_path = os.path.join(tmpdir, "output.txt")
|
||||
with open(in_path, 'w', encoding='utf-8') as f:
|
||||
f.write(input_text)
|
||||
result = subprocess.run(
|
||||
['python3', MOJO_FMT_PATH, '-o', out_path, in_path],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mojofmt failed:\n{result.stderr.strip()}")
|
||||
with open(out_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
input_text, formatted_text = "", None
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
f_obj = request.files.get("input_file")
|
||||
input_text = request.form.get("input_text", "")
|
||||
if f_obj and f_obj.filename:
|
||||
try:
|
||||
input_text = f_obj.read().decode('utf-8')
|
||||
except Exception as e:
|
||||
flash(f"Error reading uploaded file: {e}")
|
||||
return render_template_string(HTML_TEMPLATE, input_text=input_text)
|
||||
|
||||
if action in ("format", "download"):
|
||||
if not input_text.strip():
|
||||
flash("No input data provided.")
|
||||
return render_template_string(HTML_TEMPLATE, input_text=input_text)
|
||||
try:
|
||||
formatted_text = run_mojofmt(input_text)
|
||||
except RuntimeError as e:
|
||||
flash(str(e))
|
||||
return render_template_string(HTML_TEMPLATE, input_text=input_text)
|
||||
if action == "download":
|
||||
tmpfile = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8', suffix='.txt')
|
||||
tmpfile.write(formatted_text)
|
||||
tmpfile.close()
|
||||
return redirect(url_for('download_file', filename=os.path.basename(tmpfile.name)))
|
||||
return render_template_string(HTML_TEMPLATE, input_text=input_text, formatted_text=formatted_text)
|
||||
|
||||
@app.route("/download/<filename>")
|
||||
def download_file(filename):
|
||||
safe_name = secure_filename(filename)
|
||||
path = os.path.join(tempfile.gettempdir(), safe_name)
|
||||
if not os.path.exists(path):
|
||||
return "File not found", 404
|
||||
resp = send_file(path, as_attachment=True, download_name="formatted_output.txt")
|
||||
try:
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
@app.route("/api/format", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
@require_api_token
|
||||
def api_format():
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
data = request.get_json()
|
||||
text = data.get("text", "")
|
||||
if not text:
|
||||
return jsonify({"error": "Missing 'text'"}), 400
|
||||
try:
|
||||
formatted = run_mojofmt(text)
|
||||
return jsonify({"formatted_text": formatted})
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True) # debug=False in prod
|
Reference in New Issue
Block a user