blob: b56ef4d63fab88446838d42bae2c371f822e03e2 [file]
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Parses C++ feature list files and feature definition files."""
import bisect
import re
from typing import List, Tuple
import cpp_lexer
import models
_ARRAY_NAME = 'kFeaturesExposedToJava'
_ARRAY_RE = re.compile(r'\b' + _ARRAY_NAME + r'\s*(?:\[[^\]]*\])?\s*=\s*\{')
_ENTRY_RE = re.compile(r'^&((?:::)?(?:\w+::)*)(k[A-Z]\w*)$')
_DIRECTIVE_RE = re.compile(
r'^[ \t]*//[ \t]*FEATURE_DEFINITION_FILE:(.*)$', re.MULTILINE
)
_DIRECTIVE_PATH_RE = re.compile(r'^//[^\s]+$')
_INCLUDE_RE = re.compile(r'^[ \t]*#[ \t]*include[ \t]*"([^"]+)"', re.MULTILINE)
_NAMESPACE_OR_BRACE_RE = re.compile(
r'\b(?P<inline>inline\s+)?namespace\b\s*(?P<name>[\w:]*)\s*\{|[{}]'
)
_JNI_GET_NATIVE_MAP_RE = re.compile(r'\bJNI_(\w+)_GetNativeMap\s*\(')
_MACRO_CALL_RE = re.compile(r'\b([A-Z][A-Z0-9_]*)\s*\(')
_K_IDENTIFIER_RE = re.compile(r'^k[A-Z]\w*$')
_STRING_LITERAL_RE = re.compile(r'^"((?:[^"\\\n]|\\.)*)"$')
class _NamespaceTracker:
"""Answers "which namespace encloses this offset?" for a structure view."""
def __init__(self, structure: str):
self._positions = [0]
self._namespaces = [()]
# Each element is a tuple of namespace components pushed by that brace.
stack = []
for m in _NAMESPACE_OR_BRACE_RE.finditer(structure):
token = m.group()
if token == '}':
if stack:
stack.pop()
elif token == '{':
stack.append(())
else:
name = m.group('name')
# Anonymous and inline namespaces don't add a lookup component.
if m.group('inline') or not name:
stack.append(())
else:
stack.append(tuple(c for c in name.split('::') if c))
self._positions.append(m.end())
self._namespaces.append(tuple(c for frame in stack for c in frame))
def at(self, index: int) -> Tuple[str, ...]:
return self._namespaces[bisect.bisect_right(self._positions, index) - 1]
class _LineNumberTracker:
"""Maps indices in a file (or any of its lex() views) to line numbers."""
def __init__(self, text: str):
self._line_starts = [0] + [m.end() for m in re.finditer('\n', text)]
def at(self, index: int) -> int:
# The number of line starts at or before `index` is its line number.
return bisect.bisect_right(self._line_starts, index)
def _iter_lines_with_offsets(text: str):
offset = 0
for line in text.split('\n'):
yield offset, line
offset += len(line) + 1
def _parse_entries(
path: str,
structure: str,
line_numbers: _LineNumberTracker,
body: Tuple[int, int],
) -> List[models.FeatureEntry]:
body_start, body_end = body
body_text = structure[body_start:body_end]
errors = []
for offset, line_text in _iter_lines_with_offsets(body_text):
if line_text.lstrip().startswith('#'):
line = line_numbers.at(body_start + offset)
errors.append(
f'{path}:{line}: error: Preprocessor directives are not '
f'supported inside {_ARRAY_NAME}, because the generated '
f'Java class is not conditional.'
)
if errors:
raise models.GeneratorError(errors)
entries = []
seen = {}
pieces = cpp_lexer.split_top_level(structure, body_start, body_end)
for idx, (start, end) in enumerate(pieces):
raw = structure[start:end]
normalized = re.sub(r'\s+', '', raw)
if not normalized:
# Only a trailing comma may produce an empty piece.
if idx != len(pieces) - 1:
line = line_numbers.at(end)
errors.append(
f'{path}:{line}: error: Empty entry in {_ARRAY_NAME}.'
)
continue
line = line_numbers.at(start + len(raw) - len(raw.lstrip()))
m = _ENTRY_RE.match(normalized)
if not m:
errors.append(
f'{path}:{line}: error: Unsupported entry "{normalized}" in '
f'{_ARRAY_NAME}. Expected the form "&namespace::kFeatureName".'
)
continue
entry = models.FeatureEntry(path, line, m.group(1), m.group(2))
if entry.written in seen:
errors.append(
f'{path}:{line}: error: Duplicate entry "{entry.written}" '
f'(first listed on line {seen[entry.written]}).'
)
continue
seen[entry.written] = line
entries.append(entry)
if errors:
raise models.GeneratorError(errors)
return entries
def _parse_directives(
path: str, text: str, line_numbers: _LineNumberTracker
) -> List[models.DefinitionDirective]:
directives = []
errors = []
for m in _DIRECTIVE_RE.finditer(text):
value = m.group(1).strip()
line = line_numbers.at(m.start())
if not _DIRECTIVE_PATH_RE.match(value):
errors.append(
f'{path}:{line}: error: FEATURE_DEFINITION_FILE must be '
f'followed by a source-absolute path such as '
f'"//path/to/file.cc", found "{value}".'
)
continue
directives.append(models.DefinitionDirective(path, line, value))
if errors:
raise models.GeneratorError(errors)
return directives
def parse_source(path: str, text: str) -> models.ParsedSource:
"""Parses a C++ file that defines kFeaturesExposedToJava."""
code, structure = cpp_lexer.lex(text)
line_numbers = _LineNumberTracker(text)
matches = list(_ARRAY_RE.finditer(structure))
if not matches:
raise models.GeneratorError(
[f'{path}: error: Cannot find the definition of {_ARRAY_NAME}.']
)
if len(matches) > 1:
lines = ', '.join(str(line_numbers.at(m.start())) for m in matches)
raise models.GeneratorError(
[
f'{path}: error: Found multiple definitions of {_ARRAY_NAME} '
f'(lines {lines}).'
]
)
array_match = matches[0]
open_brace = array_match.end() - 1
try:
close_brace = cpp_lexer.find_matching(structure, open_brace)
except ValueError as e:
line = line_numbers.at(open_brace)
raise models.GeneratorError(
[f'{path}:{line}: error: Cannot parse {_ARRAY_NAME}: {e}.']
) from e
entries = _parse_entries(
path, structure, line_numbers, (open_brace + 1, close_brace)
)
directives = _parse_directives(path, text, line_numbers)
includes = tuple(m.group(1) for m in _INCLUDE_RE.finditer(code))
_, blanked_structure = cpp_lexer.blank_preprocessor_lines(code, structure)
array_namespace = _NamespaceTracker(blanked_structure).at(
array_match.start()
)
feature_map_names = tuple(
sorted(
{
m.group(1)
for m in _JNI_GET_NATIVE_MAP_RE.finditer(blanked_structure)
}
)
)
return models.ParsedSource(
path=path,
entries=tuple(entries),
array_namespace=array_namespace,
includes=includes,
directives=tuple(directives),
feature_map_names=feature_map_names,
)
def _definition_from_macro(
path: str,
line: int,
namespace: Tuple[str, ...],
macro: str,
identifier: str,
args: List[str],
) -> models.FeatureDefinition:
derived = identifier[1:]
where = f'{path}:{line}'
if macro == 'BASE_FEATURE' or macro == 'BASE_RUNTIME_MUTABLE_FEATURE':
if len(args) == 2:
return models.FeatureDefinition(
path, line, namespace, identifier, macro, derived
)
if len(args) == 3:
m = _STRING_LITERAL_RE.match(args[1])
if m:
return models.FeatureDefinition(
path, line, namespace, identifier, macro, m.group(1)
)
return models.FeatureDefinition(
path,
line,
namespace,
identifier,
macro,
None,
f'{where}: The feature name of {identifier} must be a plain '
f'string literal, found "{args[1]}".',
)
return models.FeatureDefinition(
path,
line,
namespace,
identifier,
macro,
None,
f'{where}: {macro}({identifier}, ...) has {len(args)} '
f'arguments; expected 2 or 3.',
)
if len(args) >= 2 and _STRING_LITERAL_RE.match(args[1]):
return models.FeatureDefinition(
path,
line,
namespace,
identifier,
macro,
None,
f'{where}: Cannot determine the feature name of {identifier} from '
f'macro {macro}, because it is passed a string literal.',
)
return models.FeatureDefinition(
path, line, namespace, identifier, macro, derived
)
def parse_definitions(path: str, text: str) -> List[models.FeatureDefinition]:
"""Finds feature definitions such as BASE_FEATURE(kFoo, ...) in a file.
A definition is any ALL_CAPS macro call whose name contains FEATURE (but not
DECLARE or PARAM) and whose first argument is a kCamelCase identifier.
"""
code, structure = cpp_lexer.lex(text)
# Preprocessor lines often sit between BASE_FEATURE arguments, e.g.
# BASE_FEATURE(kFoo,
# #if BUILDFLAG(IS_ANDROID)
# base::FEATURE_ENABLED_BY_DEFAULT);
# #else
# base::FEATURE_DISABLED_BY_DEFAULT);
# #endif
code, structure = cpp_lexer.blank_preprocessor_lines(code, structure)
namespaces = _NamespaceTracker(structure)
line_numbers = _LineNumberTracker(text)
definitions = []
for m in _MACRO_CALL_RE.finditer(structure):
macro = m.group(1)
if 'FEATURE' not in macro or 'DECLARE' in macro or 'PARAM' in macro:
continue
open_paren = m.end() - 1
try:
close_paren = cpp_lexer.find_matching(structure, open_paren)
except ValueError:
continue
ranges = cpp_lexer.split_top_level(
structure, open_paren + 1, close_paren
)
args = [code[s:e].strip() for s, e in ranges]
if not args or not _K_IDENTIFIER_RE.match(args[0]):
continue
line = line_numbers.at(m.start())
definitions.append(
_definition_from_macro(
path, line, namespaces.at(m.start()), macro, args[0], args
)
)
return definitions