| # Lint as: python3 |
| # Copyright 2023 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Helper script to use GN's JSON interface to make changes. |
| |
| AST implementation details: |
| https://gn.googlesource.com/gn/+/refs/heads/main/src/gn/parse_tree.cc |
| |
| To dump an AST: |
| gn format --dump-tree=json BUILD.gn > foo.json |
| """ |
| |
| from __future__ import annotations |
| |
| import dataclasses |
| import functools |
| import json |
| import subprocess |
| from typing import Callable, Dict, List, Optional, Tuple, TypeVar |
| |
| NODE_CHILD = 'child' |
| NODE_TYPE = 'type' |
| NODE_VALUE = 'value' |
| |
| _T = TypeVar('_T') |
| |
| |
| def _create_location_node(begin_line=1): |
| return { |
| 'begin_column': 1, |
| 'begin_line': begin_line, |
| 'end_column': 2, |
| 'end_line': begin_line, |
| } |
| |
| |
| def _wrap(node: dict): |
| kind = node[NODE_TYPE] |
| if kind == 'LIST': |
| return StringList(node) |
| if kind == 'BLOCK': |
| return BlockWrapper(node) |
| return NodeWrapper(node) |
| |
| |
| def _unwrap(thing): |
| if isinstance(thing, NodeWrapper): |
| return thing.node |
| return thing |
| |
| |
| def _find_node(root_node: dict, target_node: dict): |
| def recurse(node: dict) -> Optional[Tuple[dict, int]]: |
| children = node.get(NODE_CHILD) |
| if children: |
| for i, child in enumerate(children): |
| if child is target_node: |
| return node, i |
| ret = recurse(child) |
| if ret is not None: |
| return ret |
| return None |
| |
| ret = recurse(root_node) |
| if ret is None: |
| raise Exception( |
| f'Node not found: {target_node}\nLooked in: {root_node}') |
| return ret |
| |
| @dataclasses.dataclass |
| class NodeWrapper: |
| """Base class for all wrappers.""" |
| node: dict |
| |
| @property |
| def node_type(self) -> str: |
| return self.node[NODE_TYPE] |
| |
| @property |
| def node_value(self) -> str: |
| return self.node[NODE_VALUE] |
| |
| @property |
| def node_children(self) -> List[dict]: |
| return self.node[NODE_CHILD] |
| |
| @functools.cached_property |
| def first_child(self): |
| return _wrap(self.node_children[0]) |
| |
| @functools.cached_property |
| def second_child(self): |
| return _wrap(self.node_children[1]) |
| |
| def is_list(self): |
| return self.node_type == 'LIST' |
| |
| def is_identifier(self): |
| return self.node_type == 'IDENTIFIER' |
| |
| def visit_nodes(self, callback: Callable[[dict], |
| Optional[_T]]) -> List[_T]: |
| ret = [] |
| |
| def recurse(root: dict): |
| value = callback(root) |
| if value is not None: |
| ret.append(value) |
| return |
| children = root.get(NODE_CHILD) |
| if children: |
| for child in children: |
| recurse(child) |
| |
| recurse(self.node) |
| return ret |
| |
| def set_location_recursive(self, line): |
| def helper(n: dict): |
| loc = n.get('location') |
| if loc: |
| loc['begin_line'] = line |
| loc['end_line'] = line |
| |
| self.visit_nodes(helper) |
| |
| def add_child(self, node, *, before=None): |
| node = _unwrap(node) |
| if before is None: |
| self.node_children.append(node) |
| else: |
| before = _unwrap(before) |
| parent_node, child_idx = _find_node(self.node, before) |
| parent_node[NODE_CHILD].insert(child_idx, node) |
| |
| # Prevent blank lines between |before| and |node|. |
| target_line = before['location']['begin_line'] |
| _wrap(node).set_location_recursive(target_line) |
| |
| def remove_child(self, node): |
| node = _unwrap(node) |
| parent_node, child_idx = _find_node(self.node, node) |
| parent_node[NODE_CHILD].pop(child_idx) |
| |
| |
| @dataclasses.dataclass |
| class BlockWrapper(NodeWrapper): |
| """Wraps a BLOCK node.""" |
| def __post_init__(self): |
| assert self.node_type == 'BLOCK' |
| |
| def find_assignments(self, var_name=None): |
| def match_fn(node: dict): |
| assignment = AssignmentWrapper.from_node(node) |
| if not assignment: |
| return None |
| if var_name is None or var_name == assignment.variable_name: |
| return assignment |
| return None |
| |
| return self.visit_nodes(match_fn) |
| |
| |
| @dataclasses.dataclass |
| class AssignmentWrapper(NodeWrapper): |
| """Wraps a =, +=, or -= BINARY node where the LHS is an identifier.""" |
| def __post_init__(self): |
| assert self.node_type == 'BINARY' |
| |
| @property |
| def variable_name(self): |
| return self.first_child.node_value |
| |
| @property |
| def value(self): |
| return self.second_child |
| |
| @property |
| def list_value(self): |
| ret = self.second_child |
| assert isinstance(ret, StringList), 'Found: ' + ret.node_type |
| return ret |
| |
| @property |
| def operation(self): |
| """The assignment operation. Either "=" or "+=".""" |
| return self.node_value |
| |
| @property |
| def is_append(self): |
| return self.operation == '+=' |
| |
| def value_as_string_list(self): |
| return StringList(self.value.node) |
| |
| @staticmethod |
| def from_node(node: dict) -> Optional[AssignmentWrapper]: |
| if node.get(NODE_TYPE) != 'BINARY': |
| return None |
| children = node[NODE_CHILD] |
| assert len(children) == 2, ( |
| 'Binary nodes should have two child nodes, but the node is: ' |
| f'{node}') |
| left_child, right_child = children |
| if left_child.get(NODE_TYPE) != 'IDENTIFIER': |
| return None |
| if node.get(NODE_VALUE) not in ('=', '+=', '-='): |
| return None |
| return AssignmentWrapper(node) |
| |
| @staticmethod |
| def create(variable_name, value, operation='='): |
| value_node = _unwrap(value) |
| id_node = { |
| 'location': _create_location_node(), |
| 'type': 'IDENTIFIER', |
| 'value': variable_name, |
| } |
| return AssignmentWrapper({ |
| 'location': _create_location_node(), |
| 'child': [id_node, value_node], |
| 'type': 'BINARY', |
| 'value': operation, |
| }) |
| |
| @staticmethod |
| def create_list(variable_name, operation='='): |
| return AssignmentWrapper.create(variable_name, |
| StringList.create(), |
| operation=operation) |
| |
| |
| @dataclasses.dataclass |
| class StringList(NodeWrapper): |
| """Wraps a list node that contains only string literals.""" |
| def __post_init__(self): |
| assert self.is_list() |
| |
| self.literals: List[str] = [ |
| x[NODE_VALUE].strip('"') for x in self.node_children |
| if x[NODE_TYPE] == 'LITERAL' |
| ] |
| |
| def add_literal(self, value: str): |
| # For lists of deps, gn format will sort entries, but it will not |
| # move entries past comment boundaries. Insert at the front by default |
| # so that if sorting moves the value, and there is a comment boundary, |
| # it will end up before the comment instead of immediately after the |
| # comment (which likely does not apply to it). |
| self.literals.insert(0, value) |
| self.node_children.insert( |
| 0, { |
| 'location': _create_location_node(), |
| 'type': 'LITERAL', |
| 'value': f'"{value}"', |
| }) |
| |
| def remove_literal(self, value: str): |
| self.literals.remove(value) |
| quoted = f'"{value}"' |
| children = self.node_children |
| for i, node in enumerate(children): |
| if node[NODE_VALUE] == quoted: |
| children.pop(i) |
| break |
| else: |
| raise ValueError(f'Did not find child with value {quoted}') |
| |
| @staticmethod |
| def create() -> StringList: |
| return StringList({ |
| 'location': _create_location_node(), |
| 'begin_token': '[', |
| 'child': [], |
| 'end': { |
| 'location': _create_location_node(), |
| 'type': 'END', |
| 'value': ']' |
| }, |
| 'type': 'LIST', |
| }) |
| |
| |
| class Target(NodeWrapper): |
| """Wraps a target node. |
| |
| A target node is any function besides "template" with exactly two children: |
| * Child 1: LIST with single string literal child |
| * Child 2: BLOCK |
| |
| This does not actually find all targets. E.g. ignores those that use an |
| expression for a name, or that use "target(type, name)". |
| """ |
| def __init__(self, function_node: dict, name_node: dict): |
| super().__init__(function_node) |
| self.name_node = name_node |
| |
| @property |
| def name(self) -> str: |
| return self.name_node[NODE_VALUE].strip('"') |
| |
| # E.g. "android_library" |
| @property |
| def type(self) -> str: |
| return self.node[NODE_VALUE] |
| |
| @property |
| def block(self) -> BlockWrapper: |
| block = self.second_child |
| assert isinstance(block, BlockWrapper) |
| return block |
| |
| def set_name(self, value): |
| self.name_node[NODE_VALUE] = f'"{value}"' |
| |
| @staticmethod |
| def from_node(node: dict) -> Optional[Target]: |
| """Returns a Target if |node| is a target, None otherwise.""" |
| if node.get(NODE_TYPE) != 'FUNCTION': |
| return None |
| if node.get(NODE_VALUE) == 'template': |
| return None |
| children = node.get(NODE_CHILD) |
| if not children or len(children) != 2: |
| return None |
| func_params_node, block_node = children |
| if block_node.get(NODE_TYPE) != 'BLOCK': |
| return None |
| if func_params_node.get(NODE_TYPE) != 'LIST': |
| return None |
| param_nodes = func_params_node.get(NODE_CHILD) |
| if param_nodes is None or len(param_nodes) != 1: |
| return None |
| name_node = param_nodes[0] |
| if name_node.get(NODE_TYPE) != 'LITERAL': |
| return None |
| return Target(function_node=node, name_node=name_node) |
| |
| |
| class BuildFile: |
| """Represents the contents of a BUILD.gn file.""" |
| def __init__(self, path: str, root_node: dict): |
| self.block = BlockWrapper(root_node) |
| self.path = path |
| self._original_content = json.dumps(root_node) |
| |
| def write_changes(self) -> bool: |
| """Returns whether there were any changes.""" |
| new_content = json.dumps(self.block.node) |
| if new_content == self._original_content: |
| return False |
| output = subprocess.check_output( |
| ['gn', 'format', '--read-tree=json', self.path], |
| text=True, |
| input=new_content) |
| if 'Wrote rebuilt from json to' not in output: |
| raise Exception('JSON was invalid') |
| return True |
| |
| @functools.cached_property |
| def targets(self) -> List[Target]: |
| return self.block.visit_nodes(Target.from_node) |
| |
| @functools.cached_property |
| def targets_by_name(self) -> Dict[str, Target]: |
| return {t.name: t for t in self.targets} |
| |
| @staticmethod |
| def from_file(path): |
| output = subprocess.check_output( |
| ['gn', 'format', '--dump-tree=json', path], text=True) |
| return BuildFile(path, json.loads(output)) |