Check in CSS rewriter scripts
This CL adds a set of Python scripts that have been used to help
rewrite CrosAdapta.
Files:
- util.py - Helper functions to make processing/splitting up CSS more easy.
- rewriter.py - Contains a model for rewrites to CSS code as well as
tools for applying rewrites expressed in that model.
- example.py - A set of example rewrites as well as the boiler plate
for running the rewrites from the command line.
Bug: 822495
Change-Id: I8f5502ed077fbe2cf61bedb95c480e9728bdc9e7
Reviewed-on: https://chromium-review.googlesource.com/1134635
Commit-Ready: Josh Pratt <jopra@chromium.org>
Tested-by: Josh Pratt <jopra@chromium.org>
Reviewed-by: Raymes Khoury <raymes@chromium.org>
diff --git a/tools/rewriter/example.py b/tools/rewriter/example.py
new file mode 100644
index 0000000..e35877d
--- /dev/null
+++ b/tools/rewriter/example.py
@@ -0,0 +1,101 @@
+"""An example script applying rewrite rules files passed in as arguments.
+
+Usage: example.py "gtk-3.*/*.css"
+"""
+
+from __future__ import print_function
+import sys
+from rewriter import background_color
+from rewriter import border_color
+from rewriter import color
+from rewriter import Mod
+from rewriter import rewrite_files
+
+mods = [
+ Mod(r'button',
+ remove=r' *box-shadow:[^;]*;',
+ enabled=True),
+ Mod(r'button\.suggested-action',
+ anti=[r'\.destructive-action'],
+ remove=color(),
+ replace=r'\1@theme_base_color\3',
+ enabled=False),
+ Mod(r'button\.suggested-action',
+ anti=[r'\.destructive-action'],
+ remove=background_color(),
+ replace=r'\1@suggestion_color\3',
+ enabled=False),
+ Mod(r'button',
+ anti=[r'\..*\-action'],
+ remove=color(),
+ replace=r'\1@suggestion_color\3',
+ enabled=False),
+ Mod(r'button',
+ anti=[r'\..*\-action'],
+ remove=background_color(),
+ replace=r'\1@theme_base_color\3',
+ enabled=False),
+ Mod(r'menuitem.*button',
+ anti=[r'\..*\-action'],
+ remove=color(),
+ replace=r'\1@theme_text_color\3',
+ enabled=True),
+ Mod(r'text-button',
+ anti=[r'\..*\-action'],
+ remove=color(),
+ replace=r'\1@theme_text_color\3',
+ enabled=True),
+ Mod(r'menuitem.*button',
+ anti=[r'\..*\-action'],
+ remove=background_color(),
+ replace=r'\1@theme_base_color\3',
+ enabled=True),
+ Mod(r'text-button',
+ anti=[r'\..*\-action'],
+ remove=background_color(),
+ replace=r'\1@theme_base_color\3',
+ enabled=True),
+ Mod(r'button.*:disabled',
+ anti=[r'-action'],
+ remove=border_color(),
+ replace=r'\1@insensitive_bg_color\3',
+ enabled=True),
+ Mod(r'button.*:disabled',
+ anti=[r'-action'],
+ remove=border_color(),
+ replace=r'\1@insensitive_bg_color\3',
+ enabled=True),
+ Mod(r'button.*:disabled',
+ anti=[r'-action'],
+ remove=background_color(),
+ replace=r'\1@theme_base_color\3',
+ enabled=True),
+ Mod(r'button.*:disabled',
+ anti=[r'-action'],
+ remove=color(),
+ replace=r'\1@insensitive_fg_color\3',
+ enabled=True),
+
+ Mod(r'button.suggested-action.*:disabled',
+ anti=[r'\.destructive-action'],
+ remove=color(),
+ replace=r'\1@insensitive_fg_color\3',
+ enabled=True),
+ Mod(r'button.suggested-action.*:disabled',
+ anti=[r'\.destructive-action'],
+ remove=background_color(),
+ replace=r'\1@suggestion_color\3',
+ enabled=True),
+ Mod(r'.*', # Fixes rgba(@color, .*)
+ remove=r'rgba(\(@[a-z_]*), .*(, [01]\.[0-9]*\))',
+ replace=r'alpha\1\2'),
+ Mod(r'.*', # Fixes alpha(currentColor, @var.*
+ remove=r'alpha\(currentColor, (@[a-z][^\.]*)([\.0-9]*)\)',
+ replace=r'alpha(\1, 0\2)')
+]
+
+if len(sys.argv) > 1:
+ rewrite_files(sys.argv[1:], mods)
+else:
+ print('No files were passed in.')
+ exit()
diff --git a/tools/rewriter/rewriter.py b/tools/rewriter/rewriter.py
new file mode 100644
index 0000000..6e163ec
--- /dev/null
+++ b/tools/rewriter/rewriter.py
@@ -0,0 +1,154 @@
+"""Provides tools for automatically rewriting large sets of CSS."""
+
+from __future__ import print_function
+import re
+import util
+
+
+def contains(pattern, text):
+ """Check that some text contains a pattern or the pattern is none."""
+ if pattern is None:
+ return True
+ return re.search(pattern, text, re.MULTILINE)
+
+
+class Mod(object):
+ """Represents a rewrite to a CSS rule."""
+
+ def __init__(self, match, remove, replace=None, anti=None, enabled=True):
+ """Constructs a 'Mod' object.
+
+ Args:
+ match(string): A regex that the head of a CSS rule must match to be
+ rewritten.
+ remove(string): A regex for the portion of the CSS rule's body to be
+ rewriten.
+ replace(string): A regex that replace matches to 'remove' with.
+ anti(List[string]): A set of regexes that the head of the CSS rule must
+ not match.
+ enabled(bool): A flag to easily enabled/disable a Mod.
+ """
+ self.match = match
+ self.remove = remove
+ self.replace = replace or r''
+ self.anti = anti or []
+ self.enabled = enabled
+
+ def apply(self, head, body):
+ """Rewrites a CSS rule if it matches a set of patterns.
+
+ Replaces self.remove with self.replace (or nothing) in a chunk if the
+ following conditions are met:
+ - 'self.enabled' is True.
+ - The rule's selectors contain text matching the pattern self.match.
+ - The rule's selectors do not contain any of the patterns in self.anti.
+
+ Args:
+ head(string): Text containing all the selectors of a CSS rule.
+ body(string): Text containing all the properties of a CSS rule.
+ Returns:
+ A tuple containing the updated selectors and properties of the CSS rule.
+ """
+
+ if self.enabled:
+ if contains(self.match, head):
+ for anti in self.anti:
+ if contains(anti, head):
+ return head, body
+ # Apply the body change
+ body, _ = re.subn(self.remove, self.replace, body)
+ return head, body
+
+
+def process_rule(chunk, mods):
+ """Apply a set of modifications to a chunk of CSS.
+
+ Args:
+ chunk (string): a 'chunk' of CSS (generally a rule or a comment).
+ mods (List[Mod]): a list of modifications/rewrites.
+ Returns:
+ the rewritten chunk.
+ """
+ sp_chunk = chunk.split('{')
+ if len(sp_chunk) != 2:
+ return chunk # Not recognised as a 'rule'
+
+ head, body = sp_chunk[0], sp_chunk[1]
+ for mod in mods:
+ head, body = mod.apply(head, body)
+
+ # Clean up any deleted lines.
+ body = util.remove_empty_lines(body)
+
+ return head + '{' + body
+
+
+def process(content, mods):
+ """Apply a set of modifications to CSS."""
+ output = []
+ while content:
+ # Skip over whitespace.
+ whitespace, content = util.whitespace(content)
+ output.append(whitespace)
+
+ # Consume a rule or a comment at a time.
+ chunk, split, content = util.until(content, ['}', '*/'])
+ if split == '}':
+ output.append(process_rule(chunk, mods))
+ else:
+ output.append(chunk) # Leave unknown blocks unmodified.
+ output.append(split)
+ return ''.join(output)
+
+
+def color_property_ending_in(suffix):
+ """Generates regexes for color properties that have the suffix."""
+ # Selects whitespace and property name before the suffix.
+ before_suffix = r'([^;]* *'
+ # Selects the colon and most color values after the suffix and before the
+ # semicolon.
+ after_suffix = r':[^@#;0-9]*)([@#0-9][a-z_0-9]*)([^;]*;)'
+ return before_suffix + suffix + after_suffix
+
+
+def background_color():
+ return color_property_ending_in(r'background-color')
+
+
+def color():
+ return color_property_ending_in(r'color')
+
+
+def border_color():
+ return color_property_ending_in(r'border-color')
+
+
+def rewrite(path, mods):
+ """Rewrites a file with a set of modifications.
+
+ Reads a file, rewrites it in memory and then updates the source on disk.
+
+ Args:
+ path (string): a path to the file to rewrite.
+ mods (List[Mod]): a list of modifications/rewrites.
+ """
+ contents = None
+ with open(path, 'r') as cssfile:
+ contents = cssfile.read()
+
+ refactored = process(contents, mods)
+
+ with open(path, 'w') as cssfile:
+ cssfile.write(refactored)
+
+
+def rewrite_files(paths, mods):
+ """Rewrites a set of files with a set of modifications.
+
+ Args:
+ paths (List[string]): a list of paths to files to rewrite.
+ mods (List[Mod]): a list of modifications/rewrites.
+ """
+ for path in paths:
+ print('Rewriting {}'.format(path))
+ rewrite(path, mods)
diff --git a/tools/rewriter/util.py b/tools/rewriter/util.py
new file mode 100644
index 0000000..58a83ce
--- /dev/null
+++ b/tools/rewriter/util.py
@@ -0,0 +1,53 @@
+"""A set of helper functions for parsing text."""
+
+import re
+
+NEWLINE = '\n'
+WHITESPACE_RE = re.compile(r'\w+')
+
+
+def remove_empty_lines(text):
+ """Removes empty lines from text (preserving trailing whitespace)."""
+ non_empty_lines = []
+ for line in text.split(NEWLINE):
+ if not WHITESPACE_RE.match(line):
+ non_empty_lines.append(line)
+ return NEWLINE.join(non_empty_lines)
+
+
+def whitespace(text):
+ """Removes leading whitespace from a string.
+
+ Args:
+ text(string): Text to remove whitespace from.
+ Returns:
+ A tuple containing the leading whitespace and the remaining text.
+ """
+ match = WHITESPACE_RE.match(text)
+ if match:
+ return (match.group(), text[match.end():])
+ return ('', text)
+
+
+def until(text, suffixes):
+ """Splits text at the first suffix or 'end token'.
+
+ Args:
+ text(string): a string to remove prefixed whitespace from.
+ suffixes(List[string]): a list of strings that mark the end of a block.
+ Returns:
+ A tuple containing the text before the end token, the end token and any
+ remaining, unprocessed, text.
+ """
+ # Convert the search for each suffix into a single regex
+ pattern = '({})'.format(r'|'.join(map(re.escape, suffixes)))
+ match = re.search(pattern, text)
+
+ if match:
+ # If we find a suffix, split the text around it.
+ chunk = text[:match.start()]
+ suffix = match.group()
+ text = text[match.end():]
+ return chunk, suffix, text
+ # If no suffix is found, consume the whole string.
+ return text, '', ''