open Import

(** Because the dune_init utility deals with the addition of stanzas and fields
    to dune projects and files, we need to inspect and manipulate the concrete
    syntax tree (CST) a good deal. *)
module Cst = Dune_lang.Cst

(** Abstractions around the kinds of files handled during initialization *)
module File = struct
  type dune =
    { dir : Path.Source.t
    ; name : string
    ; content : Cst.t list
    }

  type text =
    { dir : Path.Source.t
    ; name : string
    ; content : string
    }

  type t =
    | Dune of dune
    | Text of text

  let make_text ~dir name content = Text { dir; name; content }

  let full_path = function
    | Dune { dir; name; _ } | Text { dir; name; _ } -> Path.Source.relative dir name
  ;;

  (** Inspection and manipulation of stanzas in a file *)
  module Stanza = struct
    let pp s =
      match Cst.to_sexp s with
      | None -> Pp.nop
      | Some s -> Dune_lang.pp s
    ;;

    let libraries_conflict (a : Library.t) (b : Library.t) = a.name = b.name

    let executables_conflict (a : Dune_rules.Executables.t) (b : Dune_rules.Executables.t)
      =
      let a_names = String.Set.of_list_map ~f:snd (Nonempty_list.to_list a.names) in
      let b_names = String.Set.of_list_map ~f:snd (Nonempty_list.to_list b.names) in
      String.Set.inter a_names b_names |> String.Set.is_empty |> not
    ;;

    let tests_conflict (a : Dune_rules.Tests.t) (b : Dune_rules.Tests.t) =
      executables_conflict a.exes b.exes
    ;;

    let stanzas_conflict (a : Stanza.t) (b : Stanza.t) =
      match Stanza.repr a, Stanza.repr b with
      | Dune_rules.Executables.T a, Dune_rules.Executables.T b -> executables_conflict a b
      | Library.T a, Library.T b -> libraries_conflict a b
      | Dune_rules.Tests.T a, Dune_rules.Tests.T b -> tests_conflict a b
      (* NOTE No other stanza types currently supported *)
      | _ -> false
    ;;

    let csts_conflict project ~dir (a : Cst.t) (b : Cst.t) =
      let of_ast sexp =
        let parser = Dune_project.stanza_parser project ~dir in
        Dune_lang.Decoder.parse parser Univ_map.empty sexp
      in
      (let open Option.O in
       let* a_ast = Cst.abstract a in
       let+ b_ast = Cst.abstract b in
       let a_asts = of_ast a_ast in
       let b_asts = of_ast b_ast in
       List.exists ~f:(fun x -> List.exists ~f:(stanzas_conflict x) a_asts) b_asts)
      |> Option.value ~default:false
    ;;

    (* TODO(shonfeder): replace with stanza merging *)
    let find_conflicting project ~dir new_stanzas existing_stanzas =
      let conflicting_stanza stanza =
        match List.find ~f:(csts_conflict ~dir project stanza) existing_stanzas with
        | Some conflict -> Some (stanza, conflict)
        | None -> None
      in
      List.find_map ~f:conflicting_stanza new_stanzas
    ;;

    let add (project : Dune_project.t) ~dir stanzas = function
      | Text f -> Text f (* Adding a stanza to a text file isn't meaningful *)
      | Dune f ->
        (match find_conflicting project ~dir stanzas f.content with
         | None -> Dune { f with content = f.content @ stanzas }
         | Some (a, b) ->
           User_error.raise
             [ Pp.text "Updating existing stanzas is not yet supported."
             ; Pp.text "A preexisting dune stanza conflicts with a generated stanza:"
             ; Pp.nop
             ; Pp.text "Generated stanza:"
             ; pp a
             ; Pp.nop
             ; Pp.text "Pre-existing stanza:"
             ; pp b
             ])
    ;;
  end

  (* Stanza *)

  let create_dir path =
    let path = Path.source path in
    try Path.mkdir_p path with
    | Unix.Unix_error (EACCES, _, _) ->
      User_error.raise
        [ Pp.textf
            "A project directory cannot be created or accessed: Lacking permissions \
             needed to create directory %s"
            (Path.to_string_maybe_quoted path)
        ]
  ;;

  let load_dune_file ~dir =
    let name = "dune" in
    let full_path = Path.relative (Path.source dir) name in
    let content =
      if not (Fpath.exists (Path.to_string full_path))
      then []
      else if Fpath.is_directory (Path.to_string full_path)
      then
        User_error.raise
          [ Pp.textf
              "\"%s\" already exists and is a directory"
              (Path.to_absolute_filename full_path)
          ]
      else (
        match Io.with_lexbuf_from_file ~f:Dune_lang.Format.parse full_path with
        | Dune_lang.Format.Sexps content -> content
        | Dune_lang.Format.OCaml_syntax _ ->
          User_error.raise
            [ Pp.textf
                "Cannot load dune file %s because it uses OCaml syntax"
                (Path.to_string_maybe_quoted full_path)
            ])
    in
    Dune { dir; name; content }
  ;;

  let write_dune_file (dune_file : dune) =
    let path = Path.Source.relative dune_file.dir dune_file.name in
    let version =
      Dune_lang.Syntax.greatest_supported_version_exn Dune_lang.Stanza.syntax
    in
    Io.with_file_out
      ~binary:true
      (* Why do we pass [~binary:true] but not anywhere else when formatting? *)
      (Path.source path)
      ~f:(fun oc ->
        let fmt = Format.formatter_of_out_channel oc in
        Format.fprintf
          fmt
          "%a%!"
          Pp.to_fmt
          (Dune_lang.Format.pp_top_sexps ~version dune_file.content))
  ;;

  let write f =
    let path = full_path f in
    match f with
    | Dune f -> Ok (write_dune_file f)
    | Text f ->
      let path = Path.source path in
      if Fpath.exists (Path.to_string path)
      then Error path
      else Ok (Io.write_file ~binary:false path f.content)
  ;;
end

(** The context in which the initialization is executed *)
module Init_context = struct
  open Dune_config_file

  type t =
    { dir : Path.Source.t
    ; project : Dune_project.t
    ; defaults : Dune_config.Project_defaults.t
    }

  let make path defaults =
    let open Memo.O in
    let+ project =
      (* CR-someday rgrinberg: why not get the project from the source tree? *)
      Dune_project.load
        ~dir:Path.Source.root
        ~files:Filename.Set.empty
        ~infer_from_opam_files:true
        ~load_opam_file_with_contents:Dune_pkg.Opam_file.load_opam_file_with_contents
      >>| function
      | Some p -> p
      | None ->
        Dune_project.anonymous
          ~dir:Path.Source.root
          Package_info.empty
          Package.Name.Map.empty
    in
    let dir =
      match path with
      | None -> Path.Source.root
      | Some p ->
        (match Path.Outside_build_dir.of_string p with
         | In_source_dir s -> s
         | External e ->
           (match Path.Expert.try_localize_external (Path.external_ e) with
            | In_build_dir _ ->
              (* Impossible because we never passed in a build path. It would
                 be nice to have a [Path.Outside_build_dir.try_localize_external]
                 reflect that
              *)
              assert false
            | In_source_tree p -> p
            | External _ -> User_error.raise [ Pp.textf "%s isn't in the workspace" p ]))
    in
    File.create_dir dir;
    { dir; project; defaults }
  ;;
end

let check_module_name name =
  let s = Dune_lang.Atom.to_string name in
  let (_ : Dune_lang.Module_name.t) =
    Dune_lang.Module_name.of_string_user_error (Loc.none, s) |> User_error.ok_exn
  in
  ()
;;

module Public_name = struct
  include Lib_name
  module Pkg = Dune_lang.Package_name.Opam_compatible

  let is_opam_compatible l =
    Lib_name.package_name l |> Dune_lang.Package_name.is_opam_compatible
  ;;

  let of_string_user_error (loc, s) =
    let open Result.O in
    let* l = of_string_user_error (loc, s) in
    if is_opam_compatible l
    then Ok l
    else
      Error
        (User_error.make
           [ Pp.text
               "Public names are composed of an opam package name and optional \
                dot-separated string suffixes."
           ; Pkg.description_of_valid_string
           ])
  ;;

  let of_name_exn name =
    let s = Dune_lang.Atom.to_string name in
    of_string_user_error (Loc.none, s) |> User_error.ok_exn
  ;;
end

module Component = struct
  module Options = struct
    module Common = struct
      type t =
        { name : Dune_lang.Atom.t
        ; public : Public_name.t option
        ; libraries : Dune_lang.Atom.t list
        ; pps : Dune_lang.Atom.t list
        }

      let package_name common =
        let name =
          match common.public with
          | None -> Dune_lang.Atom.to_string common.name
          | Some public -> Public_name.to_string public
        in
        Package.Name.of_string name
      ;;
    end

    module Executable = struct
      type t = unit
    end

    module Library = struct
      type t = { inline_tests : bool }
    end

    module Project = struct
      module Template = struct
        type t =
          | Exec
          | Lib

        let of_string = function
          | "executable" -> Some Exec
          | "library" -> Some Lib
          | _ -> None
        ;;

        let commands = [ "executable", Exec; "library", Lib ]
      end

      module Pkg = struct
        type t =
          | Opam
          | Esy

        let commands = [ "opam", Opam; "esy", Esy ]
      end

      type t =
        { template : Template.t
        ; inline_tests : bool
        ; pkg : Pkg.t
        }
    end

    module Test = struct
      type t = unit
    end

    type 'options t =
      { context : Init_context.t
      ; common : Common.t
      ; options : 'options
      }
  end

  (* Options *)

  type 'options t =
    | Executable : Options.Executable.t Options.t -> Options.Executable.t t
    | Library : Options.Library.t Options.t -> Options.Library.t t
    | Project : Options.Project.t Options.t -> Options.Project.t t
    | Test : Options.Test.t Options.t -> Options.Test.t t

  (** Internal representation of the files comprising a component *)
  type target =
    { dir : Path.Source.t
    ; files : File.t list
    }

  (** Creates Dune language CST stanzas describing components *)
  module Stanza_cst = struct
    open Dune_lang

    module Field = struct
      let inline_tests = Encoder.field_b "inline_tests"
      let pps_encoder pps = Encoder.list Encoder.string ("pps" :: pps)

      let preprocess_field = function
        | [] -> []
        | pps -> [ Encoder.field "preprocess" pps_encoder pps ]
      ;;

      let common (options : Options.Common.t) =
        [ Encoder.field "name" Encoder.string (Atom.to_string options.name)
        ; Encoder.field_l
            "libraries"
            Encoder.string
            (List.map ~f:Atom.to_string options.libraries)
        ]
        @ preprocess_field (List.map ~f:Atom.to_string options.pps)
      ;;
    end

    (* Make CST representation of a stanza for the given `kind` *)
    let make kind common_options fields =
      Encoder.named_record_fields kind (fields @ Field.common common_options)
      (* Convert to a CST *)
      |> Dune_lang.Ast.add_loc ~loc:Loc.none
      |> Cst.concrete
      (* Package as a list CSTs *) |> List.singleton
    ;;

    let add_to_list_set elem set =
      if List.mem ~equal:Dune_lang.Atom.equal set elem then set else elem :: set
    ;;

    let public_name_field = Encoder.field_o "public_name" Public_name.encode

    let project_info ~(defaults : Dune_config_file.Dune_config.Project_defaults.t) =
      Package_info.example
        ~authors:defaults.authors
        ~maintainers:defaults.maintainers
        ~license:defaults.license
    ;;

    let executable (common : Options.Common.t) (() : Options.Executable.t) =
      make "executable" common [ public_name_field common.public ]
    ;;

    let library (common : Options.Common.t) { Options.Library.inline_tests } =
      check_module_name common.name;
      let common =
        if inline_tests
        then (
          let pps =
            add_to_list_set (Dune_lang.Atom.of_string "ppx_inline_test") common.pps
          in
          { common with pps })
        else common
      in
      make
        "library"
        common
        [ public_name_field common.public; Field.inline_tests inline_tests ]
    ;;

    let test common (() : Options.Test.t) = make "test" common []

    let dune_project
          ~opam_file_gen
          ~(defaults : Dune_config_file.Dune_config.Project_defaults.t)
          dir
          (common : Options.Common.t)
      =
      let package =
        Package.create
          ~name:(Options.Common.package_name common)
          ~loc:Loc.none
          ~version:None
          ~conflicts:[]
          ~depopts:[]
          ~info:Package_info.empty
          ~sites:Site.Map.empty
          ~allow_empty:false
          ~deprecated_package_names:Package.Name.Map.empty
          ~has_opam_file:(Exists false)
          ~original_opam_file:None
          ~dir
          ~synopsis:(Some "A short synopsis")
          ~description:(Some "A longer description")
          ~tags:[ "add topics"; "to describe"; "your"; "project" ]
          ~depends:
            [ { Package_dependency.name = Package.Name.of_string "ocaml"
              ; constraint_ = None
              }
            ]
          ~contents_basename:None
          ~enabled_if:None
      in
      let packages = Package.Name.Map.singleton (Package.name package) package in
      let info = project_info ~defaults in
      Dune_project.anonymous ~dir info packages
      |> Dune_project.set_generate_opam_files opam_file_gen
    ;;

    (* A list of CSTs for dune-project file content *)
    let dune_project_file
          ~opam_file_gen
          ~(defaults : Dune_config_file.Dune_config.Project_defaults.t)
          dir
          (common : Options.Common.t)
      =
      let cst =
        dune_project ~opam_file_gen ~defaults dir common
        |> Dune_project.encode
        |> List.map ~f:(fun exp ->
          exp |> Dune_lang.Ast.add_loc ~loc:Loc.none |> Cst.concrete)
      in
      List.append
        cst
        [ Cst.Comment
            ( Loc.none
            , [ " See the complete stanza docs at \
                 https://dune.readthedocs.io/en/stable/reference/dune-project/index.html"
              ] )
        ]
    ;;

    let opam_file
          ~(defaults : Dune_config_file.Dune_config.Project_defaults.t)
          dir
          (common : Options.Common.t)
      =
      let project = dune_project ~opam_file_gen:true ~defaults dir common in
      let package_name = Options.Common.package_name common in
      let package =
        Package.Name.Map.find_exn (Dune_project.packages project) package_name
      in
      let info = Package_info.superpose (project_info ~defaults) (Package.info package) in
      let package =
        Package.set_version_and_info package ~version:(Package.version package) ~info
      in
      Dune_rules.Opam_create.generate project package ~template:None
    ;;
  end

  (* TODO Support for merging in changes to an existing stanza *)
  let add_stanza_to_dune_file ~(project : Dune_project.t) ~dir stanza =
    File.load_dune_file ~dir |> File.Stanza.add ~dir project stanza
  ;;

  (* Functions to make the various components, represented as lists of files *)
  module Make = struct
    let bin ({ context; common; options } : Options.Executable.t Options.t) =
      let dir = context.dir in
      let bin_dune =
        Stanza_cst.executable common options
        |> add_stanza_to_dune_file ~dir ~project:context.project
      in
      let bin_ml =
        let name = sprintf "%s.ml" (Dune_lang.Atom.to_string common.name) in
        let content = sprintf "let () = print_endline \"Hello, World!\"\n" in
        File.make_text ~dir name content
      in
      let files = [ bin_dune; bin_ml ] in
      [ { dir; files } ]
    ;;

    let src ({ context; common; options } : Options.Library.t Options.t) =
      let dir = context.dir in
      let lib_dune =
        Stanza_cst.library common options
        |> add_stanza_to_dune_file ~project:context.project ~dir
      in
      let files = [ lib_dune ] in
      [ { dir; files } ]
    ;;

    let test ({ context; common; options } : Options.Test.t Options.t) =
      let dir = context.dir in
      let test_dune =
        Stanza_cst.test common options
        |> add_stanza_to_dune_file ~project:context.project ~dir
      in
      let test_ml =
        let name = sprintf "%s.ml" (Dune_lang.Atom.to_string common.name) in
        let content = "" in
        File.make_text ~dir name content
      in
      let files = [ test_dune; test_ml ] in
      [ { dir; files } ]
    ;;

    let dune_project_file
          ~dir
          ({ context; common; options } : Options.Project.t Options.t)
      =
      let opam_file_gen =
        match options.pkg with
        | Opam -> true
        | Esy -> false
      in
      let content =
        Stanza_cst.dune_project_file
          ~opam_file_gen
          ~defaults:context.defaults
          context.dir
          common
      in
      File.Dune { dir; content; name = "dune-project" }
    ;;

    (* Convert a libname to a dune atom that can be used to declare a library as
       a dependency  *)
    let lib_name_to_atom lib : Dune_lang.Atom.t =
      Lib_name.to_string lib
      |> String.map ~f:(function
        | '-' -> '_' (* in case the lib_name is public, containing a `-` *)
        | c -> c)
      |> Dune_lang.Atom.of_string
    ;;

    let proj_exec dir ({ context; common; options } : Options.Project.t Options.t) =
      let lib_target =
        src
          { context = { context with dir = Path.Source.relative dir "lib" }
          ; options = { inline_tests = options.inline_tests }
          ; common = { common with public = None }
          }
      in
      let test_target =
        let test_name = "test_" ^ Dune_lang.Atom.to_string common.name in
        let libraries =
          match common.public with
          | None -> []
          | Some lib -> [ lib_name_to_atom lib ]
        in
        test
          { context = { context with dir = Path.Source.relative dir "test" }
          ; options = ()
          ; common = { common with name = Dune_lang.Atom.of_string test_name; libraries }
          }
      in
      let bin_target =
        (* Add the lib_target as a library to the executable*)
        let libraries = Stanza_cst.add_to_list_set common.name common.libraries in
        bin
          { context = { context with dir = Path.Source.relative dir "bin" }
          ; options = ()
          ; common = { common with libraries; name = Dune_lang.Atom.of_string "main" }
          }
      in
      bin_target @ lib_target @ test_target
    ;;

    let proj_lib dir ({ context; common; options } : Options.Project.t Options.t) =
      let lib_target =
        src
          { context = { context with dir = Path.Source.relative dir "lib" }
          ; options = { inline_tests = options.inline_tests }
          ; common
          }
      in
      let test_target =
        let test_name = "test_" ^ Dune_lang.Atom.to_string common.name in
        let libraries =
          match common.public with
          | None -> []
          | Some lib -> [ lib_name_to_atom lib ]
        in
        test
          { context = { context with dir = Path.Source.relative dir "test" }
          ; options = ()
          ; common = { common with name = Dune_lang.Atom.of_string test_name; libraries }
          }
      in
      lib_target @ test_target
    ;;

    let proj ({ context; common; options } as opts : Options.Project.t Options.t) =
      let ({ template; pkg; _ } : Options.Project.t) = options in
      let dir = Path.Source.root in
      let proj_target =
        let package_files =
          match (pkg : Options.Project.Pkg.t) with
          | Opam ->
            let name = Options.Common.package_name common in
            let opam_file = Package_name.file name ~dir in
            let content =
              Stanza_cst.opam_file ~defaults:context.defaults context.dir common
            in
            [ File.make_text
                ~dir:(Path.Source.parent_exn opam_file)
                (Path.Source.basename opam_file)
                content
            ]
          | Esy -> [ File.make_text ~dir "package.json" "" ]
        in
        { dir; files = dune_project_file ~dir opts :: package_files }
      in
      let component_targets =
        (match (template : Options.Project.Template.t) with
         | Exec -> proj_exec
         | Lib -> proj_lib)
          dir
          opts
      in
      proj_target :: component_targets
    ;;
  end

  let report_uncreated_file = function
    | Ok _ -> ()
    | Error path ->
      let open Pp.O in
      User_warning.emit
        [ Pp.textf "File "
          ++ Pp.tag
               User_message.Style.Kwd
               (Pp.verbatim (Path.to_string_maybe_quoted path))
          ++ Pp.text " was not created because it already exists"
        ]
  ;;

  (** Creates a component, writing the files to disk *)
  let create target =
    File.create_dir target.dir;
    List.map ~f:File.write target.files
  ;;

  let init (type options) (t : options t) =
    let target =
      match t with
      | Executable params -> Make.bin params
      | Library params -> Make.src params
      | Project params -> Make.proj params
      | Test params -> Make.test params
    in
    List.concat_map ~f:create target |> List.iter ~f:report_uncreated_file
  ;;
end
