| # 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) |