chromium/tools/android/modularization/convenience/build_gn_editor.py

# Lint as: python3
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
r'''Helper code to handle editing BUILD.gn files.'''

from __future__ import annotations

import difflib
import pathlib
import re
import subprocess
from typing import List, Optional, Tuple


def _find_block(source: str, start: int, open_delim: str,
                close_delim: str) -> Tuple[int, int]:
  open_delim_pos = source[start:].find(open_delim)
  if open_delim_pos < 0:
    return (-1, -1)

  baseline = start + open_delim_pos
  delim_count = 1
  for i, char in enumerate(source[baseline + 1:]):
    if char == open_delim:
      delim_count += 1
      continue
    if char == close_delim:
      delim_count -= 1
      if delim_count == 0:
        return (baseline, baseline + i + 1)
  return (baseline, -1)


def _find_line_end(source: str, start: int) -> int:
  pos = source[start:].find('\n')
  if pos < 0:
    return -1
  return start + pos


class BuildFileUpdateError(Exception):
  """Represents an error updating the build file."""

  def __init__(self, message: str):
    super().__init__()
    self._message = message

  def __str__(self):
    return self._message


class VariableContentList(object):
  """Contains the elements of a list assigned to a variable in a gn target.

  Example:
  target_type("target_name") {
    foo = [
      "a",
      "b",
      "c",
    ]
  }

  This class represents the elements "a", "b", "c" for foo.
  """

  def __init__(self):
    self._elements = []

  def parse_from(self, content: str) -> bool:
    """Parses list elements from content and returns True on success.

    The expected list format must be a valid gn list. i.e.
    1. []
    2. [ "foo" ]
    3. [
         "foo",
         "bar",
         ...
       ]
    """
    start = content.find('[')
    if start < 0:
      return False

    end = start + content[start:].find(']')
    if end <= start:
      return False

    bracketless_content = content[start + 1:end].strip()
    if not bracketless_content:
      return True

    whitespace = re.compile(r'^\s+', re.MULTILINE)
    comma = re.compile(r',$', re.MULTILINE)
    self._elements = list(
        dict.fromkeys(
            re.sub(comma, '', re.sub(whitespace, '',
                                     bracketless_content)).split('\n')))
    return True

  def get_elements(self) -> List[str]:
    return self._elements

  def add_elements(self, elements: List[str]) -> None:
    """Appends unique elements to the existing list."""
    if not self._elements:
      self._elements = list(dict.fromkeys(elements))
      return

    all_elements = list(self._elements)
    all_elements.extend(elements)
    self._elements = list(dict.fromkeys(all_elements))

  def add_list(self, other: VariableContentList) -> None:
    """Appends unique elements to the existing list."""
    self.add_elements(other.get_elements())

  def serialize(self) -> str:
    if not self._elements:
      return '[]\n'
    return '[\n' + ',\n'.join(self._elements) + ',\n]'


class TargetVariable:
  """Contains the name of a variable and its contents in a gn target.

  Example:
  target_type("target_name") {
    variable_name = variable_content
  }

  This class represents the variable_name and variable_content.
  """

  def __init__(self, name: str, content: str):
    self._name = name
    self._content = content

  def get_name(self) -> str:
    return self._name

  def get_content(self) -> str:
    return self._content

  def get_content_as_list(self) -> Optional[VariableContentList]:
    """Returns the variable's content if it can be represented as a list."""
    content_list = VariableContentList()
    if content_list.parse_from(self._content):
      return content_list
    return None

  def is_list(self) -> bool:
    """Returns whether the variable's content is represented as a list."""
    return self.get_content_as_list() is not None

  def set_content_from_list(self, content_list: VariableContentList) -> None:
    self._content = content_list.serialize()

  def set_content(self, content: str) -> None:
    self._content = content

  def serialize(self) -> str:
    return f'\n{self._name} = {self._content}\n'


class BuildTarget:
  """Contains the target name, type and content of a gn target.

  Example:
  target_type("target_name") {
    <content>
  }

  This class represents target_type, target_name and arbitrary content.

  Specific variables are accessible via this class by name although only the
  basic 'foo = "bar"' and
  'foo = [
    "bar",
    "baz",
  ]'
  formats are supported, not more complex things like += or conditionals.
  """

  def __init__(self, target_type: str, target_name: str, content: str):
    self._target_type = target_type
    self._target_name = target_name
    self._content = content

  def get_name(self) -> str:
    return self._target_name

  def get_type(self) -> str:
    return self._target_type

  def get_variable(self, variable_name: str) -> Optional[TargetVariable]:
    pattern = re.compile(fr'^\s*{variable_name} = ', re.MULTILINE)
    match = pattern.search(self._content)
    if not match:
      return None

    start = match.end() - 1
    end = start
    if self._content[match.end()] == '[':
      start, end = _find_block(self._content, start, '[', ']')
    else:
      end = _find_line_end(self._content, start)

    if end <= start:
      return None

    return TargetVariable(variable_name, self._content[start:end + 1])

  def add_variable(self, variable: TargetVariable) -> None:
    """Adds the variable to the end of the content.

    Warning: this does not check for prior existence."""
    self._content += variable.serialize()

  def replace_variable(self, variable: TargetVariable) -> None:
    """Replaces an existing variable and returns True on success."""
    pattern = re.compile(fr'^\s*{variable.get_name()} =', re.MULTILINE)
    match = pattern.search(self._content)
    if not match:
      raise BuildFileUpdateError(
          f'{self._target_type}("{self._target_name}") variable '
          f'{variable.get_name()} not found. Unable to replace.')

    start = match.end()
    if variable.is_list():
      start, end = _find_block(self._content, start, '[', ']')
    else:
      end = _find_line_end(self._content, start)

    if end <= match.start():
      raise BuildFileUpdateError(
          f'{self._target_type}("{self._target_name}") variable '
          f'{variable.get_name()} invalid. Unable to replace.')

    self._content = (self._content[:match.start()] + variable.serialize() +
                     self._content[end + 1:])

  def serialize(self) -> str:
    return (f'\n{self._target_type}("{self._target_name}") {{\n' +
            f'{self._content}\n}}\n')


class BuildFile:
  """Represents the contents of a BUILD.gn file.

  This supports modifying or adding targets to the file at a basic level.
  """

  def __init__(self, build_gn_path: pathlib.Path):
    self._path = build_gn_path
    with open(self._path, 'r') as build_gn_file:
      self._content = build_gn_file.read()

  def get_target_names_of_type(self, target_type: str) -> List[str]:
    """Lists all targets in the build file of target_type."""
    pattern = re.compile(fr'^\s*{target_type}\(\"(\w+)\"\)', re.MULTILINE)
    return pattern.findall(self._content)

  def get_target(self, target_type: str,
                 target_name: str) -> Optional[BuildTarget]:
    pattern = re.compile(fr'^\s*{target_type}\(\"{target_name}\"\)',
                         re.MULTILINE)
    match = pattern.search(self._content)
    if not match:
      return None

    start, end = _find_block(self._content, match.end(), '{', '}')
    if end <= start:
      return None

    return BuildTarget(target_type, target_name, self._content[start + 1:end])

  def get_path(self) -> pathlib.Path:
    return self._path

  def get_content(self) -> str:
    return self._content

  def get_diff(self) -> str:
    with open(self._path, 'r') as build_gn_file:
      disk_content = build_gn_file.read()
    return ''.join(
        difflib.unified_diff(disk_content.splitlines(keepends=True),
                             self._content.splitlines(keepends=True),
                             fromfile=f'{self._path}',
                             tofile=f'{self._path}'))

  def add_target(self, target: BuildTarget) -> None:
    """Adds the target to the end of the content.

    Warning: this does not check for prior existence."""
    self._content += target.serialize()

  def replace_target(self, target: BuildTarget) -> None:
    """Replaces an existing target and returns True on success."""
    pattern = re.compile(fr'^\s*{target.get_type()}\(\"{target.get_name()}\"\)',
                         re.MULTILINE)
    match = pattern.search(self._content)
    if not match:
      raise BuildFileUpdateError(
          f'{target.get_type()}("{target.get_name()}") not found. '
          'Unable to replace.')

    start, end = _find_block(self._content, match.end(), '{', '}')
    if end <= start:
      raise BuildFileUpdateError(
          f'{target.get_type()}("{target.get_name()}") invalid. '
          'Unable to replace.')

    self._content = (self._content[:match.start()] + target.serialize() +
                     self._content[end + 1:])

  def format_content(self) -> None:
    process = subprocess.Popen(['gn', 'format', '--stdin'],
                               stdout=subprocess.PIPE,
                               stdin=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    stdout_data, stderr_data = process.communicate(input=self._content.encode())
    if process.returncode:
      raise BuildFileUpdateError(
          'Formatting failed. There was likely an error in the changes '
          '(this program cannot handle complex BUILD.gn files).\n'
          f'stderr: {stderr_data.decode()}')
    self._content = stdout_data.decode()

  def write_content_to_file(self) -> None:
    with open(self._path, 'w+') as build_gn_file:
      build_gn_file.write(self._content)