1#!/usr/bin/env python
2#
3# Copyright 2011 The Closure Linter Authors. All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS-IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Methods for checking JS files for common style guide violations.
18
19These style guide violations should only apply to JavaScript and not an Ecma
20scripting languages.
21"""
22
23__author__ = ('robbyw@google.com (Robert Walker)',
24              'ajp@google.com (Andy Perelson)',
25              'jacobr@google.com (Jacob Richman)')
26
27import re
28from sets import Set
29from closure_linter import ecmalintrules
30from closure_linter import error_check
31from closure_linter import errors
32from closure_linter import javascripttokenizer
33from closure_linter import javascripttokens
34from closure_linter import requireprovidesorter
35from closure_linter import tokenutil
36from closure_linter.common import error
37from closure_linter.common import position
38
39# Shorthand
40Error = error.Error
41Position = position.Position
42Rule = error_check.Rule
43Type = javascripttokens.JavaScriptTokenType
44
45
46class JavaScriptLintRules(ecmalintrules.EcmaScriptLintRules):
47  """JavaScript lint rules that catch JavaScript specific style errors."""
48
49  def __init__(self, namespaces_info):
50    """Initializes a JavaScriptLintRules instance."""
51    ecmalintrules.EcmaScriptLintRules.__init__(self)
52    self._namespaces_info = namespaces_info
53    self._declared_private_member_tokens = {}
54    self._declared_private_members = Set()
55    self._used_private_members = Set()
56
57  def HandleMissingParameterDoc(self, token, param_name):
58    """Handle errors associated with a parameter missing a param tag."""
59    self._HandleError(errors.MISSING_PARAMETER_DOCUMENTATION,
60                      'Missing docs for parameter: "%s"' % param_name, token)
61
62  def __ContainsRecordType(self, token):
63    """Check whether the given token contains a record type.
64
65    Args:
66      token: The token being checked
67
68    Returns:
69      True if the token contains a record type, False otherwise.
70    """
71    # If we see more than one left-brace in the string of an annotation token,
72    # then there's a record type in there.
73    return (
74        token and token.type == Type.DOC_FLAG and
75        token.attached_object.type is not None and
76        token.attached_object.type.find('{') != token.string.rfind('{'))
77
78  def CheckToken(self, token, state):
79    """Checks a token, given the current parser_state, for warnings and errors.
80
81    Args:
82      token: The current token under consideration
83      state: parser_state object that indicates the current state in the page
84    """
85    if self.__ContainsRecordType(token):
86      # We should bail out and not emit any warnings for this annotation.
87      # TODO(nicksantos): Support record types for real.
88      state.GetDocComment().Invalidate()
89      return
90
91    # Call the base class's CheckToken function.
92    super(JavaScriptLintRules, self).CheckToken(token, state)
93
94    # Store some convenience variables
95    namespaces_info = self._namespaces_info
96
97    if error_check.ShouldCheck(Rule.UNUSED_PRIVATE_MEMBERS):
98      # Find all assignments to private members.
99      if token.type == Type.SIMPLE_LVALUE:
100        identifier = token.string
101        if identifier.endswith('_') and not identifier.endswith('__'):
102          doc_comment = state.GetDocComment()
103          suppressed = (doc_comment and doc_comment.HasFlag('suppress') and
104                        doc_comment.GetFlag('suppress').type == 'underscore')
105          if not suppressed:
106            # Look for static members defined on a provided namespace.
107            namespace = namespaces_info.GetClosurizedNamespace(identifier)
108            provided_namespaces = namespaces_info.GetProvidedNamespaces()
109
110            # Skip cases of this.something_.somethingElse_.
111            regex = re.compile('^this\.[a-zA-Z_]+$')
112            if namespace in provided_namespaces or regex.match(identifier):
113              variable = identifier.split('.')[-1]
114              self._declared_private_member_tokens[variable] = token
115              self._declared_private_members.add(variable)
116        elif not identifier.endswith('__'):
117          # Consider setting public members of private members to be a usage.
118          for piece in identifier.split('.'):
119            if piece.endswith('_'):
120              self._used_private_members.add(piece)
121
122      # Find all usages of private members.
123      if token.type == Type.IDENTIFIER:
124        for piece in token.string.split('.'):
125          if piece.endswith('_'):
126            self._used_private_members.add(piece)
127
128    if token.type == Type.DOC_FLAG:
129      flag = token.attached_object
130
131      if flag.flag_type == 'param' and flag.name_token is not None:
132        self._CheckForMissingSpaceBeforeToken(
133            token.attached_object.name_token)
134
135        if (error_check.ShouldCheck(Rule.OPTIONAL_TYPE_MARKER) and
136            flag.type is not None and flag.name is not None):
137          # Check for optional marker in type.
138          if (flag.type.endswith('=') and
139              not flag.name.startswith('opt_')):
140            self._HandleError(errors.JSDOC_MISSING_OPTIONAL_PREFIX,
141                              'Optional parameter name %s must be prefixed '
142                              'with opt_.' % flag.name,
143                              token)
144          elif (not flag.type.endswith('=') and
145                flag.name.startswith('opt_')):
146            self._HandleError(errors.JSDOC_MISSING_OPTIONAL_TYPE,
147                              'Optional parameter %s type must end with =.' %
148                              flag.name,
149                              token)
150
151      if flag.flag_type in state.GetDocFlag().HAS_TYPE:
152        # Check for both missing type token and empty type braces '{}'
153        # Missing suppress types are reported separately and we allow enums
154        # without types.
155        if (flag.flag_type not in ('suppress', 'enum') and
156            (not flag.type or flag.type.isspace())):
157          self._HandleError(errors.MISSING_JSDOC_TAG_TYPE,
158                            'Missing type in %s tag' % token.string, token)
159
160        elif flag.name_token and flag.type_end_token and tokenutil.Compare(
161            flag.type_end_token, flag.name_token) > 0:
162          self._HandleError(
163              errors.OUT_OF_ORDER_JSDOC_TAG_TYPE,
164              'Type should be immediately after %s tag' % token.string,
165              token)
166
167    elif token.type == Type.DOUBLE_QUOTE_STRING_START:
168      next_token = token.next
169      while next_token.type == Type.STRING_TEXT:
170        if javascripttokenizer.JavaScriptTokenizer.SINGLE_QUOTE.search(
171            next_token.string):
172          break
173        next_token = next_token.next
174      else:
175        self._HandleError(
176            errors.UNNECESSARY_DOUBLE_QUOTED_STRING,
177            'Single-quoted string preferred over double-quoted string.',
178            token,
179            Position.All(token.string))
180
181    elif token.type == Type.END_DOC_COMMENT:
182      doc_comment = state.GetDocComment()
183
184      # When @externs appears in a @fileoverview comment, it should trigger
185      # the same limited doc checks as a special filename like externs.js.
186      if doc_comment.HasFlag('fileoverview') and doc_comment.HasFlag('externs'):
187        self._SetLimitedDocChecks(True)
188
189      if (error_check.ShouldCheck(Rule.BLANK_LINES_AT_TOP_LEVEL) and
190          not self._is_html and state.InTopLevel() and not state.InBlock()):
191
192        # Check if we're in a fileoverview or constructor JsDoc.
193        is_constructor = (
194            doc_comment.HasFlag('constructor') or
195            doc_comment.HasFlag('interface'))
196        is_file_overview = doc_comment.HasFlag('fileoverview')
197
198        # If the comment is not a file overview, and it does not immediately
199        # precede some code, skip it.
200        # NOTE: The tokenutil methods are not used here because of their
201        # behavior at the top of a file.
202        next_token = token.next
203        if (not next_token or
204            (not is_file_overview and next_token.type in Type.NON_CODE_TYPES)):
205          return
206
207        # Don't require extra blank lines around suppression of extra
208        # goog.require errors.
209        if (doc_comment.SuppressionOnly() and
210            next_token.type == Type.IDENTIFIER and
211            next_token.string in ['goog.provide', 'goog.require']):
212          return
213
214        # Find the start of this block (include comments above the block, unless
215        # this is a file overview).
216        block_start = doc_comment.start_token
217        if not is_file_overview:
218          token = block_start.previous
219          while token and token.type in Type.COMMENT_TYPES:
220            block_start = token
221            token = token.previous
222
223        # Count the number of blank lines before this block.
224        blank_lines = 0
225        token = block_start.previous
226        while token and token.type in [Type.WHITESPACE, Type.BLANK_LINE]:
227          if token.type == Type.BLANK_LINE:
228            # A blank line.
229            blank_lines += 1
230          elif token.type == Type.WHITESPACE and not token.line.strip():
231            # A line with only whitespace on it.
232            blank_lines += 1
233          token = token.previous
234
235        # Log errors.
236        error_message = False
237        expected_blank_lines = 0
238
239        if is_file_overview and blank_lines == 0:
240          error_message = 'Should have a blank line before a file overview.'
241          expected_blank_lines = 1
242        elif is_constructor and blank_lines != 3:
243          error_message = (
244              'Should have 3 blank lines before a constructor/interface.')
245          expected_blank_lines = 3
246        elif not is_file_overview and not is_constructor and blank_lines != 2:
247          error_message = 'Should have 2 blank lines between top-level blocks.'
248          expected_blank_lines = 2
249
250        if error_message:
251          self._HandleError(
252              errors.WRONG_BLANK_LINE_COUNT, error_message,
253              block_start, Position.AtBeginning(),
254              expected_blank_lines - blank_lines)
255
256    elif token.type == Type.END_BLOCK:
257      if state.InFunction() and state.IsFunctionClose():
258        is_immediately_called = (token.next and
259                                 token.next.type == Type.START_PAREN)
260
261        function = state.GetFunction()
262        if not self._limited_doc_checks:
263          if (function.has_return and function.doc and
264              not is_immediately_called and
265              not function.doc.HasFlag('return') and
266              not function.doc.InheritsDocumentation() and
267              not function.doc.HasFlag('constructor')):
268            # Check for proper documentation of return value.
269            self._HandleError(
270                errors.MISSING_RETURN_DOCUMENTATION,
271                'Missing @return JsDoc in function with non-trivial return',
272                function.doc.end_token, Position.AtBeginning())
273          elif (not function.has_return and
274                not function.has_throw and
275                function.doc and
276                function.doc.HasFlag('return') and
277                not state.InInterfaceMethod()):
278            return_flag = function.doc.GetFlag('return')
279            if (return_flag.type is None or (
280                'undefined' not in return_flag.type and
281                'void' not in return_flag.type and
282                '*' not in return_flag.type)):
283              self._HandleError(
284                  errors.UNNECESSARY_RETURN_DOCUMENTATION,
285                  'Found @return JsDoc on function that returns nothing',
286                  return_flag.flag_token, Position.AtBeginning())
287
288      if state.InFunction() and state.IsFunctionClose():
289        is_immediately_called = (token.next and
290                                 token.next.type == Type.START_PAREN)
291        if (function.has_this and function.doc and
292            not function.doc.HasFlag('this') and
293            not function.is_constructor and
294            not function.is_interface and
295            '.prototype.' not in function.name):
296          self._HandleError(
297              errors.MISSING_JSDOC_TAG_THIS,
298              'Missing @this JsDoc in function referencing "this". ('
299              'this usually means you are trying to reference "this" in '
300              'a static function, or you have forgotten to mark a '
301              'constructor with @constructor)',
302              function.doc.end_token, Position.AtBeginning())
303
304    elif token.type == Type.IDENTIFIER:
305      if token.string == 'goog.inherits' and not state.InFunction():
306        if state.GetLastNonSpaceToken().line_number == token.line_number:
307          self._HandleError(
308              errors.MISSING_LINE,
309              'Missing newline between constructor and goog.inherits',
310              token,
311              Position.AtBeginning())
312
313        extra_space = state.GetLastNonSpaceToken().next
314        while extra_space != token:
315          if extra_space.type == Type.BLANK_LINE:
316            self._HandleError(
317                errors.EXTRA_LINE,
318                'Extra line between constructor and goog.inherits',
319                extra_space)
320          extra_space = extra_space.next
321
322        # TODO(robbyw): Test the last function was a constructor.
323        # TODO(robbyw): Test correct @extends and @implements documentation.
324
325      elif (token.string == 'goog.provide' and
326            not state.InFunction() and
327            namespaces_info is not None):
328        namespace = tokenutil.Search(token, Type.STRING_TEXT).string
329
330        # Report extra goog.provide statement.
331        if namespaces_info.IsExtraProvide(token):
332          self._HandleError(
333              errors.EXTRA_GOOG_PROVIDE,
334              'Unnecessary goog.provide: ' + namespace,
335              token, position=Position.AtBeginning())
336
337        if namespaces_info.IsLastProvide(token):
338          # Report missing provide statements after the last existing provide.
339          missing_provides = namespaces_info.GetMissingProvides()
340          if missing_provides:
341            self._ReportMissingProvides(
342                missing_provides,
343                tokenutil.GetLastTokenInSameLine(token).next,
344                False)
345
346          # If there are no require statements, missing requires should be
347          # reported after the last provide.
348          if not namespaces_info.GetRequiredNamespaces():
349            missing_requires = namespaces_info.GetMissingRequires()
350            if missing_requires:
351              self._ReportMissingRequires(
352                  missing_requires,
353                  tokenutil.GetLastTokenInSameLine(token).next,
354                  True)
355
356      elif (token.string == 'goog.require' and
357            not state.InFunction() and
358            namespaces_info is not None):
359        namespace = tokenutil.Search(token, Type.STRING_TEXT).string
360
361        # If there are no provide statements, missing provides should be
362        # reported before the first require.
363        if (namespaces_info.IsFirstRequire(token) and
364            not namespaces_info.GetProvidedNamespaces()):
365          missing_provides = namespaces_info.GetMissingProvides()
366          if missing_provides:
367            self._ReportMissingProvides(
368                missing_provides,
369                tokenutil.GetFirstTokenInSameLine(token),
370                True)
371
372        # Report extra goog.require statement.
373        if namespaces_info.IsExtraRequire(token):
374          self._HandleError(
375              errors.EXTRA_GOOG_REQUIRE,
376              'Unnecessary goog.require: ' + namespace,
377              token, position=Position.AtBeginning())
378
379        # Report missing goog.require statements.
380        if namespaces_info.IsLastRequire(token):
381          missing_requires = namespaces_info.GetMissingRequires()
382          if missing_requires:
383            self._ReportMissingRequires(
384                missing_requires,
385                tokenutil.GetLastTokenInSameLine(token).next,
386                False)
387
388    elif token.type == Type.OPERATOR:
389      last_in_line = token.IsLastInLine()
390      # If the token is unary and appears to be used in a unary context
391      # it's ok.  Otherwise, if it's at the end of the line or immediately
392      # before a comment, it's ok.
393      # Don't report an error before a start bracket - it will be reported
394      # by that token's space checks.
395      if (not token.metadata.IsUnaryOperator() and not last_in_line
396          and not token.next.IsComment()
397          and not token.next.IsOperator(',')
398          and not token.next.type in (Type.WHITESPACE, Type.END_PAREN,
399                                      Type.END_BRACKET, Type.SEMICOLON,
400                                      Type.START_BRACKET)):
401        self._HandleError(
402            errors.MISSING_SPACE,
403            'Missing space after "%s"' % token.string,
404            token,
405            Position.AtEnd(token.string))
406    elif token.type == Type.WHITESPACE:
407      first_in_line = token.IsFirstInLine()
408      last_in_line = token.IsLastInLine()
409      # Check whitespace length if it's not the first token of the line and
410      # if it's not immediately before a comment.
411      if not last_in_line and not first_in_line and not token.next.IsComment():
412        # Ensure there is no space after opening parentheses.
413        if (token.previous.type in (Type.START_PAREN, Type.START_BRACKET,
414                                    Type.FUNCTION_NAME)
415            or token.next.type == Type.START_PARAMETERS):
416          self._HandleError(
417              errors.EXTRA_SPACE,
418              'Extra space after "%s"' % token.previous.string,
419              token,
420              Position.All(token.string))
421
422  def _ReportMissingProvides(self, missing_provides, token, need_blank_line):
423    """Reports missing provide statements to the error handler.
424
425    Args:
426      missing_provides: A list of strings where each string is a namespace that
427          should be provided, but is not.
428      token: The token where the error was detected (also where the new provides
429          will be inserted.
430      need_blank_line: Whether a blank line needs to be inserted after the new
431          provides are inserted. May be True, False, or None, where None
432          indicates that the insert location is unknown.
433    """
434    self._HandleError(
435        errors.MISSING_GOOG_PROVIDE,
436        'Missing the following goog.provide statements:\n' +
437        '\n'.join(map(lambda x: 'goog.provide(\'%s\');' % x,
438                      sorted(missing_provides))),
439        token, position=Position.AtBeginning(),
440        fix_data=(missing_provides, need_blank_line))
441
442  def _ReportMissingRequires(self, missing_requires, token, need_blank_line):
443    """Reports missing require statements to the error handler.
444
445    Args:
446      missing_requires: A list of strings where each string is a namespace that
447          should be required, but is not.
448      token: The token where the error was detected (also where the new requires
449          will be inserted.
450      need_blank_line: Whether a blank line needs to be inserted before the new
451          requires are inserted. May be True, False, or None, where None
452          indicates that the insert location is unknown.
453    """
454    self._HandleError(
455        errors.MISSING_GOOG_REQUIRE,
456        'Missing the following goog.require statements:\n' +
457        '\n'.join(map(lambda x: 'goog.require(\'%s\');' % x,
458                      sorted(missing_requires))),
459        token, position=Position.AtBeginning(),
460        fix_data=(missing_requires, need_blank_line))
461
462  def Finalize(self, state, tokenizer_mode):
463    """Perform all checks that need to occur after all lines are processed."""
464    # Call the base class's Finalize function.
465    super(JavaScriptLintRules, self).Finalize(state, tokenizer_mode)
466
467    if error_check.ShouldCheck(Rule.UNUSED_PRIVATE_MEMBERS):
468      # Report an error for any declared private member that was never used.
469      unused_private_members = (self._declared_private_members -
470                                self._used_private_members)
471
472      for variable in unused_private_members:
473        token = self._declared_private_member_tokens[variable]
474        self._HandleError(errors.UNUSED_PRIVATE_MEMBER,
475                          'Unused private member: %s.' % token.string,
476                          token)
477
478      # Clear state to prepare for the next file.
479      self._declared_private_member_tokens = {}
480      self._declared_private_members = Set()
481      self._used_private_members = Set()
482
483    namespaces_info = self._namespaces_info
484    if namespaces_info is not None:
485      # If there are no provide or require statements, missing provides and
486      # requires should be reported on line 1.
487      if (not namespaces_info.GetProvidedNamespaces() and
488          not namespaces_info.GetRequiredNamespaces()):
489        missing_provides = namespaces_info.GetMissingProvides()
490        if missing_provides:
491          self._ReportMissingProvides(
492              missing_provides, state.GetFirstToken(), None)
493
494        missing_requires = namespaces_info.GetMissingRequires()
495        if missing_requires:
496          self._ReportMissingRequires(
497              missing_requires, state.GetFirstToken(), None)
498
499    self._CheckSortedRequiresProvides(state.GetFirstToken())
500
501  def _CheckSortedRequiresProvides(self, token):
502    """Checks that all goog.require and goog.provide statements are sorted.
503
504    Note that this method needs to be run after missing statements are added to
505    preserve alphabetical order.
506
507    Args:
508      token: The first token in the token stream.
509    """
510    sorter = requireprovidesorter.RequireProvideSorter()
511    provides_result = sorter.CheckProvides(token)
512    if provides_result:
513      self._HandleError(
514          errors.GOOG_PROVIDES_NOT_ALPHABETIZED,
515          'goog.provide classes must be alphabetized.  The correct code is:\n' +
516          '\n'.join(
517              map(lambda x: 'goog.provide(\'%s\');' % x, provides_result[1])),
518          provides_result[0],
519          position=Position.AtBeginning(),
520          fix_data=provides_result[0])
521
522    requires_result = sorter.CheckRequires(token)
523    if requires_result:
524      self._HandleError(
525          errors.GOOG_REQUIRES_NOT_ALPHABETIZED,
526          'goog.require classes must be alphabetized.  The correct code is:\n' +
527          '\n'.join(
528              map(lambda x: 'goog.require(\'%s\');' % x, requires_result[1])),
529          requires_result[0],
530          position=Position.AtBeginning(),
531          fix_data=requires_result[0])
532
533  def GetLongLineExceptions(self):
534    """Gets a list of regexps for lines which can be longer than the limit."""
535    return [
536        re.compile('.*// @suppress longLineCheck$'),
537        re.compile('goog\.require\(.+\);?\s*$'),
538        re.compile('goog\.provide\(.+\);?\s*$')
539        ]
540