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