(*
 * Copyright (c) 2015-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *)

open! Core
module F = Format

type copyright_event = CopyrightMalformed | CopyrightModified

exception CopyrightEvent of copyright_event

let exit_code_of_event = function CopyrightModified -> 1 | CopyrightMalformed -> 3

type comment_style =
  | Line of string * bool
      (** line comments, eg "#" for shell, and whether there should be a
                              newline before the copyright notice *)
  | Block of string * string * string * bool  (** block comments, eg ("(*", "*", "*)") for ocaml *)
[@@deriving compare]

let equal_comment_style = [%compare.equal: comment_style]

let comment_style_al = Line ("//", false)

let comment_style_c = Block ("/*", "*", "*/", false)

let comment_style_llvm = Line (";", true)

let comment_style_m4 = Line ("dnl", false)

let comment_style_make = Line ("#", false)

let comment_style_ocaml = Block ("(*", "*", "*)", false)

let comment_style_php = Line ("//", true)

let comment_style_python = Line ("#", false)

let comment_style_shell = Line ("#", true)

let comment_styles_lang =
  [ (comment_style_al, "AL")
  ; (comment_style_c, "C")
  ; (comment_style_llvm, "LLVM")
  ; (comment_style_m4, "M4")
  ; (comment_style_make, "Makefile")
  ; (comment_style_ocaml, "OCaml")
  ; (comment_style_php, "PHP")
  ; (comment_style_python, "python")
  ; (comment_style_shell, "shell") ]


let lang_of_comment_style style =
  List.Assoc.find_exn ~equal:equal_comment_style comment_styles_lang style


let comment_styles = List.map comment_styles_lang ~f:fst

let starts_with_newline = function
  | Line (_, starts_with_newline) | Block (_, _, _, starts_with_newline) ->
      starts_with_newline


let default_start_line_of_com_style style = if starts_with_newline style then 1 else 0

let indent_of_comment_style = function
  | Line _ ->
      ""
  | Block (_, inter, _, _) ->
      String.make (String.length inter) ' '


let keep_going = ref false

(** If true, update the copyright message of the files. *)
let update_files = ref false

let show_diff = ref false

let line_contains_copyright line = String.is_substring ~substring:"opyright " line

let find_copyright_line lines =
  List.findi lines ~f:(fun _ line -> line_contains_copyright line) |> Option.map ~f:fst


let array_rev_find_mapi_from array ~from ~f =
  let i = ref from in
  let found = ref None in
  while !i >= 0 && Option.is_none !found do
    found := f !i array.(!i) ;
    decr i
  done ;
  !found


let find_comment_start_and_style lines n =
  (* are we in a line comment? *)
  let cur_line_comment =
    List.find comment_styles ~f:(function
      | Line (s, starts_with_newline) when String.is_prefix ~prefix:s lines.(n) ->
          if starts_with_newline then n <> 0 else true
      | _ ->
          false )
  in
  let is_start i line =
    match cur_line_comment with
    | Some (Line _) ->
        cur_line_comment
    | Some (Block _) | None ->
        List.find comment_styles ~f:(function
          | Block (s, _, _, starts_with_newline) when String.is_substring ~substring:s line ->
              if starts_with_newline then i <> 0 else true
          | _ ->
              false )
  in
  let find_in_line i line = is_start i line |> Option.map ~f:(fun style -> (i, style)) in
  array_rev_find_mapi_from lines ~from:n ~f:find_in_line


let find_comment_end lines n com_style =
  let is_end line =
    match com_style with
    | Line (s, _) ->
        (not (String.is_prefix ~prefix:s line), `After)
    | Block (_, _, s, _) ->
        (String.is_substring ~substring:s line, `OnIt)
  in
  let i = ref (n + 1) in
  let len = Array.length lines in
  let found = ref (len - 1) in
  while !i < len && !found = len - 1 do
    ( match is_end lines.(!i) with
    | true, `OnIt ->
        found := !i
    | true, `After ->
        found := !i - 1
    | false, _ ->
        () ) ;
    incr i
  done ;
  match com_style with Line _ -> !found | Block _ -> !found


(** Heuristic to check if this looks like a copyright message. *)
let looks_like_copyright_message cstart cend lines =
  let max_len = 100 in
  let check_len () =
    let ok = ref true in
    for i = cstart to cend do
      if String.length lines.(i) > max_len then ok := false
    done ;
    !ok
  in
  cstart >= 0 && cend - cstart <= 10 && check_len ()


let contains_string ~substring cstart cend lines =
  let found = ref false in
  for i = cstart to cend do
    if String.is_substring ~substring lines.(i) then found := true
  done ;
  !found


let contains_monoidics cstart cend lines = contains_string ~substring:"Monoidics" cstart cend lines

let contains_ropas cstart cend lines = contains_string ~substring:"ROPAS" cstart cend lines

let get_copyright_year cstart cend lines =
  let found = ref None in
  let do_line line =
    try
      let fmt_re = Str.regexp "[0-9]+" in
      ignore (Str.search_forward fmt_re line 0) ;
      let fmt_match = Str.matched_string line in
      if String.length fmt_match = 4 then
        try found := Some (int_of_string fmt_match) with _ -> ()
    with Caml.Not_found -> ()
  in
  for i = cstart to cend do
    let line = lines.(i) in
    if String.is_substring ~substring:"Copyright" line then do_line line
  done ;
  !found


let pp_copyright ~monoidics ~ropas ~copyright_year com_style fmt =
  let running_comment = match com_style with Line (s, _) | Block (_, s, _, _) -> s in
  let indent = indent_of_comment_style com_style in
  let pp_line str =
    F.kfprintf (fun fmt -> F.fprintf fmt "@\n") fmt ("%s%s" ^^ str) indent running_comment
  in
  let pp_start () =
    match com_style with Line _ -> () | Block (start, _, _, _) -> F.fprintf fmt "%s@\n" start
  in
  let pp_end () =
    match com_style with
    | Line _ ->
        ()
    | Block (_, _, finish, _) ->
        F.fprintf fmt "%s%s@\n" indent finish
  in
  pp_start () ;
  if ropas then (
    pp_line " Copyright (c) 2016-present, Programming Research Laboratory (ROPAS)" ;
    pp_line "                             Seoul National University, Korea" ;
    pp_line " Copyright (c) 2017-present, Facebook, Inc." )
  else (
    if monoidics then pp_line " Copyright (c) 2009-2013, Monoidics ltd." ;
    pp_line " Copyright (c) %d-present, Facebook, Inc." copyright_year ) ;
  pp_line "" ;
  pp_line " This source code is licensed under the MIT license found in the" ;
  pp_line " LICENSE file in the root directory of this source tree." ;
  pp_end ()


let copyright_has_changed fname lines ~notice_range:(cstart, cend) ~monoidics ~ropas
    ~copyright_year com_style =
  let old_copyright =
    let r = ref "" in
    for i = cstart to cend do
      r := !r ^ lines.(i) ^ "\n"
    done ;
    !r
  in
  let new_copyright =
    Format.asprintf "%t" (pp_copyright ~monoidics ~ropas ~copyright_year com_style)
  in
  let changed = not (String.equal old_copyright new_copyright) in
  if !show_diff && changed then (
    let with_suffix fname suff = Filename.basename fname ^ suff in
    let orig_fname = with_suffix fname ".orig" in
    let new_fname = with_suffix fname ".new" in
    Out_channel.write_lines orig_fname [old_copyright] ;
    Out_channel.write_lines new_fname [new_copyright] ;
    () ) ;
  changed


let com_style_of_lang =
  [ (".ac", comment_style_m4)
  ; (".al", comment_style_al)
  ; (".atd", comment_style_ocaml)
  ; (".c", comment_style_c)
  ; (".cpp", comment_style_c)
  ; (".h", comment_style_c)
  ; (".inc", comment_style_c)
  ; (".java", comment_style_c)
  ; (".ll", comment_style_llvm)
  ; (".m", comment_style_c)
  ; (".m4", comment_style_m4)
  ; (".make", comment_style_make)
  ; (".mk", comment_style_make)
  ; (".ml", comment_style_ocaml)
  ; (".mli", comment_style_ocaml)
  ; (".mll", comment_style_ocaml)
  ; (".mly", comment_style_c)
  ; (".mm", comment_style_c)
  ; (".php", comment_style_php)
  ; (".py", comment_style_python)
  ; (".re", comment_style_c)
  ; (".rei", comment_style_c)
  ; (".sh", comment_style_shell)
  ; ("dune.in", comment_style_ocaml)
  ; ("dune.common.in", comment_style_ocaml)
  ; ("dune-common.in", comment_style_ocaml)
  ; ("dune-workspace.in", comment_style_llvm)
  ; ("Makefile", comment_style_make) ]


let comment_style_of_filename fname =
  List.Assoc.find com_style_of_lang ~equal:Filename.check_suffix fname


let output_diff ~fname lines ?notice_range ?(monoidics = false) ?(ropas = false) ~copyright_year
    com_style =
  let lang = lang_of_comment_style com_style in
  let pp_range_opt fmt = function
    | None ->
        F.pp_print_string fmt "none"
    | Some (s, e) ->
        F.fprintf fmt "%d-%d" s e
  in
  F.eprintf "%s (lang:%s notice:%a monoidics:%b ropas:%b year:%d)\n%!" fname lang pp_range_opt
    notice_range monoidics ropas copyright_year ;
  let pp_newfile fmt =
    let copy_lines_before, copy_lines_after =
      match notice_range with
      | Some (s, e) ->
          (s - 1, e + 1)
      | None ->
          let insert_notice_at = default_start_line_of_com_style com_style in
          (insert_notice_at - 1, insert_notice_at)
    in
    for i = 0 to copy_lines_before do
      F.fprintf fmt "%s\n" lines.(i)
    done ;
    if
      starts_with_newline com_style && copy_lines_before > 0 && lines.(copy_lines_before - 1) <> ""
    then F.fprintf fmt "@\n" ;
    pp_copyright ~monoidics ~ropas ~copyright_year com_style fmt ;
    for i = copy_lines_after to Array.length lines - 1 do
      F.fprintf fmt "%s\n" lines.(i)
    done ;
    F.fprintf fmt "%!"
  in
  if !update_files then
    Out_channel.with_file fname ~f:(fun cout ->
        let fmt = F.formatter_of_out_channel cout in
        pp_newfile fmt )
  else pp_newfile F.std_formatter


let check_copyright fname =
  let lines_list = In_channel.read_lines fname in
  let lines = Array.of_list lines_list in
  match (find_copyright_line lines_list, comment_style_of_filename fname) with
  | None, None ->
      ()
  | None, Some com_style ->
      let copyright_year = 1900 + (Unix.localtime (Unix.time ())).Unix.tm_year in
      output_diff ~fname lines ~copyright_year com_style ;
      raise (CopyrightEvent CopyrightModified)
  | Some n, fname_com_style -> (
      let cstart, contents_com_style =
        find_comment_start_and_style lines n |> Option.value ~default:(0, Line ("#", false))
      in
      let com_style =
        match fname_com_style with
        | None ->
            contents_com_style
        | Some fname_com_style ->
            let inferred_styles_agree =
              match (fname_com_style, contents_com_style) with
              | Line (fs, _), Line (cs, _) ->
                  String.equal fs cs
              | Block (fstart, fbody, fend, _), Block (cstart, cbody, cend, _) ->
                  String.equal fstart cstart && String.equal fbody cbody && String.equal fend cend
              | _ ->
                  false
            in
            if not inferred_styles_agree then (
              F.eprintf
                "Inferred comment style doesn't match the filename '%s':@\n\
                 From the filename I was expecting the %s comment style, but looking inside the \
                 file I found %s instead.@."
                fname
                (lang_of_comment_style fname_com_style)
                (lang_of_comment_style contents_com_style) ;
              raise (CopyrightEvent CopyrightMalformed) ) ;
            fname_com_style
      in
      (* hack to detect shebangs regardless of the inferred style *)
      let com_style =
        match com_style with
        | Line ("#", false) when String.is_prefix ~prefix:"#!" lines.(0) ->
            comment_style_shell
        | _ ->
            com_style
      in
      let cend = find_comment_end lines n com_style in
      if not (looks_like_copyright_message cstart cend lines) then (
        F.eprintf "Copyright not recognized: %s@." fname ;
        raise (CopyrightEvent CopyrightMalformed) ) ;
      let monoidics = contains_monoidics cstart cend lines in
      let ropas = contains_ropas cstart cend lines in
      match get_copyright_year cstart cend lines with
      | None ->
          F.eprintf "Can't find copyright year: %s@." fname ;
          raise (CopyrightEvent CopyrightMalformed)
      | Some copyright_year ->
          if
            copyright_has_changed fname lines ~notice_range:(cstart, cend) ~monoidics ~ropas
              ~copyright_year com_style
          then (
            output_diff ~fname lines ~notice_range:(cstart, cend) ~monoidics ~ropas ~copyright_year
              com_style ;
            raise (CopyrightEvent CopyrightModified) ) )


let speclist =
  [ ("-i", Arg.Set update_files, "Update copyright notice in-place")
  ; ("--inplace", Arg.Set update_files, "Update copyright notice in-place")
  ; ("-k", Arg.Set keep_going, "Exit with code 0 no matter what")
  ; ("--keep-going", Arg.Set keep_going, "Exit with code 0 no matter what")
  ; ( "--show-diff"
    , Arg.Set show_diff
    , "Write file.orig and file.new files to inspect the differences found" ) ]


let usage_msg = "checkCopyright [-i] [-k] [--show-diff] file1 ..."

let () =
  let to_check = ref [] in
  let add_file_to_check fname =
    (* hack: LICENSE looks copyrightable but is not... *)
    if not (String.is_prefix ~prefix:"LICENSE" (Filename.basename fname)) then
      to_check := fname :: !to_check
  in
  Arg.parse (Arg.align speclist) add_file_to_check usage_msg ;
  let to_check = List.rev !to_check in
  let exit_code = ref 0 in
  List.iter to_check ~f:(fun file ->
      try check_copyright file
      with CopyrightEvent event -> if not !keep_going then exit_code := exit_code_of_event event ) ;
  exit !exit_code
