#!/usr/bin/env python3
# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Extract source file information from .ninja files."""

import argparse
import io
import json
import logging
import os
import re
import sys

sys.path.insert(1, os.path.join(os.path.dirname(__file__), '..'))
import action_helpers

# E.g.:
# build obj/.../foo.o: cxx gen/.../foo.cc || obj/.../foo.inputdeps.stamp
# build obj/.../libfoo.a: alink obj/.../a.o obj/.../b.o |
# build ./libchrome.so ./lib.unstripped/libchrome.so: solink a.o b.o ...
# build libmonochrome.so: __chrome_android_libmonochrome___rule | ...
# build somefile.cc somefile.h: __some_rule | ...
_REGEX = re.compile(
    r'^(?:subninja (.*)|build ([^:]+): [^\s]+ (.*)|  rlibs = (.*))',
    re.MULTILINE,
)

_IGNORED_SUFFIXES = (
    '.build_metadata',
    '.empty',
    '_expected_outputs.txt',
    '_grd_files.json',
    '.modulemap',
    '.rsp',
    '-tsconfig.json',
    '-tsconfig.ref.json',
    'typemap_config',
)

# Unmatches seems to happen for empty source_sets(). E.g.:
# obj/chrome/browser/ui/libui_public_dependencies.a
_MAX_UNMATCHED_TO_LOG = 1000
_MAX_UNMATCHED_TO_IGNORE = 200
_MAX_RECURSION = 200


class _SourceMapper:
    def __init__(self, dep_map, parsed_files):
        self._dep_map = dep_map
        self.parsed_files = parsed_files
        self._unmatched_paths = set()

    def Lookup(self, path):
        ret = self._dep_map.get(path)
        if not ret:
            # .ninja files do not record origin of files written by write_file() and
            # generated_file().
            if not path.endswith(_IGNORED_SUFFIXES):
                if path not in self._unmatched_paths:
                    if self.unmatched_paths_count < _MAX_UNMATCHED_TO_LOG:
                        logging.warning(
                            'Could not find source path for %s', path
                        )
                    self._unmatched_paths.add(path)
        return ret

    @property
    def unmatched_paths_count(self):
        return len(self._unmatched_paths)


def _ParseNinjaPathList(path_list):
    ret = path_list.replace('\\ ', '\b')
    return [
        os.path.normpath(s.replace('\b', ' '))
        for s in ret.split()
        if s[0] != '|'
    ]


def _ParseOneFile(data, dep_map, executable_path):
    sub_ninjas = []
    prev_inputs = None
    for m in _REGEX.finditer(data):
        groups = m.groups()
        if groups[0]:
            sub_ninjas.append(groups[0])
        elif groups[3]:
            # Rust .rlibs are listed as implicit dependencies of the main
            # target linking rule, then are given as an extra
            #   rlibs =
            # variable on a subsequent line. Watch out for that line.
            prev_inputs.extend(_ParseNinjaPathList(groups[3]))
        else:
            outputs = _ParseNinjaPathList(groups[1])
            input_paths = _ParseNinjaPathList(groups[2])
            for output in outputs:
                assert output not in dep_map, f'Duplicate output: {output}'
                dep_map[output] = input_paths
            prev_inputs = input_paths

    return sub_ninjas


def _Parse(output_directory, executable_path):
    """Parses build.ninja and subninjas.

    Args:
      output_directory: Where to find the root build.ninja.
      executable_path: Path to binary to find inputs for.

    Returns: _SourceMapper instance.
    """
    to_parse = ['build.ninja']
    seen_paths = set(to_parse)
    dep_map = {}
    while to_parse:
        path = os.path.join(output_directory, to_parse.pop())
        with open(path, encoding='utf-8', errors='ignore') as obj:
            data = obj.read()
            sub_ninjas = _ParseOneFile(data, dep_map, executable_path)
        for subpath in sub_ninjas:
            assert subpath not in seen_paths, 'Double include of ' + subpath
            seen_paths.add(subpath)
        to_parse.extend(sub_ninjas)

    assert executable_path in dep_map, (
        'Failed to find rule that builds ' + executable_path
    )
    return _SourceMapper(dep_map, seen_paths)


def _CollectInputs(source_mapper, ret, output_path, stack, visited):
    token = len(visited)
    if visited.setdefault(output_path, token) != token:
        return
    if token % 10000 == 0:
        logging.info('Resolved %d', token)

    # Don't recurse into non-generated files or into shared libraries.
    if output_path.startswith('..') or output_path.endswith(('.so', '.so.TOC')):
        ret.add(output_path)
        return

    inputs = source_mapper.Lookup(output_path)
    if not inputs:
        return

    stack.append(output_path)
    if len(stack) > _MAX_RECURSION:
        print('Input loop!')
        print('\n'.join(enumerate(stack)))
        sys.exit(1)
    for p in inputs:
        _CollectInputs(source_mapper, ret, p, stack, visited)
    stack.pop()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--executable', required=True)
    parser.add_argument('--result-json', required=True)
    parser.add_argument('--depfile')
    args = parser.parse_args()
    logs_io = io.StringIO()
    logging.basicConfig(
        level=logging.DEBUG,
        format='%(levelname).1s %(relativeCreated)6d %(message)s',
        stream=logs_io,
    )

    logging.info('Parsing build.ninja')
    executable_path = os.path.relpath(args.executable)
    source_mapper = _Parse('.', executable_path)
    object_paths = source_mapper.Lookup(executable_path)
    logging.info('Found %d linker inputs', len(object_paths))
    source_paths = set()
    stack = []
    visited = {}
    for p in object_paths:
        _CollectInputs(source_mapper, source_paths, p, stack, visited)
    logging.info('Found %d source paths', len(source_paths))

    num_unmatched = source_mapper.unmatched_paths_count
    logging.warning('%d paths were missing sources', num_unmatched)
    if num_unmatched > _MAX_UNMATCHED_TO_IGNORE:
        raise Exception(
            f'{num_unmatched} unmapped files (of {len(object_paths)}).'
            ' Likely a bug in ninja_parser.py'
        )

    if args.depfile:
        action_helpers.write_depfile(
            args.depfile, args.result_json, source_mapper.parsed_files
        )

    logging.warning('Writing %s source paths', len(source_paths))
    with open(args.result_json, 'w', encoding='utf-8') as f:
        json.dump(
            {
                'logs': logs_io.getvalue(),
                'source_paths': sorted(source_paths),
            },
            f,
        )


if __name__ == '__main__':
    main()
