"""Code specific to disabling GTest tests."""
import os
import re
import subprocess
import sys
from typing import List, Optional, Tuple, Union, Any, Dict, TypeVar
import conditions
from conditions import Condition
import collections
import errors
A = TypeVar('A')
B = TypeVar('B')
CHROMIUM_SRC = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", ".."))
CLANG_FORMAT = os.path.join(CHROMIUM_SRC, 'third_party', 'depot_tools',
'clang_format.py')
TEST_MACROS = {
'TEST',
'TEST_F',
'TYPED_TEST',
'IN_PROC_BROWSER_TEST',
'IN_PROC_BROWSER_TEST_F',
}
def disabler(full_test_name: str, source_file: str, new_cond: Condition,
message: Optional[str]) -> str:
"""Disable a GTest test within the given file.
Args:
test_name: The name of the test, in the form TestSuite.TestName
lines: The existing file, split into lines. Note that each line ends with a
newline character.
new_cond: The additional conditions under which to disable the test. These
will be merged with any existing conditions.
Returns:
The new contents to write into the file, with the test disabled.
"""
lines = source_file.split('\n')
test_name = full_test_name.split('.')[1]
disabled = 'DISABLED_' + test_name
maybe = 'MAYBE_' + test_name
current_name = None
src_range = None
for i in range(len(lines) - 1, -1, -1):
line = lines[i]
idents = find_identifiers(line)
if maybe in idents:
existing_cond, src_range = find_conditions(lines, i, test_name)
current_name = maybe
break
if disabled in idents:
existing_cond = conditions.ALWAYS
current_name = disabled
break
if test_name in idents:
existing_cond = conditions.NEVER
current_name = test_name
break
else:
raise Exception(f"Couldn't find test definition for {full_test_name}")
test_name_index = i
merged = conditions.merge(existing_cond, new_cond)
comment = None
if message:
comment = f'// {message}'
modified_lines = []
def insert_lines(start_index, end_index, new_lines):
nonlocal lines
nonlocal modified_lines
prev_len = len(lines)
lines[start_index:end_index] = new_lines
len_diff = len(lines) - prev_len
i = 0
while i < len(modified_lines):
line_no = modified_lines[i]
if line_no >= start_index:
if line_no < end_index:
modified_lines.pop(i)
continue
modified_lines[i] += len_diff
i += 1
modified_lines += list(range(start_index, start_index + len(new_lines)))
def insert_line(index, new_line):
insert_lines(index, index, [new_line])
def replace_line(index, new_line):
insert_lines(index, index + 1, [new_line])
def delete_lines(start_index, end_index):
insert_lines(start_index, end_index, [])
if isinstance(merged, conditions.BaseCondition):
if merged == conditions.ALWAYS:
replacement_name = disabled
elif merged == conditions.NEVER:
replacement_name = test_name
replace_line(test_name_index,
lines[test_name_index].replace(current_name, replacement_name))
if src_range:
delete_lines(src_range[0], src_range[1] + 1)
if comment:
insert_line(test_name_index, comment)
return clang_format('\n'.join(lines), modified_lines)
replace_line(test_name_index,
lines[test_name_index].replace(current_name, maybe))
condition_impl = cc_format_condition(merged)
condition_block = [
f'#if {condition_impl}',
f'#define {maybe} {disabled}',
'#else',
f'#define {maybe} {test_name}',
'#endif',
]
if src_range:
insert_lines(src_range[0], src_range[1] + 1, condition_block)
comment_index = src_range[0]
else:
for i in range(test_name_index, -1, -1):
if any(test_macro in lines[i] for test_macro in TEST_MACROS):
break
else:
raise Exception("Couldn't find where to insert test conditions")
insert_lines(i, i, condition_block)
comment_index = i
if comment:
insert_line(comment_index, comment)
necessary_includes = {
include
for var in conditions.find_terminals(merged)
if (include := var.gtest_info.header) is not None
}
to_insert: Dict[str, int] = {}
last_include = None
if len(necessary_includes) > 0:
prev_path = ''
i = 0
while i < len(lines):
match = get_directive(lines, i)
i += 1
if match is None:
continue
name, args = match
if name != 'include':
continue
last_include = i
path = args[0].strip('<>"')
try:
necessary_includes.remove(path)
except KeyError:
pass
to_insert.pop(path, None)
for include in necessary_includes:
if prev_path < include < path:
to_insert[include] = i
necessary_includes.remove(include)
prev_path = include
i -= 1
break
if last_include is None:
last_include = 0
for include in necessary_includes:
assert last_include is not None
to_insert[include] = last_include
for path, i in sorted(to_insert.items(), key=lambda x: x[1], reverse=True):
insert_line(i, f'#include "{path}"')
return clang_format('\n'.join(lines), modified_lines)
def find_identifiers(line: str) -> List[str]:
line = re.sub('//.*$', '', line)
line = re.sub(r'"[^"]*[^\\]"', '', line)
return re.findall('[a-zA-Z_][a-zA-Z_0-9]*', line)
def find_conditions(lines: List[str], start_line: int, test_name: str):
"""Starting from a given line, find the conditions relating to this test.
We step backwards until we find a preprocessor conditional block which defines
the MAYBE_Foo macro for this test. The logic is fairly rigid - there are many
ways in which test disabling could be expressed that we don't handle. We rely
on the fact that there is a common convention that people stick to very
consistently.
The format we recognise looks like:
#if <some preprocessor condition>
#define MAYBE_TEST DISABLED_Test
#else
#define MAYBE_Test Test
#endif
We also allow for the branches to be swapped, i.e. for the false branch to
define the disabled case. We don't handle anything else (e.g. nested #ifs,
indirection through other macro definitions, wrapping the whole test, etc.).
Args:
lines: The lines of the file, in which to search.
start_line: The starting point of the search. This should be the line at
which the MAYBE_Foo macro is used to define the test.
test_name: The name of the test we're searching for. This is only the test
name, it doesn't include the suite name.
"""
disabled_test = 'DISABLED_' + test_name
maybe_test = 'MAYBE_' + test_name
start = None
found_define = False
in_disabled_branch = False
most_recent_endif = None
disabled_on_true = None
for i in range(start_line, 0, -1):
match = get_directive(lines, i)
if not match:
continue
name, args = match
if name == 'endif':
most_recent_endif = i
elif name == 'define' and args[0] == maybe_test:
if most_recent_endif is None:
raise Exception(
f'{maybe_test} is defined outside of a preprocessor conditional')
found_define = True
if args[1] == disabled_test:
in_disabled_branch = True
elif name == 'else' and in_disabled_branch:
disabled_on_true = False
in_disabled_branch = False
elif name in {'if', 'ifdef'} and found_define:
if in_disabled_branch:
disabled_on_true = True
existing_conds = args[0]
start = i
break
assert start is not None
assert most_recent_endif is not None
if not disabled_on_true:
existing_conds = ('not', existing_conds)
return canonicalise(existing_conds), (start, most_recent_endif)
def get_directive(lines: List[str], i: int) -> Optional[Tuple[str, Any]]:
"""Scans for a preprocessor directive at the given line.
We don't just pass the single line at lines[i], as the line might end with a
backslash and hence continue over to the next line.
Args:
lines: The lines of the file to look for directives.
i: The point at which to look from
Returns:
None if this lines doesn't contain a preprocessor directive.
If it does, a tuple of (directive_name, [args])
The args are parsed into an AST.
"""
full_line = lines[i]
while full_line.endswith('\\'):
i += 1
full_line = full_line[:-2] + lines[i]
full_line = re.sub(r'/\*.*\*/', '', full_line)
full_line = re.sub('//.*$', '', full_line)
match = re.match('^[ \t]*#[ \t]*(\\w*)(.*)', full_line)
if not match:
return None
directive = match.group(1)
tokens = re.findall('"[^"]*"|<[^>]*>|\\w+|\\d+|&&|\|\||[,()!]',
match.group(2))
tokens.reverse()
args = []
while tokens:
args.append(parse_arg(tokens))
return (directive, args)
def parse_arg(tokens: List[str]) -> Union[Tuple, str]:
"""Parser for binary operators."""
term = parse_terminal(tokens)
if peek(tokens) in {'&&', '||'}:
return (tokens.pop(), [term, parse_arg(tokens)])
return term
def parse_terminal(tokens: List[str]) -> Union[Tuple, str]:
"""Parser for everything else."""
tok = tokens.pop()
if is_ident(tok) and peek(tokens) == '(':
ident = tok
tokens.pop()
args = []
while (next_tok := peek(tokens)) != ')':
if next_tok is None:
raise Exception('End of input while parsing preprocessor macro')
if next_tok == ',':
tokens.pop()
else:
args.append(parse_arg(tokens))
tokens.pop()
return (ident, args)
if tok == '(':
arg = parse_arg(tokens)
if peek(tokens) != ')':
raise Exception('Expected closing bracket')
tokens.pop()
return arg
if tok == '!':
arg = parse_arg(tokens)
return (tok, arg)
return tok
def peek(tokens: List[str]) -> Optional[str]:
"""Return the next token without consuming it, if tokens is non-empty."""
if tokens:
return tokens[-1]
return None
def is_ident(s: str) -> bool:
"""Checks if s is a valid identifier.
This doesn't handle the full intricacies of the spec.
"""
return all(c.isalnum() or c == '_' for c in s)
GTestInfo = collections.namedtuple('GTestInfo', ['type', 'name', 'header'])
MACRO_TYPE = object()
BUILDFLAG_TYPE = object()
for t_name, t_repr in [
('android', GTestInfo(BUILDFLAG_TYPE, 'IS_ANDROID',
'build/build_config.h')),
('chromeos', GTestInfo(BUILDFLAG_TYPE, 'IS_CHROMEOS',
'build/build_config.h')),
('fuchsia', GTestInfo(BUILDFLAG_TYPE, 'IS_FUCHSIA',
'build/build_config.h')),
('ios', GTestInfo(BUILDFLAG_TYPE, 'IS_IOS', 'build/build_config.h')),
('linux', GTestInfo(BUILDFLAG_TYPE, 'IS_LINUX', 'build/build_config.h')),
('mac', GTestInfo(BUILDFLAG_TYPE, 'IS_MAC', 'build/build_config.h')),
('win', GTestInfo(BUILDFLAG_TYPE, 'IS_WIN', 'build/build_config.h')),
('arm64', GTestInfo(MACRO_TYPE, 'ARCH_CPU_ARM64', 'build/build_config.h')),
('x86', GTestInfo(MACRO_TYPE, 'ARCH_CPU_X86', 'build/build_config.h')),
('x86-64', GTestInfo(MACRO_TYPE, 'ARCH_CPU_X86_64',
'build/build_config.h')),
('asan', GTestInfo(MACRO_TYPE, 'ADDRESS_SANITIZER', None)),
('msan', GTestInfo(MACRO_TYPE, 'MEMORY_SANITIZER', None)),
('tsan', GTestInfo(MACRO_TYPE, 'THREAD_SANITIZER', None)),
]:
conditions.get_term(t_name).gtest_info = t_repr
def canonicalise(parsed_condition) -> Condition:
"""Make a Condition from a raw preprocessor AST.
Take the raw form of the condition we've parsed from the file and convert it
into its canonical form, replacing any domain-specific stuff with its generic
form.
"""
if not isinstance(parsed_condition, tuple):
return parsed_condition
op, args = parsed_condition
if op == '!':
return ('not', canonicalise(args))
if (logical_fn := {'&&': 'and', '||': 'or'}.get(op, None)) is not None:
return (logical_fn, [canonicalise(arg) for arg in args])
assert len(args) == 1
term = next((t for t in conditions.TERMINALS if t.gtest_info.name == args[0]),
None)
if term is None:
raise Exception(f"Couldn't find any terminal corresponding to {args[0]}")
if op == 'defined':
assert term.gtest_info.type == MACRO_TYPE
elif op == 'BUILDFLAG':
assert term.gtest_info.type == BUILDFLAG_TYPE
else:
raise Exception(f"Don't know what to do with expr {parsed_condition}")
return term
def cc_format_condition(cond: Condition, add_brackets=False) -> str:
"""The reverse of canonicalise - produce a C++ expression for a Condition."""
def bracket(s: str) -> str:
return f"({s})" if add_brackets else s
assert cond != conditions.ALWAYS
assert cond != conditions.NEVER
if isinstance(cond, conditions.Terminal):
value = cond.gtest_info.name
if cond.gtest_info.type == MACRO_TYPE:
return f'defined({value})'
if cond.gtest_info.type == BUILDFLAG_TYPE:
return f'BUILDFLAG({value})'
raise Exception(f"Don't know how to express condition '{cond}' in C++")
assert isinstance(cond, tuple)
op, args = cond
if op == 'not':
return f'!({cc_format_condition(args)})'
if op == 'and':
return bracket(' && '.join(cc_format_condition(arg, True) for arg in args))
if op == 'or':
return bracket(' || '.join(cc_format_condition(arg, True) for arg in args))
raise Exception(f'Unknown op "{op}"')
def clang_format(file_contents: str, modified_lines: List[int]) -> str:
modified_lines = [i + 1 for i in modified_lines]
p = subprocess.Popen(['python3', CLANG_FORMAT, '--style=file'] +
[f'--lines={i}:{i}' for i in modified_lines],
cwd=CHROMIUM_SRC,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True)
stdout, stderr = p.communicate(file_contents)
if p.returncode != 0:
raise errors.InternalError(f'clang-format failed with: {stderr}')
return stdout