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