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, '', ''