blob: 72f90c8be7fd6003e7c91fcf265a4ff19412faee [file]
# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Presubmit script for ios.
See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
for more details about the presubmit API built into depot_tools.
"""
import os
import subprocess
import xml.etree.ElementTree as ElementTree
NULLABILITY_PATTERN = r'(nonnull|nullable|_Nullable|_Nonnull)'
TODO_PATTERN = r'TO[D]O\(([^\)]*)\)'
BUG_PATTERN = r'^(crbug\.com|b)/\d+$'
DEPRECATED_BUG_PATTERN = r'^b/\d+$'
INCLUDE_PATTERN = r'^#include'
PIPE_IN_COMMENT_PATTERN = r'//.*[^|]\|(?!\|)'
IOS_PACKAGE_PATTERN = r'^ios'
BOXED_BOOL_PATTERN = r'@\((YES|NO)\)'
USER_DEFAULTS_PATTERN = r'\[NSUserDefaults standardUserDefaults]'
UMBRELLA_HEADER_PATTERN = r'#import\s+<([\w]+)\/(?!\1\.h)[^>]+>'
UNITTEST_FILE_PATTERN = r'_unittests?\.(mm|cc)$'
SYSTEM_COLORS_PATTERN = (
r'\[UIColor (?!white|black|clear)[a-z][a-zA-Z]*Color\]|'
r'UIColor\.(?!white|black|clear)[a-z][a-zA-Z]*Color')
# Color management constants
COLOR_SHARED_DIR = 'ios/chrome/common/ui/colors/'
COLOR_FILE_PATTERN = '.colorset/Contents.json'
def FormatMessageWithFiles(message, errors):
"""Helper to format warning/error messages with affected files."""
if not errors:
return message
return '\n'.join([message + '\n\nAffected file(s):'] + errors) + '\n'
def IsSubListOf(needle, hay):
"""Returns whether there is a slice of |hay| equal to |needle|."""
for i, line in enumerate(hay):
if line == needle[0]:
if needle == hay[i:i + len(needle)]:
return True
return False
def _CheckNullabilityAnnotations(input_api, output_api):
""" Checks whether there are nullability annotations in ios code.
They are accepted in ios/web_view/public since it tries to mimic
the platform library but not anywhere else.
"""
nullability_regex = input_api.re.compile(NULLABILITY_PATTERN)
errors = []
for f in input_api.AffectedFiles():
if (f.LocalPath().startswith('ios/web_view/public/') or
f.LocalPath().startswith('ios/third_party/')):
# ios/web_view/public tries to mimic an existing API that
# might have nullability in it and that is acceptable.
continue
for line_num, line in f.ChangedContents():
if nullability_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
plural_suffix = '' if len(errors) == 1 else 's'
warning_message = ('Found Nullability annotation%(plural)s. '
'Prefer DCHECKs in ios code to check for nullness:' % {
'plural': plural_suffix
})
return [output_api.PresubmitPromptWarning(warning_message, items=errors)]
def _CheckBugInToDo(input_api, output_api):
""" Checks whether TODOs in ios code are identified by a bug number."""
errors = []
warnings = []
for f in input_api.AffectedFiles():
for line_num, line in f.ChangedContents():
if _HasToDoWithNoBug(input_api, line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if _HasToDoWithDeprecatedBug(input_api, line):
warnings.append('%s:%s' % (f.LocalPath(), line_num))
if not errors and not warnings:
return []
output = []
if errors:
singular_article = 'a ' if len(errors) == 1 else ''
plural_suffix = '' if len(errors) == 1 else 's'
error_message = '\n'.join([
'Found TO'
'DO%(plural)s without %(a)sbug number%(plural)s (expected format '
'is \"TO'
'DO(crbug.com/######)\"):' % {
'plural': plural_suffix,
'a' : singular_article
}
] + errors) + '\n'
output.append(output_api.PresubmitError(error_message))
if warnings:
singular_article = 'a ' if len(warnings) == 1 else ''
plural_suffix = '' if len(warnings) == 1 else 's'
warning_message = '\n'.join([
'Found TO'
'DO%(plural)s with %(a)sdeprecated bug link%(plural)s (found '
'"b/#####\", expected format is \"crbug.com/######"):' % {
'plural': plural_suffix,
'a' : singular_article
}
] + warnings) + '\n'
output.append(output_api.PresubmitPromptWarning(warning_message))
return output
def _CheckHasNoIncludeDirectives(input_api, output_api):
""" Checks that #include preprocessor directives are not present."""
errors = []
for f in input_api.AffectedFiles():
if not _IsInIosPackage(input_api, f.LocalPath()):
continue
_, ext = os.path.splitext(f.LocalPath())
if ext != '.mm':
continue
for line_num, line in f.ChangedContents():
if _HasIncludeDirective(input_api, line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
singular_plural = 'it' if len(errors) == 1 else 'them'
plural_suffix = '' if len(errors) == 1 else 's'
error_message = '\n'.join([
'Found usage of `#include` preprocessor directive%(plural)s! Please, '
'replace %(singular_plural)s with `#import` preprocessor '
'directive%(plural)s instead. '
'Consider replacing all existing `#include` with `#import` (if any) in '
'this file for the code clean up. See '
'https://chromium.googlesource.com/chromium/src.git/+/refs/heads/main'
'/styleguide/objective-c/objective-c.md'
'#import-and-include-in-the-directory for more details. '
'\n\nAffected file%(plural)s:' % {
'plural': plural_suffix,
'singular_plural': singular_plural
}
] + errors) + '\n'
return [output_api.PresubmitError(error_message)]
def _CheckHasNoPipeInComment(input_api, output_api):
""" Checks that comments don't contain pipes."""
pipe_regex = input_api.re.compile(PIPE_IN_COMMENT_PATTERN)
errors = []
for f in input_api.AffectedFiles():
if not _IsInIosPackage(input_api, f.LocalPath()):
continue
for line_num, line in f.ChangedContents():
if pipe_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
warning_message = '\n'.join([
'Please use backticks "`" instead of pipes "|" if you need to quote'
' variable names and symbols in comments.\n'
'Found potential uses of pipes in:'
] + errors) + '\n'
return [output_api.PresubmitPromptWarning(warning_message)]
def _CheckCanImproveTestUsingExpectNSEQ(input_api, output_api):
""" Checks that test files use EXPECT_NSEQ when possible."""
errors = []
# Substrings that should not be used together with EXPECT_TRUE or
# EXPECT_FALSE in tests.
wrong_patterns = ["isEqualToString:", "isEqualToData:", "isEqualToArray:"]
for f in input_api.AffectedFiles():
if not '_unittest.' in f.LocalPath():
continue
for line_num, line in f.ChangedContents():
if line.startswith(("EXPECT_TRUE", "EXPECT_FALSE")):
# Condition is in one line.
if any(x in line for x in wrong_patterns):
errors.append('%s:%s' % (f.LocalPath(), line_num))
# Condition is split on multiple lines.
elif not line.endswith(";"):
# Check this is not the last line.
if line_num < len(f.NewContents()):
next_line = f.NewContents()[line_num]
if any(x in next_line for x in wrong_patterns):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
plural_suffix = '' if len(errors) == 1 else 's'
warning_message = '\n'.join([
'Found possible improvement in unittest. Prefer using'
' EXPECT_NSEQ() or EXPECT_NSNE() when possible.'
'\n\nAffected file%(plural)s:' % {
'plural': plural_suffix,
}
] + errors) + '\n'
return [output_api.PresubmitPromptWarning(warning_message)]
def _IsInIosPackage(input_api, path):
""" Returns True if path is within ios package"""
ios_package_regex = input_api.re.compile(IOS_PACKAGE_PATTERN)
return ios_package_regex.search(path)
def _HasIncludeDirective(input_api, line):
""" Returns True if #include is found in the line"""
include_regex = input_api.re.compile(INCLUDE_PATTERN)
return include_regex.search(line)
def _HasToDoWithNoBug(input_api, line):
""" Returns True if TODO is not identified by a bug number."""
todo_regex = input_api.re.compile(TODO_PATTERN)
bug_regex = input_api.re.compile(BUG_PATTERN)
todo_match = todo_regex.search(line)
if not todo_match:
return False
return not bug_regex.match(todo_match.group(1))
def _HasToDoWithDeprecatedBug(input_api, line):
""" Returns True if TODO is identified by a deprecated bug number format."""
todo_regex = input_api.re.compile(TODO_PATTERN)
deprecated_bug_regex = input_api.re.compile(DEPRECATED_BUG_PATTERN)
todo_match = todo_regex.search(line)
if not todo_match:
return False
return deprecated_bug_regex.match(todo_match.group(1))
def _CheckHasNoBoxedBOOL(input_api, output_api):
""" Checks that there are no @(YES) or @(NO)."""
boxed_BOOL_regex = input_api.re.compile(BOXED_BOOL_PATTERN)
errors = []
for f in input_api.AffectedFiles():
for line_num, line in f.ChangedContents():
if boxed_BOOL_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
plural_suffix = '' if len(errors) == 1 else 's'
warning_message = ('Found boxed BOOL%(plural)s. '
'Prefer @YES or @NO in ios code:' % {
'plural': plural_suffix
})
return [output_api.PresubmitPromptWarning(warning_message, items=errors)]
def _CheckNoTearDownEGTest(input_api, output_api):
""" Checks that `- (void)tearDown {` is not present in an egtest.mm"""
errors = []
for f in input_api.AffectedFiles():
if not '_egtest.' in f.LocalPath():
continue
for line_num, line in f.ChangedContents():
if line.startswith("- (void)tearDown {"):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
warning_message = '\n'.join([
'To support hermetic EarlGrey test cases, tearDown has been renamed '
'to tearDownHelper, and will soon be removed. If tearDown is really '
'necessary for this test, please use addTeardownBlock'
] + errors) + '\n'
return [output_api.PresubmitError(warning_message)]
def _IsAlphabeticallySortedXML(file):
"""Check that the `file` is alphabetically sorted"""
parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder(
insert_comments=True))
with open(file, 'r', encoding='utf8') as xml_file:
tree = ElementTree.parse(xml_file, parser)
root = tree.getroot()
original_tree_string = ElementTree.tostring(root, encoding='utf8')
messages_element = tree.findall('.//messages')[0]
messages = messages_element.findall('message')
messages.sort(key=lambda message: message.attrib["name"])
for message in messages:
messages_element.remove(message)
for message in messages:
messages_element.append(message)
ordered_tree_string = ElementTree.tostring(root, encoding='utf8')
return ordered_tree_string == original_tree_string
def _CheckOrderedStringFile(input_api, output_api):
""" Checks that the string files are alphabetically ordered"""
errors = []
for f in input_api.AffectedFiles(include_deletes=False):
if not f.LocalPath().endswith("_strings.grd"):
continue
if not _IsAlphabeticallySortedXML(f.AbsoluteLocalPath()):
errors.append(' python3 ios/tools/order_string_file.py ' +
f.LocalPath())
if not errors:
return []
warning_message = '\n'.join(
['Files not alphabetically sorted, try running:'] + errors) + '\n'
return [output_api.PresubmitPromptWarning(warning_message)]
def _CheckOrderedFlagsFile(input_api, output_api):
""" Checks that the flag description files are alphabetically ordered"""
# The order_flags.py script calls `gclient root` which is not available
# on Windows CI bots, causing all CLs that touch
# ios_chrome_flag_descriptions.{h,cc} to fail presubmit on Windows.
if input_api.platform == 'win32':
return []
h_file = None
for f in input_api.AffectedFiles(include_deletes=False):
if f.LocalPath().endswith('ios_chrome_flag_descriptions.h'):
h_file = f.LocalPath()
if h_file:
try:
command = [
input_api.python3_executable, 'tools/order_flags.py', '--check'
]
if h_file:
command.extend(['--h-file', h_file])
subprocess.check_output(command, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
message = 'Flag description files not alphabetically sorted.\n'
message += e.output.decode('utf-8')
message += '\nPlease run: python3 ios/tools/order_flags.py'
return [output_api.PresubmitError(message)]
return []
def _CheckNotUsingNSUserDefaults(input_api, output_api):
""" Checks the added code to limit new usage of NSUserDefaults """
user_defaults_regex = input_api.re.compile(USER_DEFAULTS_PATTERN)
errors = []
for f in input_api.AffectedFiles():
if (not f.LocalPath().endswith('.mm')):
continue
for line_num, line in f.ChangedContents():
if user_defaults_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
warning_message = '\n'.join([
'A new use of NSUserDefaults was added. If this is a newly added key '
'consider storing it to PrefService instead.'
] + errors) + '\n'
return [output_api.PresubmitPromptWarning(warning_message)]
def _CheckNewColorIntroduction(input_api, output_api):
"""Checks for new or modified colorset files.
Ensures colors are properly added to the shared directory.
"""
results = []
affected_files = [
f for f in input_api.AffectedFiles()
if f.LocalPath().endswith(COLOR_FILE_PATTERN)
]
warnings = {
'shared_added': [],
'shared_modified': [],
'other_modified': []
}
errors = []
for affected_file in affected_files:
action = affected_file.Action()
local_path = affected_file.LocalPath()
file_path_error = '%s' % (affected_file.LocalPath())
if COLOR_SHARED_DIR in local_path:
if action == 'A':
warnings['shared_added'].append(file_path_error)
elif action == 'M':
warnings['shared_modified'].append(file_path_error)
else:
if action == 'A':
errors.append(file_path_error)
elif action == 'M':
warnings['other_modified'].append(file_path_error)
output = []
if errors:
error_message = ('New color(s) must be added to the %s directory.' %
COLOR_SHARED_DIR)
output.append(
output_api.PresubmitError(
FormatMessageWithFiles(error_message, errors)))
warning_message = ('Please ensure the color does not already exist in the '
'shared %s directory.' % COLOR_SHARED_DIR)
if warnings['shared_added']:
shared_added_message = ('New color(s) added in %s. %s' %
(COLOR_SHARED_DIR, warning_message))
output.append(
output_api.PresubmitPromptWarning(
FormatMessageWithFiles(shared_added_message,
warnings['shared_added'])))
if warnings['shared_modified']:
shared_modified_message = ('Color(s) modified in %s. %s' %
(COLOR_SHARED_DIR, warning_message))
output.append(
output_api.PresubmitPromptWarning(
FormatMessageWithFiles(shared_modified_message,
warnings['shared_modified'])))
if warnings['other_modified']:
modified_message = ('Color(s) modified. %s' % warning_message)
output.append(
output_api.PresubmitPromptWarning(
FormatMessageWithFiles(modified_message,
warnings['other_modified'])))
return output
def _CheckStyleESLint(input_api, output_api):
results = []
try:
import sys
old_sys_path = sys.path[:]
cwd = input_api.PresubmitLocalPath()
sys.path += [input_api.os_path.join(cwd, '..', 'tools')]
from web_dev_style import presubmit_support
results += presubmit_support.CheckStyleESLint(input_api, output_api)
finally:
sys.path = old_sys_path
return results
def _CheckUIGraphicsBeginImageContextWithOptions(input_api, output_api):
""" Checks that UIGraphicsBeginImageContext and
UIGraphicsBeginImageContextWithOptions are not used"""
deprecated_regex = input_api.re.compile(
r'UIGraphicsBeginImageContext(WithOptions)?\(')
errors = []
for f in input_api.AffectedFiles():
if (not f.LocalPath().endswith('.mm')):
continue
for line_num, line in f.ChangedContents():
if deprecated_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
error_message = '\n'.join([
'UIGraphicsBeginImageContext and '
'UIGraphicsBeginImageContextWithOptions are deprecated, use '
'UIGraphicsImageRenderer instead.'
] + errors) + '\n'
return [output_api.PresubmitError(error_message)]
def _CheckOmniboxTextInEgtest(input_api, output_api):
"""Checks use of OmniboxText or chrome_test_util::OmniboxText in egtests.
"""
pattern = input_api.re.compile(r'(OmniboxText)')
errors = []
for f in input_api.AffectedFiles():
if not f.LocalPath().endswith('_egtest.mm'):
continue
for line_num, line in f.ChangedContents():
if pattern.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
warning_message = '\n'.join([
'Please use [ChromeEarlGrey waitForWebStateVisibleURL:] to check for '
'URL load in the browser'
] + errors) + '\n'
return [output_api.PresubmitPromptWarning(warning_message)]
def _CheckUmbrellaHeaderUsage(input_api, output_api):
"""Checks for individual system header imports instead of umbrella headers.
"""
umbrella_regex = input_api.re.compile(UMBRELLA_HEADER_PATTERN)
errors = []
file_filter = lambda f: f.LocalPath().endswith(('.mm', '.h'))
for f in input_api.AffectedSourceFiles(file_filter):
for line_num, line in f.ChangedContents():
match = umbrella_regex.search(line)
if match:
framework_name = match.group(1)
errors.append('%s:%s (Found: %s. Use <%s/%s.h> instead)' %
(f.LocalPath(), line_num, line.strip(),
framework_name, framework_name))
if not errors:
return []
warning_message = (
'Always use Umbrella Headers for system frameworks. Importing '
'individual headers increases compilation time and breaks module '
'optimization.'
)
return [
output_api.PresubmitPromptWarning(warning_message, items=errors)
]
def _CheckNoFlakyUnitTest(input_api, output_api):
""" Checks that there are no tests with FLAKY_ prefix."""
flaky_specifier_pattern = input_api.re.compile(r'\bFLAKY_')
unittest_regex = input_api.re.compile(UNITTEST_FILE_PATTERN)
errors = []
for f in input_api.AffectedFiles():
if not unittest_regex.search(f.LocalPath()):
continue
for line_num, line in f.ChangedContents():
if line.lstrip().startswith('//'):
continue
if flaky_specifier_pattern.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
error_message = '\n'.join([
'Unit tests must not be marked as FLAKY_. If a test is flaky, use '
'DISABLED_ instead.'
] + errors) + '\n'
return [output_api.PresubmitError(error_message)]
def _CheckUsageOfSystemColors(input_api, output_api):
""" Checks whether there are forbidden system color used in ios code.
Only white, black and clear colors are accepted.
"""
system_colors_regex = input_api.re.compile(SYSTEM_COLORS_PATTERN)
errors = []
file_filter = lambda f: f.LocalPath().endswith(('.mm', '.h'))
for f in input_api.AffectedSourceFiles(file_filter):
for line_num, line in f.ChangedContents():
if system_colors_regex.search(line):
errors.append('%s:%s' % (f.LocalPath(), line_num))
if not errors:
return []
warning_message = 'Found forbidden usage of system colors. '
'Only white, black and clear colors are accepted.'
'Prefer semantic colors in ios code instead of the system ones:'
return [output_api.PresubmitPromptWarning(warning_message, items=errors)]
def _CheckLargeImagesets(input_api, output_api):
"""Checks that no large (> 100pt) PDF or SVG files are added to
.imageset directories.
"""
import re
def get_svg_dimensions(file_contents):
try:
import xml.etree.ElementTree as ET
if isinstance(file_contents, bytes):
text = file_contents.decode('utf-8', errors='ignore')
else:
text = file_contents
root = ET.fromstring(text)
w_str = root.attrib.get('width', '')
h_str = root.attrib.get('height', '')
viewbox_str = (root.attrib.get('viewBox', '') or
root.attrib.get('viewbox', ''))
def parse_val(v):
if not v:
return 0.0
m = re.match(r'^([\d.]+)', str(v).strip())
return float(m.group(1)) if m else 0.0
w, h = parse_val(w_str), parse_val(h_str)
if (w == 0.0 or h == 0.0) and viewbox_str:
parts = re.split(r'[\s,]+', viewbox_str.strip())
if len(parts) >= 4:
w = parse_val(parts[2])
h = parse_val(parts[3])
return w, h
except Exception:
return 0.0, 0.0
def get_pdf_dimensions(file_contents):
try:
if isinstance(file_contents, str):
file_bytes = file_contents.encode('latin-1', errors='ignore')
else:
file_bytes = file_contents
# The PDF spec specifies that MediaBox can have whitespace or
# newlines.
pattern = re.compile(
rb'/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+'
rb'(-?[\d.]+)\s+(-?[\d.]+)\s*\]')
match = pattern.search(file_bytes)
if match:
llx = float(match.group(1))
lly = float(match.group(2))
urx = float(match.group(3))
ury = float(match.group(4))
return abs(urx - llx), abs(ury - lly)
except Exception:
pass
return 0.0, 0.0
errors = []
for f in input_api.AffectedFiles(include_deletes=False):
if f.Action() != 'A':
continue
local_path = f.LocalPath()
lower_path = local_path.lower()
if '.imageset/' not in lower_path:
continue
if lower_path.endswith('.svg'):
is_svg = True
elif lower_path.endswith('.pdf'):
is_svg = False
else:
continue
try:
with open(f.AbsoluteLocalPath(), 'rb') as fp:
file_contents = fp.read()
except (IOError, OSError):
file_contents = input_api.ReadFile(
f.AbsoluteLocalPath(), mode='rb')
if is_svg:
w, h = get_svg_dimensions(file_contents)
else:
w, h = get_pdf_dimensions(file_contents)
if w > 100.0 or h > 100.0:
errors.append('%s (%.1fx%.1f pt)' % (local_path, w, h))
if not errors:
return []
warning_message = (
'Large vector assets (> 100pt) found in .imageset directories.\n'
'Apple actool rasterizes these at compile time into uncompressed\n'
'32-bit ARGB bitmaps, which severely bloats the Assets.car binary\n'
'size. Please use @2x and @3x PNGs instead.\n'
'Files:\n'
)
return [output_api.PresubmitError(warning_message, items=errors)]
def CheckChange(input_api, output_api):
results = []
results.extend(_CheckBugInToDo(input_api, output_api))
results.extend(_CheckNullabilityAnnotations(input_api, output_api))
results.extend(_CheckHasNoIncludeDirectives(input_api, output_api))
results.extend(_CheckHasNoPipeInComment(input_api, output_api))
results.extend(_CheckHasNoBoxedBOOL(input_api, output_api))
results.extend(_CheckNoTearDownEGTest(input_api, output_api))
results.extend(_CheckCanImproveTestUsingExpectNSEQ(input_api, output_api))
results.extend(_CheckOrderedStringFile(input_api, output_api))
results.extend(_CheckOrderedFlagsFile(input_api, output_api))
results.extend(_CheckNotUsingNSUserDefaults(input_api, output_api))
results.extend(_CheckNewColorIntroduction(input_api, output_api))
results.extend(_CheckStyleESLint(input_api, output_api))
results.extend(
_CheckUIGraphicsBeginImageContextWithOptions(input_api, output_api))
results.extend(_CheckOmniboxTextInEgtest(input_api, output_api))
results.extend(_CheckUmbrellaHeaderUsage(input_api, output_api))
results.extend(_CheckNoFlakyUnitTest(input_api, output_api))
results.extend(_CheckUsageOfSystemColors(input_api, output_api))
results.extend(_CheckLargeImagesets(input_api, output_api))
return results
def CheckChangeOnUpload(input_api, output_api):
return CheckChange(input_api, output_api)
def CheckChangeOnCommit(input_api, output_api):
return CheckChange(input_api, output_api)