1#!/usr/bin/python
2# Copyright 2014 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Runs Closure compiler on a JavaScript file to check for errors."""
7
8import argparse
9import os
10import re
11import subprocess
12import sys
13import tempfile
14import processor
15
16
17class Checker(object):
18  """Runs the Closure compiler on a given source file and returns the
19  success/errors."""
20
21  _COMMON_CLOSURE_ARGS = [
22    "--accept_const_keyword",
23    "--jscomp_error=accessControls",
24    "--jscomp_error=ambiguousFunctionDecl",
25    "--jscomp_error=checkStructDictInheritance",
26    "--jscomp_error=checkTypes",
27    "--jscomp_error=checkVars",
28    "--jscomp_error=constantProperty",
29    "--jscomp_error=deprecated",
30    "--jscomp_error=externsValidation",
31    "--jscomp_error=globalThis",
32    "--jscomp_error=invalidCasts",
33    "--jscomp_error=misplacedTypeAnnotation",
34    "--jscomp_error=missingProperties",
35    "--jscomp_error=missingReturn",
36    "--jscomp_error=nonStandardJsDocs",
37    "--jscomp_error=suspiciousCode",
38    "--jscomp_error=undefinedNames",
39    "--jscomp_error=undefinedVars",
40    "--jscomp_error=unknownDefines",
41    "--jscomp_error=uselessCode",
42    "--jscomp_error=visibility",
43    # TODO(dbeam): happens when the same file is <include>d multiple times.
44    "--jscomp_off=duplicate",
45    "--language_in=ECMASCRIPT5_STRICT",
46    "--summary_detail_level=3",
47  ]
48
49  _JAR_COMMAND = [
50    "java",
51    "-jar",
52    "-Xms1024m",
53    "-client",
54    "-XX:+TieredCompilation"
55  ]
56
57  _found_java = False
58
59  def __init__(self, verbose=False):
60    current_dir = os.path.join(os.path.dirname(__file__))
61    self._compiler_jar = os.path.join(current_dir, "lib", "compiler.jar")
62    self._runner_jar = os.path.join(current_dir, "runner", "runner.jar")
63    self._temp_files = []
64    self._verbose = verbose
65
66  def _clean_up(self):
67    if not self._temp_files:
68      return
69
70    self._debug("Deleting temporary files: %s" % ", ".join(self._temp_files))
71    for f in self._temp_files:
72      os.remove(f)
73    self._temp_files = []
74
75  def _debug(self, msg, error=False):
76    if self._verbose:
77      print "(INFO) %s" % msg
78
79  def _error(self, msg):
80    print >> sys.stderr, "(ERROR) %s" % msg
81    self._clean_up()
82
83  def _run_command(self, cmd):
84    """Runs a shell command.
85
86    Args:
87        cmd: A list of tokens to be joined into a shell command.
88
89    Return:
90        True if the exit code was 0, else False.
91    """
92    cmd_str = " ".join(cmd)
93    self._debug("Running command: %s" % cmd_str)
94
95    devnull = open(os.devnull, "w")
96    return subprocess.Popen(
97        cmd_str, stdout=devnull, stderr=subprocess.PIPE, shell=True)
98
99  def _check_java_path(self):
100    """Checks that `java` is on the system path."""
101    if not self._found_java:
102      proc = self._run_command(["which", "java"])
103      proc.communicate()
104      if proc.returncode == 0:
105        self._found_java = True
106      else:
107        self._error("Cannot find java (`which java` => %s)" % proc.returncode)
108
109    return self._found_java
110
111  def _run_jar(self, jar, args=None):
112    args = args or []
113    self._check_java_path()
114    return self._run_command(self._JAR_COMMAND + [jar] + args)
115
116  def _fix_line_number(self, match):
117    """Changes a line number from /tmp/file:300 to /orig/file:100.
118
119    Args:
120        match: A re.MatchObject from matching against a line number regex.
121
122    Returns:
123        The fixed up /file and :line number.
124    """
125    real_file = self._processor.get_file_from_line(match.group(1))
126    return "%s:%d" % (os.path.abspath(real_file.file), real_file.line_number)
127
128  def _fix_up_error(self, error):
129    """Filter out irrelevant errors or fix line numbers.
130
131    Args:
132        error: A Closure compiler error (2 line string with error and source).
133
134    Return:
135        The fixed up erorr string (blank if it should be ignored).
136    """
137    if " first declared in " in error:
138      # Ignore "Variable x first declared in /same/file".
139      return ""
140
141    expanded_file = self._expanded_file
142    fixed = re.sub("%s:(\d+)" % expanded_file, self._fix_line_number, error)
143    return fixed.replace(expanded_file, os.path.abspath(self._file_arg))
144
145  def _format_errors(self, errors):
146    """Formats Closure compiler errors to easily spot compiler output."""
147    errors = filter(None, errors)
148    contents = "\n## ".join("\n\n".join(errors).splitlines())
149    return "## %s" % contents if contents else ""
150
151  def _create_temp_file(self, contents):
152    with tempfile.NamedTemporaryFile(mode="wt", delete=False) as tmp_file:
153      self._temp_files.append(tmp_file.name)
154      tmp_file.write(contents)
155    return tmp_file.name
156
157  def check(self, source_file, depends=None, externs=None):
158    """Closure compile a file and check for errors.
159
160    Args:
161        source_file: A file to check.
162        depends: Other files that would be included with a <script> earlier in
163            the page.
164        externs: @extern files that inform the compiler about custom globals.
165
166    Returns:
167        (exitcode, output) The exit code of the Closure compiler (as a number)
168            and its output (as a string).
169    """
170    depends = depends or []
171    externs = externs or []
172
173    if not self._check_java_path():
174      return 1, ""
175
176    self._debug("FILE: %s" % source_file)
177
178    if source_file.endswith("_externs.js"):
179      self._debug("Skipping externs: %s" % source_file)
180      return
181
182    self._file_arg = source_file
183
184    tmp_dir = tempfile.gettempdir()
185    rel_path = lambda f: os.path.join(os.path.relpath(os.getcwd(), tmp_dir), f)
186
187    includes = [rel_path(f) for f in depends + [source_file]]
188    contents = ['<include src="%s">' % i for i in includes]
189    meta_file = self._create_temp_file("\n".join(contents))
190    self._debug("Meta file: %s" % meta_file)
191
192    self._processor = processor.Processor(meta_file)
193    self._expanded_file = self._create_temp_file(self._processor.contents)
194    self._debug("Expanded file: %s" % self._expanded_file)
195
196    args = ["--js=%s" % self._expanded_file]
197    args += ["--externs=%s" % e for e in externs]
198    args_file_content = " %s" % " ".join(self._COMMON_CLOSURE_ARGS + args)
199    self._debug("Args: %s" % args_file_content.strip())
200
201    args_file = self._create_temp_file(args_file_content)
202    self._debug("Args file: %s" % args_file)
203
204    runner_args = ["--compiler-args-file=%s" % args_file]
205    runner_cmd = self._run_jar(self._runner_jar, args=runner_args)
206    (_, stderr) = runner_cmd.communicate()
207
208    errors = stderr.strip().split("\n\n")
209    self._debug("Summary: %s" % errors.pop())
210
211    output = self._format_errors(map(self._fix_up_error, errors))
212    if runner_cmd.returncode:
213      self._error("Error in: %s%s" % (source_file, "\n" + output if output else ""))
214    elif output:
215      self._debug("Output: %s" % output)
216
217    self._clean_up()
218
219    return runner_cmd.returncode, output
220
221
222if __name__ == "__main__":
223  parser = argparse.ArgumentParser(
224      description="Typecheck JavaScript using Closure compiler")
225  parser.add_argument("sources", nargs=argparse.ONE_OR_MORE,
226                      help="Path to a source file to typecheck")
227  parser.add_argument("-d", "--depends", nargs=argparse.ZERO_OR_MORE)
228  parser.add_argument("-e", "--externs", nargs=argparse.ZERO_OR_MORE)
229  parser.add_argument("-o", "--out_file", help="A place to output results")
230  parser.add_argument("-v", "--verbose", action="store_true",
231                      help="Show more information as this script runs")
232  opts = parser.parse_args()
233
234  checker = Checker(verbose=opts.verbose)
235  for source in opts.sources:
236    exit, _ = checker.check(source, depends=opts.depends, externs=opts.externs)
237    if exit != 0:
238      sys.exit(exit)
239
240    if opts.out_file:
241      out_dir = os.path.dirname(opts.out_file)
242      if not os.path.exists(out_dir):
243        os.makedirs(out_dir)
244      # TODO(dbeam): write compiled file to |opts.out_file|.
245      open(opts.out_file, "w").write("")
246