1# Copyright 2014 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5class StrictEnumValueChecker(object):
6  """Verify that changes to enums are valid.
7
8  This class is used to check enums where reordering or deletion is not allowed,
9  and additions must be at the end of the enum, just prior to some "boundary"
10  entry. See comments at the top of the "extension_function_histogram_value.h"
11  file in chrome/browser/extensions for an example what are considered valid
12  changes. There are situations where this class gives false positive warnings,
13  i.e. it warns even though the edit is legitimate. Since the class warns using
14  prompt warnings, the user can always choose to continue. The main point is to
15  attract the attention to all (potentially or not) invalid edits.
16
17  """
18  def __init__(self, input_api, output_api, start_marker, end_marker, path):
19    self.input_api = input_api
20    self.output_api = output_api
21    self.start_marker = start_marker
22    self.end_marker = end_marker
23    self.path = path
24    self.results = []
25
26  class EnumRange(object):
27    """Represents a range of line numbers (1-based)"""
28    def __init__(self, first_line, last_line):
29      self.first_line = first_line
30      self.last_line = last_line
31
32    def Count(self):
33      return self.last_line - self.first_line + 1
34
35    def Contains(self, line_num):
36      return self.first_line <= line_num and line_num <= self.last_line
37
38  def LogInfo(self, message):
39    self.input_api.logging.info(message)
40    return
41
42  def LogDebug(self, message):
43    self.input_api.logging.debug(message)
44    return
45
46  def ComputeEnumRangeInContents(self, contents):
47    """Returns an |EnumRange| object representing the line extent of the
48    enum members in |contents|. The line numbers are 1-based,
49    compatible with line numbers returned by AffectedFile.ChangeContents().
50    |contents| is a list of strings reprenting the lines of a text file.
51
52    If either start_marker or end_marker cannot be found in
53    |contents|, returns None and emits detailed warnings about the problem.
54
55    """
56    first_enum_line = 0
57    last_enum_line = 0
58    line_num = 1  # Line numbers are 1-based
59    for line in contents:
60      if line.startswith(self.start_marker):
61        first_enum_line = line_num + 1
62      elif line.startswith(self.end_marker):
63        last_enum_line = line_num
64      line_num += 1
65
66    if first_enum_line == 0:
67      self.EmitWarning("The presubmit script could not find the start of the "
68                       "enum definition (\"%s\"). Did the enum definition "
69                       "change?" % self.start_marker)
70      return None
71
72    if last_enum_line == 0:
73      self.EmitWarning("The presubmit script could not find the end of the "
74                       "enum definition (\"%s\"). Did the enum definition "
75                       "change?" % self.end_marker)
76      return None
77
78    if first_enum_line >= last_enum_line:
79      self.EmitWarning("The presubmit script located the start of the enum "
80                       "definition (\"%s\" at line %d) *after* its end "
81                       "(\"%s\" at line %d). Something is not quite right."
82                       % (self.start_marker, first_enum_line,
83                          self.end_marker, last_enum_line))
84      return None
85
86    self.LogInfo("Line extent of (\"%s\") enum definition: "
87                 "first_line=%d, last_line=%d."
88                 % (self.start_marker, first_enum_line, last_enum_line))
89    return self.EnumRange(first_enum_line, last_enum_line)
90
91  def ComputeEnumRangeInNewFile(self, affected_file):
92    return self.ComputeEnumRangeInContents(affected_file.NewContents())
93
94  def GetLongMessage(self, local_path):
95    return str("The file \"%s\" contains the definition of the "
96               "(\"%s\") enum which should be edited in specific ways "
97               "only - *** read the comments at the top of the header file ***"
98               ". There are changes to the file that may be incorrect and "
99               "warrant manual confirmation after review. Note that this "
100               "presubmit script can not reliably report the nature of all "
101               "types of invalid changes, especially when the diffs are "
102               "complex. For example, an invalid deletion may be reported "
103               "whereas the change contains a valid rename."
104               % (local_path, self.start_marker))
105
106  def EmitWarning(self, message, line_number=None, line_text=None):
107    """Emits a presubmit prompt warning containing the short message
108    |message|. |item| is |LOCAL_PATH| with optional |line_number| and
109    |line_text|.
110
111    """
112    if line_number is not None and line_text is not None:
113      item = "%s(%d): %s" % (self.path, line_number, line_text)
114    elif line_number is not None:
115      item = "%s(%d)" % (self.path, line_number)
116    else:
117      item = self.path
118    long_message = self.GetLongMessage(self.path)
119    self.LogInfo(message)
120    self.results.append(
121      self.output_api.PresubmitPromptWarning(message, [item], long_message))
122
123  def CollectRangesInsideEnumDefinition(self, affected_file,
124                                        first_line, last_line):
125    """Returns a list of triplet (line_start, line_end, line_text) of ranges of
126    edits changes. The |line_text| part is the text at line |line_start|.
127    Since it used only for reporting purposes, we do not need all the text
128    lines in the range.
129
130    """
131    results = []
132    previous_line_number = 0
133    previous_range_start_line_number = 0
134    previous_range_start_text = ""
135
136    def addRange():
137      tuple = (previous_range_start_line_number,
138               previous_line_number,
139               previous_range_start_text)
140      results.append(tuple)
141
142    for line_number, line_text in affected_file.ChangedContents():
143      if first_line <= line_number and line_number <= last_line:
144        self.LogDebug("Line change at line number " + str(line_number) + ": " +
145                      line_text)
146        # Start a new interval if none started
147        if previous_range_start_line_number == 0:
148          previous_range_start_line_number = line_number
149          previous_range_start_text = line_text
150        # Add new interval if we reached past the previous one
151        elif line_number != previous_line_number + 1:
152          addRange()
153          previous_range_start_line_number = line_number
154          previous_range_start_text = line_text
155        previous_line_number = line_number
156
157    # Add a last interval if needed
158    if previous_range_start_line_number != 0:
159        addRange()
160    return results
161
162  def CheckForFileDeletion(self, affected_file):
163    """Emits a warning notification if file has been deleted """
164    if not affected_file.NewContents():
165      self.EmitWarning("The file seems to be deleted in the changelist. If "
166                       "your intent is to really delete the file, the code in "
167                       "PRESUBMIT.py should be updated to remove the "
168                       "|StrictEnumValueChecker| class.");
169      return False
170    return True
171
172  def GetDeletedLinesFromScmDiff(self, affected_file):
173    """Return a list of of line numbers (1-based) corresponding to lines
174    deleted from the new source file (if they had been present in it). Note
175    that if multiple contiguous lines have been deleted, the returned list will
176    contain contiguous line number entries. To prevent false positives, we
177    return deleted line numbers *only* from diff chunks which decrease the size
178    of the new file.
179
180    Note: We need this method because we have access to neither the old file
181    content nor the list of "delete" changes from the current presubmit script
182    API.
183
184    """
185    results = []
186    line_num = 0
187    deleting_lines = False
188    for line in affected_file.GenerateScmDiff().splitlines():
189      # Parse the unified diff chunk optional section heading, which looks like
190      # @@ -l,s +l,s @@ optional section heading
191      m = self.input_api.re.match(
192        r"^@@ \-([0-9]+)\,([0-9]+) \+([0-9]+)\,([0-9]+) @@", line)
193      if m:
194        old_line_num = int(m.group(1))
195        old_size = int(m.group(2))
196        new_line_num = int(m.group(3))
197        new_size = int(m.group(4))
198        line_num = new_line_num
199        # Return line numbers only from diff chunks decreasing the size of the
200        # new file
201        deleting_lines = old_size > new_size
202        continue
203      if not line.startswith("-"):
204        line_num += 1
205      if deleting_lines and line.startswith("-") and not line.startswith("--"):
206        results.append(line_num)
207    return results
208
209  def CheckForEnumEntryDeletions(self, affected_file):
210    """Look for deletions inside the enum definition. We currently use a
211    simple heuristics (not 100% accurate): if there are deleted lines inside
212    the enum definition, this might be a deletion.
213
214    """
215    range_new = self.ComputeEnumRangeInNewFile(affected_file)
216    if not range_new:
217      return False
218
219    is_ok = True
220    for line_num in self.GetDeletedLinesFromScmDiff(affected_file):
221      if range_new.Contains(line_num):
222        self.EmitWarning("It looks like you are deleting line(s) from the "
223                         "enum definition. This should never happen.",
224                         line_num)
225        is_ok = False
226    return is_ok
227
228  def CheckForEnumEntryInsertions(self, affected_file):
229    range = self.ComputeEnumRangeInNewFile(affected_file)
230    if not range:
231      return False
232
233    first_line = range.first_line
234    last_line = range.last_line
235
236    # Collect the range of changes inside the enum definition range.
237    is_ok = True
238    for line_start, line_end, line_text in \
239          self.CollectRangesInsideEnumDefinition(affected_file,
240                                                 first_line,
241                                                 last_line):
242      # The only edit we consider valid is adding 1 or more entries *exactly*
243      # at the end of the enum definition. Every other edit inside the enum
244      # definition will result in a "warning confirmation" message.
245      #
246      # TODO(rpaquay): We currently cannot detect "renames" of existing entries
247      # vs invalid insertions, so we sometimes will warn for valid edits.
248      is_valid_edit = (line_end == last_line - 1)
249
250      self.LogDebug("Edit range in new file at starting at line number %d and "
251                    "ending at line number %d: valid=%s"
252                    % (line_start, line_end, is_valid_edit))
253
254      if not is_valid_edit:
255        self.EmitWarning("The change starting at line %d and ending at line "
256                         "%d is *not* located *exactly* at the end of the "
257                         "enum definition. Unless you are renaming an "
258                         "existing entry, this is not a valid change, as new "
259                         "entries should *always* be added at the end of the "
260                         "enum definition, right before the \"%s\" "
261                         "entry." % (line_start, line_end, self.end_marker),
262                         line_start,
263                         line_text)
264        is_ok = False
265    return is_ok
266
267  def PerformChecks(self, affected_file):
268    if not self.CheckForFileDeletion(affected_file):
269      return
270    if not self.CheckForEnumEntryDeletions(affected_file):
271      return
272    if not self.CheckForEnumEntryInsertions(affected_file):
273      return
274
275  def ProcessHistogramValueFile(self, affected_file):
276    self.LogInfo("Start processing file \"%s\"" % affected_file.LocalPath())
277    self.PerformChecks(affected_file)
278    self.LogInfo("Done processing file \"%s\"" % affected_file.LocalPath())
279
280  def Run(self):
281    for file in self.input_api.AffectedFiles(include_deletes=True):
282      if file.LocalPath() == self.path:
283        self.ProcessHistogramValueFile(file)
284    return self.results
285