# Copyright 2014 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
class StrictEnumValueChecker(object):
"""Verify that changes to enums are valid.
This class is used to check enums where reordering or deletion is not allowed,
and additions must be at the end of the enum, just prior to some "boundary"
entry. See comments at the top of the "extension_function_histogram_value.h"
file in chrome/browser/extensions for an example what are considered valid
changes. There are situations where this class gives false positive warnings,
i.e. it warns even though the edit is legitimate. Since the class warns using
prompt warnings, the user can always choose to continue. The main point is to
attract the attention to all (potentially or not) invalid edits.
"""
def __init__(self, input_api, output_api, start_marker, end_marker, path):
self.input_api = input_api
self.output_api = output_api
self.start_marker = start_marker
self.end_marker = end_marker
self.path = path
self.results = []
class EnumRange(object):
"""Represents a range of line numbers (1-based)"""
def __init__(self, first_line, last_line):
self.first_line = first_line
self.last_line = last_line
def Count(self):
return self.last_line - self.first_line + 1
def Contains(self, line_num):
return self.first_line <= line_num and line_num <= self.last_line
def LogInfo(self, message):
self.input_api.logging.info(message)
return
def LogDebug(self, message):
self.input_api.logging.debug(message)
return
def ComputeEnumRangeInContents(self, contents):
"""Returns an |EnumRange| object representing the line extent of the
enum members in |contents|. The line numbers are 1-based,
compatible with line numbers returned by AffectedFile.ChangeContents().
|contents| is a list of strings reprenting the lines of a text file.
If either start_marker or end_marker cannot be found in
|contents|, returns None and emits detailed warnings about the problem.
"""
first_enum_line = 0
last_enum_line = 0
line_num = 1 # Line numbers are 1-based
for line in contents:
if line.startswith(self.start_marker):
first_enum_line = line_num + 1
elif line.startswith(self.end_marker):
last_enum_line = line_num
line_num += 1
if first_enum_line == 0:
self.EmitWarning("The presubmit script could not find the start of the "
"enum definition (\"%s\"). Did the enum definition "
"change?" % self.start_marker)
return None
if last_enum_line == 0:
self.EmitWarning("The presubmit script could not find the end of the "
"enum definition (\"%s\"). Did the enum definition "
"change?" % self.end_marker)
return None
if first_enum_line >= last_enum_line:
self.EmitWarning("The presubmit script located the start of the enum "
"definition (\"%s\" at line %d) *after* its end "
"(\"%s\" at line %d). Something is not quite right."
% (self.start_marker, first_enum_line,
self.end_marker, last_enum_line))
return None
self.LogInfo("Line extent of (\"%s\") enum definition: "
"first_line=%d, last_line=%d."
% (self.start_marker, first_enum_line, last_enum_line))
return self.EnumRange(first_enum_line, last_enum_line)
def ComputeEnumRangeInNewFile(self, affected_file):
return self.ComputeEnumRangeInContents(affected_file.NewContents())
def GetLongMessage(self, local_path):
return str("The file \"%s\" contains the definition of the "
"(\"%s\") enum which should be edited in specific ways "
"only - *** read the comments at the top of the header file ***"
". There are changes to the file that may be incorrect and "
"warrant manual confirmation after review. Note that this "
"presubmit script can not reliably report the nature of all "
"types of invalid changes, especially when the diffs are "
"complex. For example, an invalid deletion may be reported "
"whereas the change contains a valid rename."
% (local_path, self.start_marker))
def EmitWarning(self, message, line_number=None, line_text=None):
"""Emits a presubmit prompt warning containing the short message
|message|. |item| is |LOCAL_PATH| with optional |line_number| and
|line_text|.
"""
if line_number is not None and line_text is not None:
item = "%s(%d): %s" % (self.path, line_number, line_text)
elif line_number is not None:
item = "%s(%d)" % (self.path, line_number)
else:
item = self.path
long_message = self.GetLongMessage(self.path)
self.LogInfo(message)
self.results.append(
self.output_api.PresubmitPromptWarning(message, [item], long_message))
def CollectRangesInsideEnumDefinition(self, affected_file,
first_line, last_line):
"""Returns a list of triplet (line_start, line_end, line_text) of ranges of
edits changes. The |line_text| part is the text at line |line_start|.
Since it used only for reporting purposes, we do not need all the text
lines in the range.
"""
results = []
previous_line_number = 0
previous_range_start_line_number = 0
previous_range_start_text = ""
def addRange():
tuple = (previous_range_start_line_number,
previous_line_number,
previous_range_start_text)
results.append(tuple)
for line_number, line_text in affected_file.ChangedContents():
if first_line <= line_number and line_number <= last_line:
self.LogDebug("Line change at line number " + str(line_number) + ": " +
line_text)
# Start a new interval if none started
if previous_range_start_line_number == 0:
previous_range_start_line_number = line_number
previous_range_start_text = line_text
# Add new interval if we reached past the previous one
elif line_number != previous_line_number + 1:
addRange()
previous_range_start_line_number = line_number
previous_range_start_text = line_text
previous_line_number = line_number
# Add a last interval if needed
if previous_range_start_line_number != 0:
addRange()
return results
def CheckForFileDeletion(self, affected_file):
"""Emits a warning notification if file has been deleted """
if not affected_file.NewContents():
self.EmitWarning("The file seems to be deleted in the changelist. If "
"your intent is to really delete the file, the code in "
"PRESUBMIT.py should be updated to remove the "
"|StrictEnumValueChecker| class.");
return False
return True
def GetDeletedLinesFromScmDiff(self, affected_file):
"""Return a list of of line numbers (1-based) corresponding to lines
deleted from the new source file (if they had been present in it). Note
that if multiple contiguous lines have been deleted, the returned list will
contain contiguous line number entries. To prevent false positives, we
return deleted line numbers *only* from diff chunks which decrease the size
of the new file.
Note: We need this method because we have access to neither the old file
content nor the list of "delete" changes from the current presubmit script
API.
"""
results = []
line_num = 0
deleting_lines = False
for line in affected_file.GenerateScmDiff().splitlines():
# Parse the unified diff chunk optional section heading, which looks like
# @@ -l,s +l,s @@ optional section heading
m = self.input_api.re.match(
r"^@@ \-([0-9]+)\,([0-9]+) \+([0-9]+)\,([0-9]+) @@", line)
if m:
old_line_num = int(m.group(1))
old_size = int(m.group(2))
new_line_num = int(m.group(3))
new_size = int(m.group(4))
line_num = new_line_num
# Return line numbers only from diff chunks decreasing the size of the
# new file
deleting_lines = old_size > new_size
continue
if not line.startswith("-"):
line_num += 1
if deleting_lines and line.startswith("-") and not line.startswith("--"):
results.append(line_num)
return results
def CheckForEnumEntryDeletions(self, affected_file):
"""Look for deletions inside the enum definition. We currently use a
simple heuristics (not 100% accurate): if there are deleted lines inside
the enum definition, this might be a deletion.
"""
range_new = self.ComputeEnumRangeInNewFile(affected_file)
if not range_new:
return False
is_ok = True
for line_num in self.GetDeletedLinesFromScmDiff(affected_file):
if range_new.Contains(line_num):
self.EmitWarning("It looks like you are deleting line(s) from the "
"enum definition. This should never happen.",
line_num)
is_ok = False
return is_ok
def CheckForEnumEntryInsertions(self, affected_file):
range = self.ComputeEnumRangeInNewFile(affected_file)
if not range:
return False
first_line = range.first_line
last_line = range.last_line
# Collect the range of changes inside the enum definition range.
is_ok = True
for line_start, line_end, line_text in \
self.CollectRangesInsideEnumDefinition(affected_file,
first_line,
last_line):
# The only edit we consider valid is adding 1 or more entries *exactly*
# at the end of the enum definition. Every other edit inside the enum
# definition will result in a "warning confirmation" message.
#
# TODO(rpaquay): We currently cannot detect "renames" of existing entries
# vs invalid insertions, so we sometimes will warn for valid edits.
is_valid_edit = (line_end == last_line - 1)
self.LogDebug("Edit range in new file at starting at line number %d and "
"ending at line number %d: valid=%s"
% (line_start, line_end, is_valid_edit))
if not is_valid_edit:
self.EmitWarning("The change starting at line %d and ending at line "
"%d is *not* located *exactly* at the end of the "
"enum definition. Unless you are renaming an "
"existing entry, this is not a valid change, as new "
"entries should *always* be added at the end of the "
"enum definition, right before the \"%s\" "
"entry." % (line_start, line_end, self.end_marker),
line_start,
line_text)
is_ok = False
return is_ok
def PerformChecks(self, affected_file):
if not self.CheckForFileDeletion(affected_file):
return
if not self.CheckForEnumEntryDeletions(affected_file):
return
if not self.CheckForEnumEntryInsertions(affected_file):
return
def ProcessHistogramValueFile(self, affected_file):
self.LogInfo("Start processing file \"%s\"" % affected_file.LocalPath())
self.PerformChecks(affected_file)
self.LogInfo("Done processing file \"%s\"" % affected_file.LocalPath())
def Run(self):
for file in self.input_api.AffectedFiles(include_deletes=True):
if file.LocalPath() == self.path:
self.ProcessHistogramValueFile(file)
return self.results