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

"""Moves C++ files to a new location, updating any include paths that point
to them, and re-ordering headers as needed.  If multiple source files are
specified, the destination must be a directory.  Updates include guards in
moved header files.  Assumes Chromium coding style.

Attempts to update and reorder paths used in .gn/.gni files, as well as legacy
.gyp/.gypi files, using a heuristic.

Updates full-path references to files in // comments in source files.

Must run in a git checkout, as it relies on git grep for a fast way to
find files that reference the moved file.
"""

from __future__ import print_function

import optparse
import os
import re
import subprocess
import sys

import mffr

if __name__ == '__main__':
  # Need to add the directory containing sort_sources.py to the Python
  # classpath.
  sys.path.append(os.path.abspath(os.path.join(sys.path[0], '..')))
import sort_sources


HANDLED_EXTENSIONS = ['.cc', '.mm', '.h', '.hh', '.cpp', '.mojom']


def IsHandledFile(path):
  return os.path.splitext(path)[1] in HANDLED_EXTENSIONS


def MakeDestinationPath(from_path, to_path):
  """Given the from and to paths, return a correct destination path.

  The initial destination path may either a full path or a directory.
  Also does basic sanity checks.
  """
  if not IsHandledFile(from_path):
    raise Exception(
      'Only intended to move individual source files '
      '(%s does not have a recognized extension).' % from_path
    )

  # Remove '.', '..', etc.
  to_path = os.path.normpath(to_path)

  if os.path.isdir(to_path):
    to_path = os.path.join(to_path, os.path.basename(from_path))
  else:
    dest_extension = os.path.splitext(to_path)[1]
    if dest_extension not in HANDLED_EXTENSIONS:
      raise Exception(
        'Destination must be either a full path with '
        'a recognized extension or a directory.'
      )
  return to_path


def MoveFile(from_path, to_path):
  """Performs a git mv command to move a file from |from_path| to |to_path|."""
  if not os.system('git mv %s %s' % (from_path, to_path)) == 0:
    raise Exception('Fatal: Failed to run git mv command.')


def UpdateIncludes(from_path, to_path):
  """Updates any includes of |from_path| to |to_path|. Paths supplied to this
  function have been mapped to forward slashes.
  """
  # This handles three types of include/imports:
  # . C++ includes.
  # . Object-C imports
  # . Imports in mojom files.
  files_with_changed_includes = mffr.MultiFileFindReplace(
    r'(#?(include|import)\s*["<])%s([>"])' % re.escape(from_path),
    r'\1%s\3' % to_path,
    ['*.cc', '*.h', '*.m', '*.mm', '*.cpp', '*.mojom'],
  )


def UpdatePostMove(from_path, to_path):
  """Given a file that has moved from |from_path| to |to_path|,
  updates the moved file's include guard to match the new path and
  updates all references to the file in other source files. Also tries
  to update references in .gn/.gni/.gyp/.gypi files using a heuristic.
  """
  # Include paths always use forward slashes.
  from_path = from_path.replace('\\', '/')
  to_path = to_path.replace('\\', '/')
  extension = os.path.splitext(from_path)[1]

  if extension in ['.h', '.hh', '.mojom']:
    UpdateIncludes(from_path, to_path)
    if extension == '.mojom':
      # For mojom files, update includes of generated headers.
      UpdateIncludes(from_path + '.h', to_path + '.h')
      UpdateIncludes(from_path + '-blink.h', to_path + '-blink.h')
      UpdateIncludes(from_path + '-shared.h', to_path + '-shared.h')
      UpdateIncludes(from_path + '-forward.h', to_path + '-forward.h')
    else:
      UpdateIncludeGuard(from_path, to_path)

  # Update comments; only supports // comments, which are primarily
  # used in our code.
  #
  # This work takes a bit of time. If this script starts feeling too
  # slow, one good way to speed it up is to make the comment handling
  # optional under a flag.
  mffr.MultiFileFindReplace(
    r'(//.*)%s' % re.escape(from_path),
    r'\1%s' % to_path,
    ['*.cc', '*.h', '*.m', '*.mm', '*.cpp'],
  )

  # Update references in GYP and BUILD.gn files.
  #
  # GYP files are mostly located under the first level directory, but sometimes
  # they are located in directories at a deeper level. BUILD.gn files can be
  # placed in any directory.
  #
  # Paths in a GYP or BUILD.gn file are relative to the directory where the
  # file is placed.
  #
  # For instance, "chrome/browser/chromeos/device_uma.h" is listed as
  # "browser/chromeos/device_uma.h" in "chrome/chrome_browser_chromeos.gypi",
  # but it's listed as "device_uma.h" in "chrome/browser/chromeos/BUILD.gn".
  #
  # To handle this, the code here will visit directories from the top level
  # src directory to the directory of |from_path| and try to update GYP and
  # BUILD.gn files in each directory.
  #
  # The code only handles files moved/renamed within the same build file. If
  # files are moved beyond the same build file, the affected build files
  # should be fixed manually.
  def SplitByFirstComponent(path):
    """'foo/bar/baz' -> ('foo', 'bar/baz')
    'bar' -> ('bar', '')
    '' -> ('', '')
    """
    parts = re.split(r"[/\\]", path, maxsplit=1)
    if len(parts) == 2:
      return (parts[0], parts[1])
    else:
      return (parts[0], '')

  visiting_directory = ''
  from_rest = from_path
  to_rest = to_path
  while True:
    files_with_changed_sources = mffr.MultiFileFindReplace(
      r'([\'"])%s([\'"])' % from_rest,
      r'\1%s\2' % to_rest,
      [
        os.path.join(visiting_directory, 'BUILD.gn'),
        os.path.join(visiting_directory, '*.gyp*'),
      ],
    )
    for changed_file in files_with_changed_sources:
      sort_sources.ProcessFile(changed_file, should_confirm=False)
    from_first, from_rest = SplitByFirstComponent(from_rest)
    to_first, to_rest = SplitByFirstComponent(to_rest)
    visiting_directory = os.path.join(visiting_directory, from_first)
    if not from_rest or not to_rest or from_rest == to_rest:
      break


def MakeIncludeGuardName(path_from_root):
  """Returns an include guard name given a path from root."""
  guard = path_from_root.replace('/', '_')
  guard = guard.replace('\\', '_')
  guard = guard.replace('.', '_')
  guard += '_'
  return guard.upper()


def UpdateIncludeGuard(old_path, new_path):
  """Updates the include guard in a file now residing at |new_path|,
  previously residing at |old_path|, with an up-to-date include guard.

  Prints a warning if the update could not be completed successfully (e.g.,
  because the old include guard was not formatted correctly per Chromium style).
  """
  old_guard = MakeIncludeGuardName(old_path)
  new_guard = MakeIncludeGuardName(new_path)

  with open(new_path) as f:
    contents = f.read()

  new_contents = contents.replace(old_guard, new_guard)
  # The file should now have three instances of the new guard: two at the top
  # of the file plus one at the bottom for the comment on the #endif.
  if new_contents.count(new_guard) != 3:
    print(
      'WARNING: Could not successfully update include guard; perhaps '
      'old guard is not per style guide? You will have to update the '
      'include guard manually. (%s)' % new_path
    )

  with open(new_path, 'w', newline='\n') as f:
    f.write(new_contents)


def main():
  # We use "git rev-parse" to check if the script is run from a git checkout. It
  # returns 0 even when run in the .git directory. We don't want people running
  # this in the .git directory.
  if os.system('git rev-parse') != 0 or os.path.basename(os.getcwd()) == '.git':
    print('Fatal: You must run in a git checkout.')
    return 1

  cwd = os.getcwd()
  parent = os.path.dirname(cwd)

  parser = optparse.OptionParser(usage='%prog FROM_PATH... TO_PATH')
  parser.add_option(
    '--already_moved',
    action='store_true',
    dest='already_moved',
    help='Causes the script to skip moving the file.',
  )
  parser.add_option(
    '--no_error_for_non_source_file',
    action='store_false',
    default='True',
    dest='error_for_non_source_file',
    help='Causes the script to simply print a warning on '
    'encountering a non-source file rather than raising an '
    'error.',
  )
  opts, args = parser.parse_args()

  if len(args) < 2:
    parser.print_help()
    return 1

  from_paths = args[: len(args) - 1]
  orig_to_path = args[-1]

  if len(from_paths) > 1 and not os.path.isdir(orig_to_path):
    print('Target %s is not a directory.' % orig_to_path)
    print()
    parser.print_help()
    return 1

  for from_path in from_paths:
    if not opts.error_for_non_source_file and not IsHandledFile(from_path):
      print('%s does not appear to be a source file, skipping' % (from_path))
      continue
    to_path = MakeDestinationPath(from_path, orig_to_path)
    if not opts.already_moved:
      MoveFile(from_path, to_path)
    UpdatePostMove(from_path, to_path)
  return 0


if __name__ == '__main__':
  sys.exit(main())
