blob: 6fe7cc06d5c3a61c4d0597fb57cf07005891e689 [file]
#!/usr/bin/env python3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script to format Android resource XML files.
It enforces:
- Indentation of 2 spaces for children.
- Continuation indent of 4 spaces for attributes on new lines.
- Attribute ordering from tools/android/android_studio/ChromiumStyle.xml.
- Self-closing tags for empty elements.
- Collapsing of multiple empty lines between elements to a single one.
"""
import argparse
import difflib
import os
import sys
from xml.dom import minidom
INDENT_SIZE = 2
CONTINUATION_INDENT_SIZE = 4
MAX_LINE_LENGTH = 100
def sort_key(item):
name, value = item
if name == 'xmlns:android':
return (0, name)
if name.startswith('xmlns:'):
return (1, name)
if name == 'android:id':
return (2, name)
if name == 'android:name':
return (3, name)
if name == 'name':
return (4, name)
if name == 'style':
return (5, name)
if ':' not in name: # No namespace
return (6, name)
if name.startswith('android:') and name.endswith('Style'):
return (7, name)
if name == 'android:width':
return (8, name)
if name == 'android:height':
return (9, name)
if name == 'android:layout_width':
return (10, name)
if name == 'android:layout_height':
return (11, name)
if name == 'android:layout_weight':
return (12, name)
if name == 'android:layout_margin':
return (13, name)
if name == 'android:layout_marginTop':
return (14, name)
if name == 'android:layout_marginBottom':
return (15, name)
if name == 'android:layout_marginStart':
return (16, name)
if name == 'android:layout_marginEnd':
return (17, name)
if name == 'android:layout_marginLeft':
return (18, name)
if name == 'android:layout_marginRight':
return (19, name)
if name.startswith('android:layout_'):
return (20, name)
if name == 'android:padding':
return (21, name)
if name == 'android:paddingTop':
return (22, name)
if name == 'android:paddingBottom':
return (23, name)
if name == 'android:paddingStart':
return (24, name)
if name == 'android:paddingEnd':
return (25, name)
if name == 'android:paddingLeft':
return (26, name)
if name == 'android:paddingRight':
return (27, name)
if name.startswith('android:'):
return (28, name)
if name.startswith('app:'):
return (29, name)
if name.startswith('tools:'):
return (30, name)
return (31, name)
def sort_attributes(attrs):
"""Sorts attributes according to ChromiumStyle.xml rules."""
if not attrs:
return []
items = list(attrs.items())
items.sort(key=sort_key)
return items
def format_node(node, indent=0, indent_str=' ' * INDENT_SIZE):
"""Recursively formats an XML node.
Args:
node: The minidom node to format.
indent: The current indentation level.
indent_str: The string used for one level of indentation.
"""
if node.nodeType == minidom.Node.DOCUMENT_NODE:
parts = [
format_node(child, indent, indent_str) for child in node.childNodes
]
return ''.join(parts)
ret = ''
if node.nodeType == minidom.Node.ELEMENT_NODE:
tag = node.tagName
attrs = sort_attributes(node.attributes)
real_children = [
c for c in node.childNodes
if c.nodeType != minidom.Node.TEXT_NODE or c.nodeValue.strip()
]
# Try single-line formatting first
attr_strs = [f'{name}="{value}"' for name, value in attrs]
attr_str = ' ' + ' '.join(attr_strs) if attr_strs else ''
if not real_children:
single_line = f"{indent_str * indent}<{tag}{attr_str} />\n"
if len(single_line) <= MAX_LINE_LENGTH:
return single_line
elif len(real_children
) == 1 and real_children[0].nodeType == minidom.Node.TEXT_NODE:
text = real_children[0].nodeValue.strip()
single_line = f"{indent_str * indent}<{tag}{attr_str}>{text}</{tag}>\n"
if len(single_line) <= MAX_LINE_LENGTH:
return single_line
# Fallback to multi-line if it doesn't fit or has complex children
ret = ''
attr_strs = [f'{name}="{value}"' for name, value in attrs]
attr_str = ' ' + ' '.join(attr_strs) if attr_strs else ''
single_line_open = f"{indent_str * indent}<{tag}{attr_str}"
if len(single_line_open) <= MAX_LINE_LENGTH or len(attrs) <= 1:
ret += single_line_open
else:
continuation_indent = ' ' * (indent * len(indent_str) +
CONTINUATION_INDENT_SIZE)
attr_strs_wrapped = [
f'\n{continuation_indent}{name}="{value}"' for name, value in attrs
]
ret += f"{indent_str * indent}<{tag}{''.join(attr_strs_wrapped)}"
if real_children:
parts = ['>\n']
for child in node.childNodes:
is_text_node = child.nodeType == minidom.Node.TEXT_NODE
if is_text_node and not child.nodeValue.strip():
if child.nodeValue.count('\n') >= 2:
parts.append('\n')
continue
parts.append(format_node(child, indent + 1, indent_str))
parts.append(f"{indent_str * indent}</{tag}>\n")
return ret + ''.join(parts)
else:
ret += ' />\n'
return ret
if node.nodeType == minidom.Node.TEXT_NODE:
text = node.nodeValue.strip()
if text:
return f"{indent_str * indent}{text}\n"
return ''
if node.nodeType == minidom.Node.COMMENT_NODE:
return f"{indent_str * indent}<!--{node.nodeValue}-->\n"
return ''
def format_xml(content):
"""Parses and formats XML content."""
dom = minidom.parseString(content)
formatted = format_node(dom)
if content.startswith('<?xml'):
end_idx = content.find('?>')
if end_idx != -1:
xml_declaration = content[:end_idx + 2] + '\n'
return xml_declaration + formatted
return formatted
def print_diff(original, formatted, filename):
"""Prints a unified diff between original and formatted text.
Returns:
2 if differences were found, 0 otherwise.
"""
diff = list(
difflib.unified_diff(original.splitlines(keepends=True),
formatted.splitlines(keepends=True),
fromfile=f'a/{filename}',
tofile=f'b/{filename}'))
if not diff:
return 0
sys.stdout.writelines(diff)
return 2
def process_file(file_path, check=False, stdout=False, diff=False):
"""Processes a single XML file."""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
formatted = format_xml(content)
if check:
return content == formatted
if diff:
return print_diff(content, formatted, file_path) == 0
if stdout:
sys.stdout.write(formatted)
return True
with open(file_path, 'w', encoding='utf-8') as f:
f.write(formatted)
print(f"Formatted {file_path}")
return True
def main():
"""Main function to handle CLI arguments and run the formatter."""
parser = argparse.ArgumentParser(
description='Format Android resource XML files.')
parser.add_argument('paths',
nargs='+',
help='Paths to XML files or directories to format.')
parser.add_argument(
'--check',
action='store_true',
help='Check if files are formatted without modifying them.')
parser.add_argument('--diff',
action='store_true',
help='Print diff to stdout rather than modifying files.')
parser.add_argument('--stdout',
action='store_true',
help='Print formatted content to stdout instead of file.')
args = parser.parse_args()
success = True
invalid_path_found = False
for path in args.paths:
if os.path.isdir(path):
for root, _, files in os.walk(path):
for file in files:
if file.endswith('.xml'):
file_path = os.path.join(root, file)
if not process_file(file_path, args.check, args.stdout, args.diff):
success = False
if args.check or args.diff:
print(f"Requires formatting: {file_path}")
elif os.path.isfile(path):
if not process_file(path, args.check, args.stdout, args.diff):
success = False
if args.check or args.diff:
print(f"Requires formatting: {path}")
else:
print(f"Error: {path} is not a valid file or directory", file=sys.stderr)
invalid_path_found = True
if invalid_path_found:
sys.exit(1)
if (args.check or args.diff) and not success:
sys.exit(2)
if __name__ == '__main__':
main()