1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5from __future__ import absolute_import, print_function
6
7from argparse import ArgumentParser
8import json
9import os
10
11try:
12    import urlparse
13except ImportError:
14    import urllib.parse as urlparse
15
16from six import viewitems
17
18from mozpack.chrome.manifest import parse_manifest
19import mozpack.path as mozpath
20from .manifest_handler import ChromeManifestHandler
21
22
23class LcovRecord(object):
24    __slots__ = ("test_name",
25                 "source_file",
26                 "functions",
27                 "function_exec_counts",
28                 "function_count",
29                 "covered_function_count",
30                 "branches",
31                 "branch_count",
32                 "covered_branch_count",
33                 "lines",
34                 "line_count",
35                 "covered_line_count")
36
37    def __init__(self):
38        self.functions = {}
39        self.function_exec_counts = {}
40        self.branches = {}
41        self.lines = {}
42
43    def __iadd__(self, other):
44
45        # These shouldn't differ.
46        self.source_file = other.source_file
47        if hasattr(other, 'test_name'):
48            self.test_name = other.test_name
49        self.functions.update(other.functions)
50
51        for name, count in viewitems(other.function_exec_counts):
52            self.function_exec_counts[name] = count + self.function_exec_counts.get(name, 0)
53
54        for key, taken in viewitems(other.branches):
55            self.branches[key] = taken + self.branches.get(key, 0)
56
57        for line, (exec_count, checksum) in viewitems(other.lines):
58            new_exec_count = exec_count
59            if line in self.lines:
60                old_exec_count, _ = self.lines[line]
61                new_exec_count += old_exec_count
62            self.lines[line] = new_exec_count, checksum
63
64        self.resummarize()
65        return self
66
67    def resummarize(self):
68        # Re-calculate summaries after generating or splitting a record.
69        self.function_count = len(self.functions.keys())
70        # Function records may have moved between files, so filter here.
71        self.function_exec_counts = {
72            fn_name: count for fn_name, count in viewitems(self.function_exec_counts)
73            if fn_name in self.functions.values()}
74        self.covered_function_count = len([c for c in self.function_exec_counts.values() if c])
75        self.line_count = len(self.lines)
76        self.covered_line_count = len([c for c, _ in self.lines.values() if c])
77        self.branch_count = len(self.branches)
78        self.covered_branch_count = len([c for c in self.branches.values() if c])
79
80
81class RecordRewriter(object):
82    # Helper class for rewriting/spliting individual lcov records according
83    # to what the preprocessor did.
84    def __init__(self):
85        self._ranges = None
86
87    def _get_range(self, line):
88        for start, end in self._ranges:
89            if line < start:
90                return None
91            if line < end:
92                return start, end
93        return None
94
95    def _get_mapped_line(self, line, r):
96        inc_source, inc_start = self._current_pp_info[r]
97        start, end = r
98        offs = line - start
99        return inc_start + offs
100
101    def _get_record(self, inc_source):
102        if inc_source in self._additions:
103            gen_rec = self._additions[inc_source]
104        else:
105            gen_rec = LcovRecord()
106            gen_rec.source_file = inc_source
107            self._additions[inc_source] = gen_rec
108        return gen_rec
109
110    def _rewrite_lines(self, record):
111        rewritten_lines = {}
112        for ln, line_info in viewitems(record.lines):
113            r = self._get_range(ln)
114            if r is None:
115                rewritten_lines[ln] = line_info
116                continue
117            new_ln = self._get_mapped_line(ln, r)
118            inc_source, _ = self._current_pp_info[r]
119
120            if inc_source != record.source_file:
121                gen_rec = self._get_record(inc_source)
122                gen_rec.lines[new_ln] = line_info
123                continue
124
125            # Move exec_count to the new lineno.
126            rewritten_lines[new_ln] = line_info
127
128        record.lines = rewritten_lines
129
130    def _rewrite_functions(self, record):
131        rewritten_fns = {}
132
133        # Sometimes we get multiple entries for a named function ("top-level", for
134        # instance). It's not clear the records that result are well-formed, but
135        # we act as though if a function has multiple FN's, the corresponding
136        # FNDA's are all the same.
137        for ln, fn_name in viewitems(record.functions):
138            r = self._get_range(ln)
139            if r is None:
140                rewritten_fns[ln] = fn_name
141                continue
142            new_ln = self._get_mapped_line(ln, r)
143            inc_source, _ = self._current_pp_info[r]
144            if inc_source != record.source_file:
145                gen_rec = self._get_record(inc_source)
146                gen_rec.functions[new_ln] = fn_name
147                if fn_name in record.function_exec_counts:
148                    gen_rec.function_exec_counts[fn_name] = record.function_exec_counts[fn_name]
149                continue
150            rewritten_fns[new_ln] = fn_name
151        record.functions = rewritten_fns
152
153    def _rewrite_branches(self, record):
154        rewritten_branches = {}
155        for (ln, block_number, branch_number), taken in viewitems(record.branches):
156            r = self._get_range(ln)
157            if r is None:
158                rewritten_branches[ln, block_number, branch_number] = taken
159                continue
160            new_ln = self._get_mapped_line(ln, r)
161            inc_source, _ = self._current_pp_info[r]
162            if inc_source != record.source_file:
163                gen_rec = self._get_record(inc_source)
164                gen_rec.branches[(new_ln, block_number, branch_number)] = taken
165                continue
166            rewritten_branches[(new_ln, block_number, branch_number)] = taken
167
168        record.branches = rewritten_branches
169
170    def rewrite_record(self, record, pp_info):
171        # Rewrite the lines in the given record according to preprocessor info
172        # and split to additional records when pp_info has included file info.
173        self._current_pp_info = dict(
174            [(tuple([int(l) for l in k.split(',')]), v) for k, v in pp_info.items()])
175        self._ranges = sorted(self._current_pp_info.keys())
176        self._additions = {}
177        self._rewrite_lines(record)
178        self._rewrite_functions(record)
179        self._rewrite_branches(record)
180
181        record.resummarize()
182
183        generated_records = self._additions.values()
184        for r in generated_records:
185            r.resummarize()
186        return generated_records
187
188
189class LcovFile(object):
190    # Simple parser/pretty-printer for lcov format.
191    # lcov parsing based on http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php
192
193    # TN:<test name>
194    # SF:<absolute path to the source file>
195    # FN:<line number of function start>,<function name>
196    # FNDA:<execution count>,<function name>
197    # FNF:<number of functions found>
198    # FNH:<number of function hit>
199    # BRDA:<line number>,<block number>,<branch number>,<taken>
200    # BRF:<number of branches found>
201    # BRH:<number of branches hit>
202    # DA:<line number>,<execution count>[,<checksum>]
203    # LF:<number of instrumented lines>
204    # LH:<number of lines with a non-zero execution count>
205    # end_of_record
206    PREFIX_TYPES = {
207      'TN': 0,
208      'SF': 0,
209      'FN': 1,
210      'FNDA': 1,
211      'FNF': 0,
212      'FNH': 0,
213      'BRDA': 3,
214      'BRF': 0,
215      'BRH': 0,
216      'DA': 2,
217      'LH': 0,
218      'LF': 0,
219    }
220
221    def __init__(self, lcov_paths):
222        self.lcov_paths = lcov_paths
223
224    def iterate_records(self, rewrite_source=None):
225        current_source_file = None
226        current_pp_info = None
227        current_lines = []
228        for lcov_path in self.lcov_paths:
229            with open(lcov_path) as lcov_fh:
230                for line in lcov_fh:
231                    line = line.rstrip()
232                    if not line:
233                        continue
234
235                    if line == 'end_of_record':
236                        # We skip records that we couldn't rewrite, that is records for which
237                        # rewrite_url returns None.
238                        if current_source_file is not None:
239                            yield (current_source_file, current_pp_info, current_lines)
240                        current_source_file = None
241                        current_pp_info = None
242                        current_lines = []
243                        continue
244
245                    colon = line.find(':')
246                    prefix = line[:colon]
247
248                    if prefix == 'SF':
249                        sf = line[(colon + 1):]
250                        res = rewrite_source(sf) if rewrite_source is not None else (sf, None)
251                        if res is None:
252                            current_lines.append(line)
253                        else:
254                            current_source_file, current_pp_info = res
255                            current_lines.append('SF:' + current_source_file)
256                    else:
257                        current_lines.append(line)
258
259    def parse_record(self, record_content):
260        self.current_record = LcovRecord()
261
262        for line in record_content:
263            colon = line.find(':')
264
265            prefix = line[:colon]
266
267            # We occasionally end up with multi-line scripts in data:
268            # uris that will trip up the parser, just skip them for now.
269            if colon < 0 or prefix not in self.PREFIX_TYPES:
270                continue
271
272            args = line[(colon + 1):].split(',', self.PREFIX_TYPES[prefix])
273
274            def try_convert(a):
275                try:
276                    return int(a)
277                except ValueError:
278                    return a
279            args = [try_convert(a) for a in args]
280
281            try:
282                LcovFile.__dict__['parse_' + prefix](self, *args)
283            except ValueError:
284                print("Encountered an error in %s:\n%s" %
285                      (self.lcov_fh.name, line))
286                raise
287            except KeyError:
288                print("Invalid lcov line start in %s:\n%s" %
289                      (self.lcov_fh.name, line))
290                raise
291            except TypeError:
292                print("Invalid lcov line start in %s:\n%s" %
293                      (self.lcov_fh.name, line))
294                raise
295
296        ret = self.current_record
297        self.current_record = LcovRecord()
298        return ret
299
300    def print_file(self, fh, rewrite_source, rewrite_record):
301        for source_file, pp_info, record_content in self.iterate_records(rewrite_source):
302            if pp_info is not None:
303                record = self.parse_record(record_content)
304                for r in rewrite_record(record, pp_info):
305                    fh.write(self.format_record(r))
306                fh.write(self.format_record(record))
307            else:
308                fh.write('\n'.join(record_content) + '\nend_of_record\n')
309
310    def format_record(self, record):
311        out_lines = []
312        for name in LcovRecord.__slots__:
313            if hasattr(record, name):
314                out_lines.append(LcovFile.__dict__['format_' + name](self, record))
315        return '\n'.join(out_lines) + '\nend_of_record\n'
316
317    def format_test_name(self, record):
318        return "TN:%s" % record.test_name
319
320    def format_source_file(self, record):
321        return "SF:%s" % record.source_file
322
323    def format_functions(self, record):
324        # Sorting results gives deterministic output (and is a lot faster than
325        # using OrderedDict).
326        fns = []
327        for start_lineno, fn_name in sorted(viewitems(record.functions)):
328            fns.append('FN:%s,%s' % (start_lineno, fn_name))
329        return '\n'.join(fns)
330
331    def format_function_exec_counts(self, record):
332        fndas = []
333        for name, exec_count in sorted(viewitems(record.function_exec_counts)):
334            fndas.append('FNDA:%s,%s' % (exec_count, name))
335        return '\n'.join(fndas)
336
337    def format_function_count(self, record):
338        return 'FNF:%s' % record.function_count
339
340    def format_covered_function_count(self, record):
341        return 'FNH:%s' % record.covered_function_count
342
343    def format_branches(self, record):
344        brdas = []
345        for key in sorted(record.branches):
346            taken = record.branches[key]
347            taken = '-' if taken == 0 else taken
348            brdas.append('BRDA:%s' %
349                         ','.join(map(str, list(key) + [taken])))
350        return '\n'.join(brdas)
351
352    def format_branch_count(self, record):
353        return 'BRF:%s' % record.branch_count
354
355    def format_covered_branch_count(self, record):
356        return 'BRH:%s' % record.covered_branch_count
357
358    def format_lines(self, record):
359        das = []
360        for line_no, (exec_count, checksum) in sorted(viewitems(record.lines)):
361            s = 'DA:%s,%s' % (line_no, exec_count)
362            if checksum:
363                s += ',%s' % checksum
364            das.append(s)
365        return '\n'.join(das)
366
367    def format_line_count(self, record):
368        return 'LF:%s' % record.line_count
369
370    def format_covered_line_count(self, record):
371        return 'LH:%s' % record.covered_line_count
372
373    def parse_TN(self, test_name):
374        self.current_record.test_name = test_name
375
376    def parse_SF(self, source_file):
377        self.current_record.source_file = source_file
378
379    def parse_FN(self, start_lineno, fn_name):
380        self.current_record.functions[start_lineno] = fn_name
381
382    def parse_FNDA(self, exec_count, fn_name):
383        self.current_record.function_exec_counts[fn_name] = exec_count
384
385    def parse_FNF(self, function_count):
386        self.current_record.function_count = function_count
387
388    def parse_FNH(self, covered_function_count):
389        self.current_record.covered_function_count = covered_function_count
390
391    def parse_BRDA(self, line_number, block_number, branch_number, taken):
392        taken = 0 if taken == '-' else taken
393        self.current_record.branches[(line_number, block_number,
394                                      branch_number)] = taken
395
396    def parse_BRF(self, branch_count):
397        self.current_record.branch_count = branch_count
398
399    def parse_BRH(self, covered_branch_count):
400        self.current_record.covered_branch_count = covered_branch_count
401
402    def parse_DA(self, line_number, execution_count, checksum=None):
403        self.current_record.lines[line_number] = (execution_count, checksum)
404
405    def parse_LH(self, covered_line_count):
406        self.current_record.covered_line_count = covered_line_count
407
408    def parse_LF(self, line_count):
409        self.current_record.line_count = line_count
410
411
412class UrlFinderError(Exception):
413    pass
414
415
416class UrlFinder(object):
417    # Given a "chrome://" or "resource://" url, uses data from the UrlMapBackend
418    # and install manifests to find a path to the source file and the corresponding
419    # (potentially pre-processed) file in the objdir.
420    def __init__(self, chrome_map_path, appdir, gredir, extra_chrome_manifests):
421        # Cached entries
422        self._final_mapping = {}
423
424        try:
425            with open(chrome_map_path) as fh:
426                url_prefixes, overrides, install_info, buildconfig = json.load(fh)
427        except IOError:
428            print("Error reading %s. Run |./mach build-backend -b ChromeMap| to "
429                  "populate the ChromeMap backend." % chrome_map_path)
430            raise
431
432        self.topobjdir = buildconfig['topobjdir']
433        self.MOZ_APP_NAME = buildconfig['MOZ_APP_NAME']
434        self.OMNIJAR_NAME = buildconfig['OMNIJAR_NAME']
435
436        # These are added dynamically in nsIResProtocolHandler, we might
437        # need to get them at run time.
438        if "resource:///" not in url_prefixes:
439            url_prefixes["resource:///"] = [appdir]
440        if "resource://gre/" not in url_prefixes:
441            url_prefixes["resource://gre/"] = [gredir]
442
443        self._url_prefixes = url_prefixes
444        self._url_overrides = overrides
445
446        self._respath = None
447
448        mac_bundle_name = buildconfig['MOZ_MACBUNDLE_NAME']
449        if mac_bundle_name:
450            self._respath = mozpath.join('dist',
451                                         mac_bundle_name,
452                                         'Contents',
453                                         'Resources')
454
455        if not extra_chrome_manifests:
456            extra_path = os.path.join(self.topobjdir, '_tests', 'extra.manifest')
457            if os.path.isfile(extra_path):
458                extra_chrome_manifests = [extra_path]
459
460        if extra_chrome_manifests:
461            self._populate_chrome(extra_chrome_manifests)
462
463        self._install_mapping = install_info
464
465    def _populate_chrome(self, manifests):
466        handler = ChromeManifestHandler()
467        for m in manifests:
468            path = os.path.abspath(m)
469            for e in parse_manifest(None, path):
470                handler.handle_manifest_entry(e)
471        self._url_overrides.update(handler.overrides)
472        self._url_prefixes.update(handler.chrome_mapping)
473
474    def _find_install_prefix(self, objdir_path):
475
476        def _prefix(s):
477            for p in mozpath.split(s):
478                if '*' not in p:
479                    yield p + '/'
480
481        offset = 0
482        for leaf in reversed(mozpath.split(objdir_path)):
483            offset += len(leaf)
484            if objdir_path[:-offset] in self._install_mapping:
485                pattern_prefix, is_pp = self._install_mapping[objdir_path[:-offset]]
486                full_leaf = objdir_path[len(objdir_path) - offset:]
487                src_prefix = ''.join(_prefix(pattern_prefix))
488                self._install_mapping[objdir_path] = (mozpath.join(src_prefix, full_leaf),
489                                                      is_pp)
490                break
491            offset += 1
492
493    def _install_info(self, objdir_path):
494        if objdir_path not in self._install_mapping:
495            # If our path is missing, some prefix of it may be in the install
496            # mapping mapped to a wildcard.
497            self._find_install_prefix(objdir_path)
498        if objdir_path not in self._install_mapping:
499            raise UrlFinderError("Couldn't find entry in manifest for %s" %
500                                 objdir_path)
501        return self._install_mapping[objdir_path]
502
503    def _abs_objdir_install_info(self, term):
504        obj_relpath = term[len(self.topobjdir) + 1:]
505        res = self._install_info(obj_relpath)
506
507        # Some urls on osx will refer to paths in the mac bundle, so we
508        # re-interpret them as being their original location in dist/bin.
509        if (not res and self._respath and
510            obj_relpath.startswith(self._respath)):
511            obj_relpath = obj_relpath.replace(self._respath, 'dist/bin')
512            res = self._install_info(obj_relpath)
513
514        if not res:
515            raise UrlFinderError("Couldn't find entry in manifest for %s" %
516                                 obj_relpath)
517        return res
518
519    def find_files(self, url):
520        # Returns a tuple of (source file, pp_info)
521        # for the given "resource:", "chrome:", or "file:" uri.
522        term = url
523        if term in self._url_overrides:
524            term = self._url_overrides[term]
525
526        if os.path.isabs(term) and term.startswith(self.topobjdir):
527            source_path, pp_info = self._abs_objdir_install_info(term)
528            return source_path, pp_info
529
530        for prefix, dests in viewitems(self._url_prefixes):
531            if term.startswith(prefix):
532                for dest in dests:
533                    if not dest.endswith('/'):
534                        dest += '/'
535                    objdir_path = term.replace(prefix, dest)
536
537                    while objdir_path.startswith('//'):
538                        # The mochitest harness produces some wonky file:// uris
539                        # that need to be fixed.
540                        objdir_path = objdir_path[1:]
541
542                    try:
543                        if os.path.isabs(objdir_path) and objdir_path.startswith(self.topobjdir):
544                            return self._abs_objdir_install_info(objdir_path)
545                        else:
546                            src_path, pp_info = self._install_info(objdir_path)
547                            return mozpath.normpath(src_path), pp_info
548                    except UrlFinderError:
549                        pass
550
551                    if (dest.startswith('resource://') or
552                        dest.startswith('chrome://')):
553                        result = self.find_files(term.replace(prefix, dest))
554                        if result:
555                            return result
556
557        raise UrlFinderError("No objdir path for %s" % term)
558
559    def rewrite_url(self, url):
560        # This applies one-off rules and returns None for urls that we aren't
561        # going to be able to resolve to a source file ("about:" urls, for
562        # instance).
563        if url in self._final_mapping:
564            return self._final_mapping[url]
565        if url.endswith('> eval'):
566            return None
567        if url.endswith('> Function'):
568            return None
569        if ' -> ' in url:
570            url = url.split(' -> ')[1].rstrip()
571        if '?' in url:
572            url = url.split('?')[0]
573
574        url_obj = urlparse.urlparse(url)
575        if url_obj.scheme == 'jar':
576            app_name = self.MOZ_APP_NAME
577            omnijar_name = self.OMNIJAR_NAME
578
579            if app_name in url:
580                if omnijar_name in url:
581                    # e.g. file:///home/worker/workspace/build/application/firefox/omni.ja!/components/MainProcessSingleton.js  # noqa
582                    parts = url_obj.path.split(omnijar_name + '!', 1)
583                elif '.xpi!' in url:
584                    # e.g. file:///home/worker/workspace/build/application/firefox/browser/features/e10srollout@mozilla.org.xpi!/bootstrap.js  # noqa
585                    parts = url_obj.path.split('.xpi!', 1)
586                else:
587                    # We don't know how to handle this jar: path, so return it to the
588                    # caller to make it print a warning.
589                    return url_obj.path, None
590
591                dir_parts = parts[0].rsplit(app_name + '/', 1)
592                url = mozpath.normpath(
593                    mozpath.join(self.topobjdir, 'dist',
594                                 'bin', dir_parts[1].lstrip('/'), parts[1].lstrip('/'))
595                    )
596            elif '.xpi!' in url:
597                # This matching mechanism is quite brittle and based on examples seen in the wild.
598                # There's no rule to match the XPI name to the path in dist/xpi-stage.
599                parts = url_obj.path.split('.xpi!', 1)
600                addon_name = os.path.basename(parts[0])
601                if '-test@mozilla.org' in addon_name:
602                    addon_name = addon_name[:-len('-test@mozilla.org')]
603                elif addon_name.endswith('@mozilla.org'):
604                    addon_name = addon_name[:-len('@mozilla.org')]
605                url = mozpath.normpath(mozpath.join(self.topobjdir, 'dist',
606                                                    'xpi-stage', addon_name, parts[1].lstrip('/')))
607        elif url_obj.scheme == 'file' and os.path.isabs(url_obj.path):
608            path = url_obj.path
609            if not os.path.isfile(path):
610                # This may have been in a profile directory that no
611                # longer exists.
612                return None
613            if not path.startswith(self.topobjdir):
614                return path, None
615            url = url_obj.path
616        elif url_obj.scheme in ('http', 'https', 'javascript', 'data', 'about'):
617            return None
618
619        result = self.find_files(url)
620        self._final_mapping[url] = result
621        return result
622
623
624class LcovFileRewriter(object):
625    # Class for partial parses of LCOV format and rewriting to resolve urls
626    # and preprocessed file lines.
627    def __init__(self, chrome_map_path, appdir='dist/bin/browser/',
628                 gredir='dist/bin/', extra_chrome_manifests=[]):
629        self.url_finder = UrlFinder(chrome_map_path, appdir, gredir, extra_chrome_manifests)
630        self.pp_rewriter = RecordRewriter()
631
632    def rewrite_files(self, in_paths, output_file, output_suffix):
633        unknowns = set()
634        found_valid = [False]
635
636        def rewrite_source(url):
637            try:
638                res = self.url_finder.rewrite_url(url)
639                if res is None:
640                    return None
641            except Exception as e:
642                if url not in unknowns:
643                    print("Error: %s.\nCouldn't find source info for %s, removing record" %
644                          (e, url))
645                unknowns.add(url)
646                return None
647
648            source_file, pp_info = res
649            # We can't assert that the file exists here, because we don't have the source
650            # checkout available on test machines. We can bring back this assertion when
651            # bug 1432287 is fixed.
652            # assert os.path.isfile(source_file), "Couldn't find mapped source file %s at %s!" % (
653            #     url, source_file)
654
655            found_valid[0] = True
656
657            return res
658
659        in_paths = [os.path.abspath(in_path) for in_path in in_paths]
660
661        if output_file:
662            lcov_file = LcovFile(in_paths)
663            with open(output_file, 'w+') as out_fh:
664                lcov_file.print_file(out_fh, rewrite_source, self.pp_rewriter.rewrite_record)
665        else:
666            for in_path in in_paths:
667                lcov_file = LcovFile([in_path])
668                with open(in_path + output_suffix, 'w+') as out_fh:
669                    lcov_file.print_file(out_fh, rewrite_source, self.pp_rewriter.rewrite_record)
670
671        if not found_valid[0]:
672            print("WARNING: No valid records found in %s" % in_paths)
673            return
674
675
676def main():
677    parser = ArgumentParser(
678        description="Given a set of gcov .info files produced "
679        "by spidermonkey's code coverage, re-maps file urls "
680        "back to source files and lines in preprocessed files "
681        "back to their original locations."
682    )
683    parser.add_argument(
684        "--chrome-map-path", default="chrome-map.json", help="Path to the chrome-map.json file."
685    )
686    parser.add_argument(
687        "--app-dir",
688        default="dist/bin/browser/",
689        help="Prefix of the appdir in use. This is used to map "
690        "urls starting with resource:///. It may differ by "
691        "app, but defaults to the valid value for firefox.",
692    )
693    parser.add_argument(
694        "--gre-dir",
695        default="dist/bin/",
696        help="Prefix of the gre dir in use. This is used to map "
697        "urls starting with resource://gre. It may differ by "
698        "app, but defaults to the valid value for firefox.",
699    )
700    parser.add_argument(
701        "--output-suffix", default=".out", help="The suffix to append to output files."
702    )
703    parser.add_argument(
704        "--extra-chrome-manifests",
705        nargs='+',
706        help="Paths to files containing extra chrome registration.",
707    )
708    parser.add_argument(
709        "--output-file",
710        default="",
711        help="The output file where the results are merged. Leave empty to make the rewriter not "
712        "merge files.",
713    )
714    parser.add_argument("files", nargs='+', help="The set of files to process.")
715
716    args = parser.parse_args()
717
718    rewriter = LcovFileRewriter(args.chrome_map_path, args.app_dir, args.gre_dir,
719                                args.extra_chrome_manifests)
720
721    files = []
722    for f in args.files:
723        if os.path.isdir(f):
724            files += [os.path.join(f, e) for e in os.listdir(f)]
725        else:
726            files.append(f)
727
728    rewriter.rewrite_files(files, args.output_file, args.output_suffix)
729
730
731if __name__ == '__main__':
732    main()
733