OLD | NEW |
(Empty) | |
| 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 JavaScript files to check for errors and produce |
| 7 minified output.""" |
| 8 |
| 9 import argparse |
| 10 import os |
| 11 import re |
| 12 import subprocess |
| 13 import sys |
| 14 import tempfile |
| 15 |
| 16 import build.inputs |
| 17 import processor |
| 18 import error_filter |
| 19 |
| 20 |
| 21 _CURRENT_DIR = os.path.join(os.path.dirname(__file__)) |
| 22 |
| 23 |
| 24 class Checker(object): |
| 25 """Runs the Closure compiler on given source files to typecheck them |
| 26 and produce minified output.""" |
| 27 |
| 28 _JAR_COMMAND = [ |
| 29 "java", |
| 30 "-jar", |
| 31 "-Xms1024m", |
| 32 "-client", |
| 33 "-XX:+TieredCompilation" |
| 34 ] |
| 35 |
| 36 _MAP_FILE_FORMAT = "%s.map" |
| 37 |
| 38 def __init__(self, verbose=False): |
| 39 """ |
| 40 Args: |
| 41 verbose: Whether this class should output diagnostic messages. |
| 42 """ |
| 43 self._compiler_jar = os.path.join(_CURRENT_DIR, "compiler", "compiler.jar") |
| 44 self._temp_files = [] |
| 45 self._verbose = verbose |
| 46 self._error_filter = error_filter.PromiseErrorFilter() |
| 47 |
| 48 def _nuke_temp_files(self): |
| 49 """Deletes any temp files this class knows about.""" |
| 50 if not self._temp_files: |
| 51 return |
| 52 |
| 53 self._log_debug("Deleting temp files: %s" % ", ".join(self._temp_files)) |
| 54 for f in self._temp_files: |
| 55 os.remove(f) |
| 56 self._temp_files = [] |
| 57 |
| 58 def _log_debug(self, msg, error=False): |
| 59 """Logs |msg| to stdout if --verbose/-v is passed when invoking this script. |
| 60 |
| 61 Args: |
| 62 msg: A debug message to log. |
| 63 """ |
| 64 if self._verbose: |
| 65 print "(INFO) %s" % msg |
| 66 |
| 67 def _log_error(self, msg): |
| 68 """Logs |msg| to stderr regardless of --flags. |
| 69 |
| 70 Args: |
| 71 msg: An error message to log. |
| 72 """ |
| 73 print >> sys.stderr, "(ERROR) %s" % msg |
| 74 |
| 75 def _run_jar(self, jar, args): |
| 76 """Runs a .jar from the command line with arguments. |
| 77 |
| 78 Args: |
| 79 jar: A file path to a .jar file |
| 80 args: A list of command line arguments to be passed when running the .jar. |
| 81 |
| 82 Return: |
| 83 (exit_code, stderr) The exit code of the command (e.g. 0 for success) and |
| 84 the stderr collected while running |jar| (as a string). |
| 85 """ |
| 86 shell_command = " ".join(self._JAR_COMMAND + [jar] + args) |
| 87 self._log_debug("Running jar: %s" % shell_command) |
| 88 |
| 89 devnull = open(os.devnull, "w") |
| 90 kwargs = {"stdout": devnull, "stderr": subprocess.PIPE, "shell": True} |
| 91 process = subprocess.Popen(shell_command, **kwargs) |
| 92 _, stderr = process.communicate() |
| 93 return process.returncode, stderr |
| 94 |
| 95 def _get_line_number(self, match): |
| 96 """When chrome is built, it preprocesses its JavaScript from: |
| 97 |
| 98 <include src="blah.js"> |
| 99 alert(1); |
| 100 |
| 101 to: |
| 102 |
| 103 /* contents of blah.js inlined */ |
| 104 alert(1); |
| 105 |
| 106 Because Closure Compiler requires this inlining already be done (as |
| 107 <include> isn't valid JavaScript), this script creates temporary files to |
| 108 expand all the <include>s. |
| 109 |
| 110 When type errors are hit in temporary files, a developer doesn't know the |
| 111 original source location to fix. This method maps from /tmp/file:300 back to |
| 112 /original/source/file:100 so fixing errors is faster for developers. |
| 113 |
| 114 Args: |
| 115 match: A re.MatchObject from matching against a line number regex. |
| 116 |
| 117 Returns: |
| 118 The fixed up /file and :line number. |
| 119 """ |
| 120 real_file = self._processor.get_file_from_line(match.group(1)) |
| 121 return "%s:%d" % (os.path.abspath(real_file.file), real_file.line_number) |
| 122 |
| 123 def _filter_errors(self, errors): |
| 124 """Removes some extraneous errors. For example, we ignore: |
| 125 |
| 126 Variable x first declared in /tmp/expanded/file |
| 127 |
| 128 Because it's just a duplicated error (it'll only ever show up 2+ times). |
| 129 We also ignore Promose-based errors: |
| 130 |
| 131 found : function (VolumeInfo): (Promise<(DirectoryEntry|null)>|null) |
| 132 required: (function (Promise<VolumeInfo>): ?|null|undefined) |
| 133 |
| 134 as templates don't work with Promises in all cases yet. See |
| 135 https://github.com/google/closure-compiler/issues/715 for details. |
| 136 |
| 137 Args: |
| 138 errors: A list of string errors extracted from Closure Compiler output. |
| 139 |
| 140 Return: |
| 141 A slimmer, sleeker list of relevant errors (strings). |
| 142 """ |
| 143 first_declared_in = lambda e: " first declared in " not in e |
| 144 return self._error_filter.filter(filter(first_declared_in, errors)) |
| 145 |
| 146 def _clean_up_error(self, error): |
| 147 """Reverse the effects that funky <include> preprocessing steps have on |
| 148 errors messages. |
| 149 |
| 150 Args: |
| 151 error: A Closure compiler error (2 line string with error and source). |
| 152 |
| 153 Return: |
| 154 The fixed up error string. |
| 155 """ |
| 156 expanded_file = self._expanded_file |
| 157 fixed = re.sub("%s:(\d+)" % expanded_file, self._get_line_number, error) |
| 158 return fixed.replace(expanded_file, os.path.abspath(self._file_arg)) |
| 159 |
| 160 def _format_errors(self, errors): |
| 161 """Formats Closure compiler errors to easily spot compiler output. |
| 162 |
| 163 Args: |
| 164 errors: A list of strings extracted from the Closure compiler's output. |
| 165 |
| 166 Returns: |
| 167 A formatted output string. |
| 168 """ |
| 169 contents = "\n## ".join("\n\n".join(errors).splitlines()) |
| 170 return "## %s" % contents if contents else "" |
| 171 |
| 172 def _create_temp_file(self, contents): |
| 173 """Creates an owned temporary file with |contents|. |
| 174 |
| 175 Args: |
| 176 content: A string of the file contens to write to a temporary file. |
| 177 |
| 178 Return: |
| 179 The filepath of the newly created, written, and closed temporary file. |
| 180 """ |
| 181 with tempfile.NamedTemporaryFile(mode="wt", delete=False) as tmp_file: |
| 182 self._temp_files.append(tmp_file.name) |
| 183 tmp_file.write(contents) |
| 184 return tmp_file.name |
| 185 |
| 186 def _run_js_check(self, sources, out_file, externs=None, |
| 187 closure_args=None): |
| 188 """Check |sources| for type errors. |
| 189 |
| 190 Args: |
| 191 sources: Files to check. |
| 192 out_file: A file where the compiled output is written to. |
| 193 externs: @extern files that inform the compiler about custom globals. |
| 194 closure_args: Arguments passed directly to the Closure compiler. |
| 195 |
| 196 Returns: |
| 197 (errors, stderr) A parsed list of errors (strings) found by the compiler |
| 198 and the raw stderr (as a string). |
| 199 """ |
| 200 args = ["--js=%s" % s for s in sources] |
| 201 |
| 202 assert out_file |
| 203 |
| 204 out_dir = os.path.dirname(out_file) |
| 205 if not os.path.exists(out_dir): |
| 206 os.makedirs(out_dir) |
| 207 |
| 208 checks_only = 'checks_only' in closure_args |
| 209 |
| 210 if not checks_only: |
| 211 args += ["--js_output_file=%s" % out_file] |
| 212 args += ["--create_source_map=%s" % (self._MAP_FILE_FORMAT % out_file)] |
| 213 |
| 214 args += ["--externs=%s" % e for e in externs or []] |
| 215 |
| 216 closure_args = closure_args or [] |
| 217 closure_args += ["summary_detail_level=3", "continue_after_errors"] |
| 218 args += ["--%s" % arg for arg in closure_args] |
| 219 |
| 220 self._log_debug("Args: %s" % " ".join(args)) |
| 221 |
| 222 _, stderr = self._run_jar(self._compiler_jar, args) |
| 223 |
| 224 errors = stderr.strip().split("\n\n") |
| 225 maybe_summary = errors.pop() |
| 226 |
| 227 if re.search(".*error.*warning.*typed", maybe_summary): |
| 228 self._log_debug("Summary: %s" % maybe_summary) |
| 229 else: |
| 230 # Not a summary. Running the jar failed. Bail. |
| 231 self._log_error(stderr) |
| 232 self._nuke_temp_files() |
| 233 sys.exit(1) |
| 234 |
| 235 if errors: |
| 236 if os.path.exists(out_file): |
| 237 os.remove(out_file) |
| 238 if os.path.exists(self._MAP_FILE_FORMAT % out_file): |
| 239 os.remove(self._MAP_FILE_FORMAT % out_file) |
| 240 elif checks_only: |
| 241 # Compile succeeded but --checks_only disables --js_output_file from |
| 242 # actually writing a file. Write a file ourselves so incremental builds |
| 243 # still work. |
| 244 with open(out_file, 'w') as f: |
| 245 f.write('') |
| 246 |
| 247 return errors, stderr |
| 248 |
| 249 def check(self, source_file, out_file=None, depends=None, externs=None, |
| 250 closure_args=None): |
| 251 """Closure compiler |source_file| while checking for errors. |
| 252 |
| 253 Args: |
| 254 source_file: A file to check. |
| 255 out_file: A file where the compiled output is written to. |
| 256 depends: Files that |source_file| requires to run (e.g. earlier <script>). |
| 257 externs: @extern files that inform the compiler about custom globals. |
| 258 closure_args: Arguments passed directly to the Closure compiler. |
| 259 |
| 260 Returns: |
| 261 (found_errors, stderr) A boolean indicating whether errors were found and |
| 262 the raw Closure compiler stderr (as a string). |
| 263 """ |
| 264 self._log_debug("FILE: %s" % source_file) |
| 265 |
| 266 if source_file.endswith("_externs.js"): |
| 267 self._log_debug("Skipping externs: %s" % source_file) |
| 268 return |
| 269 |
| 270 self._file_arg = source_file |
| 271 |
| 272 cwd, tmp_dir = os.getcwd(), tempfile.gettempdir() |
| 273 rel_path = lambda f: os.path.join(os.path.relpath(cwd, tmp_dir), f) |
| 274 |
| 275 depends = depends or [] |
| 276 includes = [rel_path(f) for f in depends + [source_file]] |
| 277 contents = ['<include src="%s">' % i for i in includes] |
| 278 meta_file = self._create_temp_file("\n".join(contents)) |
| 279 self._log_debug("Meta file: %s" % meta_file) |
| 280 |
| 281 self._processor = processor.Processor(meta_file) |
| 282 self._expanded_file = self._create_temp_file(self._processor.contents) |
| 283 self._log_debug("Expanded file: %s" % self._expanded_file) |
| 284 |
| 285 errors, stderr = self._run_js_check([self._expanded_file], |
| 286 out_file=out_file, externs=externs, |
| 287 closure_args=closure_args) |
| 288 filtered_errors = self._filter_errors(errors) |
| 289 cleaned_errors = map(self._clean_up_error, filtered_errors) |
| 290 output = self._format_errors(cleaned_errors) |
| 291 |
| 292 if cleaned_errors: |
| 293 prefix = "\n" if output else "" |
| 294 self._log_error("Error in: %s%s%s" % (source_file, prefix, output)) |
| 295 elif output: |
| 296 self._log_debug("Output: %s" % output) |
| 297 |
| 298 self._nuke_temp_files() |
| 299 return bool(cleaned_errors), stderr |
| 300 |
| 301 def check_multiple(self, sources, out_file=None, externs=None, |
| 302 closure_args=None): |
| 303 """Closure compile a set of files and check for errors. |
| 304 |
| 305 Args: |
| 306 sources: An array of files to check. |
| 307 out_file: A file where the compiled output is written to. |
| 308 externs: @extern files that inform the compiler about custom globals. |
| 309 closure_args: Arguments passed directly to the Closure compiler. |
| 310 |
| 311 Returns: |
| 312 (found_errors, stderr) A boolean indicating whether errors were found and |
| 313 the raw Closure Compiler stderr (as a string). |
| 314 """ |
| 315 errors, stderr = self._run_js_check(sources, out_file=out_file, |
| 316 externs=externs, |
| 317 closure_args=closure_args) |
| 318 self._nuke_temp_files() |
| 319 return bool(errors), stderr |
| 320 |
| 321 |
| 322 if __name__ == "__main__": |
| 323 parser = argparse.ArgumentParser( |
| 324 description="Typecheck JavaScript using Closure compiler") |
| 325 parser.add_argument("sources", nargs=argparse.ONE_OR_MORE, |
| 326 help="Path to a source file to typecheck") |
| 327 single_file_group = parser.add_mutually_exclusive_group() |
| 328 single_file_group.add_argument("--single_file", dest="single_file", |
| 329 action="store_true", |
| 330 help="Process each source file individually") |
| 331 # TODO(twellington): remove --no_single_file and use len(opts.sources). |
| 332 single_file_group.add_argument("--no_single_file", dest="single_file", |
| 333 action="store_false", |
| 334 help="Process all source files as a group") |
| 335 parser.add_argument("-d", "--depends", nargs=argparse.ZERO_OR_MORE) |
| 336 parser.add_argument("-e", "--externs", nargs=argparse.ZERO_OR_MORE) |
| 337 parser.add_argument("-o", "--out_file", required=True, |
| 338 help="A file where the compiled output is written to") |
| 339 parser.add_argument("-c", "--closure_args", nargs=argparse.ZERO_OR_MORE, |
| 340 help="Arguments passed directly to the Closure compiler") |
| 341 parser.add_argument("-v", "--verbose", action="store_true", |
| 342 help="Show more information as this script runs") |
| 343 |
| 344 parser.set_defaults(single_file=True) |
| 345 opts = parser.parse_args() |
| 346 |
| 347 depends = opts.depends or [] |
| 348 # TODO(devlin): should we run normpath() on this first and/or do this for |
| 349 # depends as well? |
| 350 externs = set(opts.externs or []) |
| 351 sources = set(opts.sources) |
| 352 |
| 353 externs.add(os.path.join(_CURRENT_DIR, "externs", "polymer-1.0.js")) |
| 354 |
| 355 checker = Checker(verbose=opts.verbose) |
| 356 if opts.single_file: |
| 357 for source in sources: |
| 358 # Normalize source to the current directory. |
| 359 source = os.path.normpath(os.path.join(os.getcwd(), source)) |
| 360 depends, externs = build.inputs.resolve_recursive_dependencies( |
| 361 source, depends, externs) |
| 362 |
| 363 found_errors, _ = checker.check(source, out_file=opts.out_file, |
| 364 depends=depends, externs=externs, |
| 365 closure_args=opts.closure_args) |
| 366 if found_errors: |
| 367 sys.exit(1) |
| 368 else: |
| 369 found_errors, stderr = checker.check_multiple( |
| 370 sources, |
| 371 out_file=opts.out_file, |
| 372 externs=externs, |
| 373 closure_args=opts.closure_args) |
| 374 if found_errors: |
| 375 print stderr |
| 376 sys.exit(1) |
OLD | NEW |