open Import

module Options = struct
  (* Option flags for what to do while crawling the workspace *)
  type t =
    { with_deps : bool (* whether to compute direct dependencies between modules *)
    ; with_pps : bool
      (* whether to include the dependencies to ppx-rewriters (that are
       used at compile time) *)
    ; no_recursive : bool
      (* whether to only consider the provided directories and not descendants *)
    }

  (* whether to sanitize absolute paths of workspace items, and their UIDs, to
     ensure reproducible tests *)
  let sanitize_for_tests = ref false

  let arg_with_deps =
    let open Arg in
    value
    & flag
    & info
        [ "with-deps" ]
        ~doc:(Some "Whether the dependencies between modules should be printed.")
  ;;

  let arg_with_pps =
    let open Arg in
    value
    & flag
    & info
        [ "with-pps" ]
        ~doc:
          (Some
             "Whether the dependencies towards ppx-rewriters (that are called at compile \
              time) should be taken into account.")
  ;;

  let arg_sanitize_for_tests =
    let open Arg in
    value
    & flag
    & info
        [ "sanitize-for-tests" ]
        ~doc:
          (Some
             "Sanitize the absolute paths in workspace items, and the associated UIDs, \
              so that the output is reproducible.")
  ;;

  let arg_no_recursive =
    let open Arg in
    value
    & flag
    & info
        [ "no-recursive" ]
        ~doc:
          (Some
             "Do not traverse subdirectories. Only consider the provided directories, or \
              the workspace root when none are given.")
  ;;

  let arg : t Term.t =
    let+ with_deps = arg_with_deps
    and+ with_pps = arg_with_pps
    and+ sanitize_for_tests_value = arg_sanitize_for_tests
    and+ no_recursive = arg_no_recursive in
    sanitize_for_tests := sanitize_for_tests_value;
    { with_deps; with_pps; no_recursive }
  ;;
end

(* The module [Descr] is a typed representation of the description of a
   workspace, that is provided by the ``dune describe workspace`` command.

   Each sub-module contains a [to_dyn] function, that translates the
   descriptors to a value of type [Dyn.t].

   The typed representation aims at precisely describing the structure of the
   information computed by ``dune describe``, and hopefully make users' life
   easier in decoding the S-expressions into meaningful contents. *)
module Descr = struct
  (* [dyn_path p] converts a path to a value of type [Dyn.t]. Remark: this is
     different from Path.to_dyn, that produces extra tags from a variant
     datatype. *)
  let dyn_path (p : Path.t) : Dyn.t = String (Path.to_string p)

  (* Description of the dependencies of a module *)
  module Mod_deps = struct
    type t =
      { for_intf : Dune_lang.Module_name.t list
        (* direct module dependencies for the interface *)
      ; for_impl : Dune_lang.Module_name.t list
        (* direct module dependencies for the implementation *)
      }

    (* Conversion to the [Dyn.t] type *)
    let to_dyn { for_intf; for_impl } =
      let open Dyn in
      record
        [ "for_intf", list Dune_lang.Module_name.to_dyn for_intf
        ; "for_impl", list Dune_lang.Module_name.to_dyn for_impl
        ]
    ;;
  end

  (* Description of modules *)
  module Mod = struct
    type t =
      { name : Dune_lang.Module_name.t (* name of the module *)
      ; impl : Path.t option (* path to the .ml file, if any *)
      ; intf : Path.t option (* path to the .mli file, if any *)
      ; cmt : Path.t option (* path to the .cmt file, if any *)
      ; cmti : Path.t option (* path to the .cmti file, if any *)
      ; module_deps : Mod_deps.t (* direct module dependencies *)
      }

    (* Conversion to the [Dyn.t] type *)
    let to_dyn { Options.with_deps; _ } { name; impl; intf; cmt; cmti; module_deps }
      : Dyn.t
      =
      let open Dyn in
      let optional_fields =
        let module_deps =
          if with_deps then Some ("module_deps", Mod_deps.to_dyn module_deps) else None
        in
        (* we build a list of options, that is later filtered, so that adding
           new optional fields in the future can be done easily *)
        match module_deps with
        | None -> []
        | Some module_deps -> [ module_deps ]
      in
      record
      @@ [ "name", Dune_lang.Module_name.to_dyn name
         ; "impl", option dyn_path impl
         ; "intf", option dyn_path intf
         ; "cmt", option dyn_path cmt
         ; "cmti", option dyn_path cmti
         ]
      @ optional_fields
    ;;
  end

  (* Description of executables *)
  module Exe = struct
    type t =
      { names : string list (* names of the executable *)
      ; requires : Digest.t list
        (* list of direct dependencies to libraries, identified by their
             digests *)
      ; modules : Mod.t list (* list of the modules the executable is composed of *)
      ; include_dirs : Path.t list (* list of include directories *)
      }

    let map_path t ~f = { t with include_dirs = List.map ~f t.include_dirs }

    (* Conversion to the [Dyn.t] type *)
    let to_dyn options { names; requires; modules; include_dirs } : Dyn.t =
      let open Dyn in
      record
        [ "names", List (List.map ~f:(fun name -> String name) names)
        ; "requires", Dyn.(list string) (List.map ~f:Digest.to_string requires)
        ; "modules", list (Mod.to_dyn options) modules
        ; "include_dirs", list dyn_path include_dirs
        ]
    ;;
  end

  (* Description of libraries *)

  module Lib = struct
    type t =
      { name : Lib_name.t (* name of the library *)
      ; uid : Digest.t (* digest of the library *)
      ; local : bool (* whether this library is local *)
      ; requires : Digest.t list
        (* list of direct dependendies to libraries, identified by their
             digests *)
      ; source_dir : Path.t
        (* path to the directory that contains the sources of this library *)
      ; modules : Mod.t list (* list of the modules the executable is composed of *)
      ; include_dirs : Path.t list (* list of include directories *)
      }

    let map_path t ~f =
      { t with source_dir = f t.source_dir; include_dirs = List.map ~f t.include_dirs }
    ;;

    (* Conversion to the [Dyn.t] type *)
    let to_dyn options { name; uid; local; requires; source_dir; modules; include_dirs }
      : Dyn.t
      =
      let open Dyn in
      record
        [ "name", Lib_name.to_dyn name
        ; "uid", String (Digest.to_string uid)
        ; "local", Bool local
        ; "requires", (list string) (List.map ~f:Digest.to_string requires)
        ; "source_dir", dyn_path source_dir
        ; "modules", list (Mod.to_dyn options) modules
        ; "include_dirs", (list dyn_path) include_dirs
        ]
    ;;
  end

  (* Description of items: executables, or libraries *)
  module Item = struct
    type t =
      | Executables of Exe.t
      | Library of Lib.t
      | Root of Path.t
      | Build_context of Path.t

    let map_path t ~f =
      match t with
      | Executables exe -> Executables (Exe.map_path exe ~f)
      | Library lib -> Library (Lib.map_path lib ~f)
      | Root r -> Root (f r)
      | Build_context c -> Build_context (f c)
    ;;

    (* Conversion to the [Dyn.t] type *)
    let to_dyn options : t -> Dyn.t = function
      | Executables exe_descr -> Variant ("executables", [ Exe.to_dyn options exe_descr ])
      | Library lib_descr -> Variant ("library", [ Lib.to_dyn options lib_descr ])
      | Root root -> Variant ("root", [ String (Path.to_absolute_filename root) ])
      | Build_context build_ctxt ->
        Variant ("build_context", [ String (Path.to_string build_ctxt) ])
    ;;
  end

  (* Description of a workspace: a list of items *)
  module Workspace = struct
    type t = Item.t list

    (* Conversion to the [Dyn.t] type *)
    let to_dyn options (items : t) : Dyn.t = Dyn.list (Item.to_dyn options) items
  end
end

module Lang = struct
  type t = Dune_lang.Syntax.Version.t

  let arg_conv =
    let parser s =
      match Scanf.sscanf s "%u.%u" (fun a b -> a, b) with
      | Ok t -> Ok t
      | Error () -> Error (`Msg "Expected version of the form NNN.NNN.")
    in
    let printer ppf t =
      Stdlib.Format.fprintf ppf "%s" (Dune_lang.Syntax.Version.to_string t)
    in
    Arg.conv ~docv:"VERSION" (parser, printer)
  ;;

  let arg : t Term.t =
    Term.ret
    @@ let+ v =
         Arg.(
           value
           & opt arg_conv (0, 1)
           & info
               [ "lang" ]
               ~docv:"VERSION"
               ~doc:(Some "Behave the same as this version of Dune."))
       in
       if v = (0, 1)
       then `Ok v
       else (
         let msg =
           let pp =
             "Only --lang 0.1 is available at the moment as this command is not yet \
              stabilised. If you would like to release a software that relies on the \
              output of 'dune describe', please open a ticket on \
              https://github.com/ocaml/dune."
             |> Pp.text
           in
           Stdlib.Format.asprintf "%a" Pp.to_fmt pp
         in
         `Error (true, msg))
  ;;
end

(* The following module is responsible sanitizing the output of
   [dune describe workspace], so that the absolute paths and the UIDs that
   depend on them are stable for tests. These paths may differ, depending on
   the machine they are run on. *)
module Sanitize_for_tests = struct
  module Workspace = struct
    let fake_findlib = lazy (Path.External.of_string "/FINDLIB")
    let fake_workspace = lazy (Path.External.of_string "/WORKSPACE_ROOT")

    let sanitize_with_findlib ~findlib_paths path =
      let path = Path.external_ path in
      List.find_map findlib_paths ~f:(fun candidate ->
        let open Option.O in
        let* candidate = Path.as_external candidate in
        (* if the path to rename is an external path, try to find the
           OCaml root inside, and replace it with a fixed string *)
        let+ without_prefix = Path.drop_prefix ~prefix:(Path.external_ candidate) path in
        (* we have found the OCaml root path: let's replace it with a
           constant string *)
        Path.External.append_local (Lazy.force fake_findlib) without_prefix)
    ;;

    (* Sanitizes a workspace description, by renaming non-reproducible UIDs and
       paths *)
    let really_sanitize ~findlib_paths items =
      let rename_path = function
        (* we have found a path for OCaml's root: let's define the renaming
           function *)
        | Path.External path ->
          sanitize_with_findlib ~findlib_paths path
          |> Option.value ~default:path
          |> Path.external_
        | In_source_tree p ->
          (* Replace the workspace root with a fixed string *)
          Path.External.append_local (Lazy.force fake_workspace) (Path.Source.to_local p)
          |> Path.external_
        | path ->
          (* Otherwise, it should not be changed *)
          path
      in
      (* now, we rename the UIDs in the [requires] field , while reversing the
         list of items, so that we get back the original ordering *)
      List.map ~f:(Descr.Item.map_path ~f:rename_path) items
    ;;

    (* Sanitizes a workspace description when options ask to do so, or performs
       no change at all otherwise *)
    let sanitize ~findlib_paths items =
      if !Options.sanitize_for_tests then really_sanitize ~findlib_paths items else items
    ;;
  end
end

(* Crawl the workspace to get all the data *)
module Crawl = struct
  open Dune_rules
  open Dune_engine
  open Memo.O

  (* Computes the digest of a library *)
  let uid_of_library (lib : Lib.t) : Digest.t =
    let name = Lib.name lib in
    if Lib.is_local lib
    then (
      let source_dir = Lib_info.src_dir (Lib.info lib) in
      Digest.Feed.compute_digest
        (Digest.Feed.tuple2 Digest.Feed.string Digest.Feed.string)
        (Lib_name.to_string name, Path.to_string source_dir))
    else Digest.string (Lib_name.to_string name)
  ;;

  let for_ = Compilation_mode.Ocaml

  let immediate_deps_of_module ~options ~obj_dir ~modules unit =
    match (options : Options.t) with
    | { with_deps = false; _ } ->
      Action_builder.return { Root.Ocaml.Ml_kind.Dict.intf = []; impl = [] }
    | { with_deps = true; _ } ->
      let deps ml_kind =
        Dune_rules.Dep_rules.read_immediate_deps_of ~obj_dir ~modules ~ml_kind unit ~for_
      in
      let open Action_builder.O in
      let+ intf, impl = Action_builder.both (deps Intf) (deps Impl) in
      { Root.Ocaml.Ml_kind.Dict.intf; impl }
  ;;

  (* Builds the description of a module from a module and its object directory *)
  let module_
        ~obj_dir
        ~(deps_for_intf : Module.t list)
        ~(deps_for_impl : Module.t list)
        (m : Module.t)
    : Descr.Mod.t
    =
    let source ml_kind = Option.map (Module.source m ~ml_kind) ~f:Module.File.path in
    let cmt ml_kind =
      Dune_rules.Obj_dir.Module.cmt_file obj_dir m ~ml_kind ~cm_kind:(Ocaml Cmi)
    in
    { Descr.Mod.name = Module.name m
    ; impl = source Impl
    ; intf = source Intf
    ; cmt = cmt Impl
    ; cmti = cmt Intf
    ; module_deps =
        { for_intf = List.map ~f:Module.name deps_for_intf
        ; for_impl = List.map ~f:Module.name deps_for_impl
        }
    }
  ;;

  (* Builds the list of modules *)
  let modules ~obj_dir ~deps_of modules_ : Descr.Mod.t list Memo.t =
    modules_
    |> Modules.With_vlib.drop_vlib
    |> Modules.fold ~init:(Memo.return []) ~f:(fun m macc ->
      let* acc = macc in
      let deps = deps_of m in
      let+ { Root.Ocaml.Ml_kind.Dict.intf = deps_for_intf; impl = deps_for_impl }, _ =
        Dune_engine.Action_builder.evaluate_and_collect_facts deps
      in
      module_ ~obj_dir ~deps_for_intf ~deps_for_impl m :: acc)
  ;;

  (* Builds a workspace item for the provided executables object *)
  let executables sctx ~options ~project ~dir (exes : Executables.t)
    : (Descr.Item.t * Lib.Set.t) option Memo.t
    =
    let* expander = Super_context.expander sctx ~dir in
    Expander.eval_blang expander exes.enabled_if
    >>= function
    | false -> Memo.return None
    | true ->
      let first_exe = snd (Nonempty_list.hd exes.names) in
      let* scope =
        Scope.DB.find_by_project (Super_context.context sctx |> Context.name) project
      in
      let* modules_, obj_dir =
        let+ modules_, obj_dir =
          Dir_contents.get sctx ~dir
          >>= Dir_contents.ocaml
          >>= Ml_sources.modules_and_obj_dir
                ~libs:(Scope.libs scope)
                ~for_:(Exe { first_exe })
        in
        Modules.With_vlib.modules modules_, obj_dir
      in
      let* pp_map =
        let+ version =
          let+ ocaml = Super_context.context sctx |> Context.ocaml in
          ocaml.version
        in
        Staged.unstage
        @@ Pp_spec.pped_modules_map
             (Dune_lang.Preprocess.Per_module.without_instrumentation
                exes.buildable.preprocess)
             version
      in
      let deps_of module_ =
        let module_ = pp_map module_ in
        immediate_deps_of_module ~options ~obj_dir ~modules:modules_ module_
      in
      let obj_dir = Obj_dir.of_local obj_dir in
      let* modules = modules ~obj_dir ~deps_of modules_ in
      let+ requires =
        let* compile_info = Exe_rules.compile_info ~scope exes in
        let open Resolve.Memo.O in
        let* requires = Lib.Compile.direct_requires compile_info ~for_ in
        if options.with_pps
        then
          let+ pps = Lib.Compile.pps compile_info ~for_ in
          pps @ requires
        else Resolve.Memo.return requires
      in
      (match Resolve.peek requires with
       | Error () -> None
       | Ok libs ->
         let include_dirs = Obj_dir.all_cmis obj_dir in
         let exe_descr =
           { Descr.Exe.names = Nonempty_list.to_list_map exes.names ~f:snd
           ; requires = List.map ~f:uid_of_library libs
           ; modules
           ; include_dirs
           }
         in
         Some (Descr.Item.Executables exe_descr, Lib.Set.of_list libs))
  ;;

  (* Builds a workspace item for the provided library object *)
  let library sctx ~options (lib : Lib.t) : Descr.Item.t option Memo.t =
    let* requires = Lib.requires lib ~for_ in
    match Resolve.peek requires with
    | Error () -> Memo.return None
    | Ok requires ->
      let name = Lib.name lib in
      let info = Lib.info lib in
      let src_dir = Lib_info.src_dir info in
      let obj_dir = Lib_info.obj_dir info in
      let+ modules_ =
        match Lib.is_local lib with
        | false -> Memo.return []
        | true ->
          (* XXX why do we have a second object directory? *)
          let* modules_, obj_dir_ =
            let* libs =
              Scope.DB.find_by_dir (Path.as_in_build_dir_exn src_dir) >>| Scope.libs
            in
            let+ modules_, obj_dir_ =
              Dir_contents.get sctx ~dir:(Path.as_in_build_dir_exn src_dir)
              >>= Dir_contents.ocaml
              >>= Ml_sources.modules_and_obj_dir
                    ~libs
                    ~for_:(Library (Lib_info.lib_id info |> Lib_id.to_local_exn))
            in
            Modules.With_vlib.modules modules_, obj_dir_
          in
          let* pp_map =
            let+ version =
              let+ ocaml = Super_context.context sctx |> Context.ocaml in
              ocaml.version
            in
            Staged.unstage
            @@ Pp_spec.pped_modules_map
                 (Dune_lang.Preprocess.Per_module.without_instrumentation
                    (Lib_info.preprocess info ~for_))
                 version
          in
          let deps_of module_ =
            immediate_deps_of_module
              ~options
              ~obj_dir:obj_dir_
              ~modules:modules_
              (pp_map module_)
          in
          modules ~obj_dir ~deps_of modules_
      in
      let include_dirs = Obj_dir.all_cmis obj_dir in
      let lib_descr =
        { Descr.Lib.name
        ; uid = uid_of_library lib
        ; local = Lib.is_local lib
        ; requires = List.map requires ~f:uid_of_library
        ; source_dir = src_dir
        ; modules = modules_
        ; include_dirs
        }
      in
      Some (Descr.Item.Library lib_descr)
  ;;

  (* [source_path_is_in_dirs dirs p] tests whether the source path [p] is a
     descendant of some of the provided directory [dirs]. If [dirs = None],
     then it always succeeds, unless [options.no_recursive] is set in which
     case it only matches the workspace root. If [dirs = Some l], then a
     matching directory is search in the list [l]. *)
  let source_path_is_in_dirs ~no_recursive dirs (p : Path.Source.t) =
    match dirs with
    | None -> if no_recursive then Path.Source.equal p Path.Source.root else true
    | Some dirs ->
      if no_recursive
      then List.exists ~f:(fun dir -> Path.Source.equal p dir) dirs
      else List.exists ~f:(fun dir -> Path.Source.is_descendant p ~of_:dir) dirs
  ;;

  (* Tests whether a dune file is located in a path that is a descendant of
     some directory *)
  let dune_file_is_in_dirs ~no_recursive dirs dune_file =
    Dune_file.dir dune_file |> source_path_is_in_dirs ~no_recursive dirs
  ;;

  (* Tests whether a library is located in a path that is a descendant of some
     directory *)
  let lib_is_in_dirs ~no_recursive dirs (lib : Lib.t) =
    source_path_is_in_dirs
      ~no_recursive
      dirs
      (Path.drop_build_context_exn @@ Lib_info.best_src_dir @@ Lib.info lib)
  ;;

  (* Builds a workspace item for the root path *)
  let root () = Descr.Item.Root Path.root

  (* Builds a workspace item for the build directory path *)
  let build_ctxt (context : Context.t) : Descr.Item.t =
    Descr.Item.Build_context (Path.build (Context.build_dir context))
  ;;

  (* Builds a workspace description for the provided dune setup and context *)
  let workspace
        options
        ({ Dune_rules.Main.contexts = _; scontexts } : Dune_rules.Main.build_system)
        (context : Context.t)
        dirs
    : Descr.Workspace.t Memo.t
    =
    let { Options.no_recursive; _ } = options in
    let context_name = Context.name context in
    let sctx = Context_name.Map.find_exn scontexts context_name in
    let open Memo.O in
    let* dune_files =
      Dune_load.dune_files context_name
      >>| List.filter ~f:(dune_file_is_in_dirs ~no_recursive dirs)
    in
    let* exes, exe_libs =
      (* the list of workspace items that describe executables, and the list of
         their direct library dependencies *)
      Memo.parallel_map dune_files ~f:(fun (dune_file : Dune_file.t) ->
        Dune_file.stanzas dune_file
        >>= Memo.parallel_map ~f:(fun stanza ->
          match Stanza.repr stanza with
          | Executables.T exes ->
            let dir =
              Path.Build.append_source
                (Context.build_dir context)
                (Dune_file.dir dune_file)
            in
            let project = Dune_file.project dune_file in
            executables sctx ~options ~project ~dir exes
          | _ -> Memo.return None)
        >>| List.filter_opt)
      >>| List.concat
      >>| List.split
    in
    let exe_libs =
      (* conflate the dependencies of executables into a single set *)
      Lib.Set.union_all exe_libs
    in
    let* project_libs =
      (* the list of libraries declared in the project *)
      Dune_load.projects ()
      >>= Memo.parallel_map ~f:(fun project ->
        Scope.DB.find_by_project (Context.name context) project
        >>| Scope.libs
        >>= Lib.DB.all)
      >>| Lib.Set.union_all
      >>| Lib.Set.filter ~f:(lib_is_in_dirs ~no_recursive dirs)
    in
    let+ libs =
      (* the executables' libraries, and the project's libraries *)
      Lib.Set.union exe_libs project_libs
      |> Lib.Set.to_list
      |> Lib.descriptive_closure ~with_pps:options.with_pps ~for_
      >>= Memo.parallel_map ~f:(library ~options sctx)
      >>| List.filter_opt
    in
    let root = root () in
    let build_ctxt = build_ctxt context in
    root :: build_ctxt :: (exes @ libs)
  ;;
end

let find_dir common dir =
  let p = Path.Source.(relative root) (Common.prefix_target common dir) in
  let s = Path.source p in
  if not @@ Fpath.exists (Path.to_string s)
  then User_error.raise [ Pp.textf "No such file or directory: %s" (Path.to_string s) ];
  if not @@ Fpath.is_directory (Path.to_string s)
  then
    User_error.raise
      [ Pp.textf "File exists, but is not a directory: %s" (Path.to_string s) ];
  Memo.return p
;;

let term : unit Term.t =
  let+ builder = Common.Builder.term
  and+ what =
    Arg.(
      value
      & pos_all string []
      & info
          []
          ~docv:"DIRS"
          ~doc:
            (Some
               "prints a description of the workspace's structure. If some directories \
                DIRS are provided, then only those directories of the workspace are \
                considered."))
  and+ context_name = Common.context_arg ~doc:(Some "Build context to use.")
  and+ format = Describe_format.arg
  and+ lang = Lang.arg
  and+ options = Options.arg in
  let common, config = Common.init builder in
  let dirs =
    let args = "workspace" :: what in
    let parse =
      Dune_lang.Syntax.set Stanza.syntax (Active lang)
      @@
      let open Dune_lang.Decoder in
      fields
      @@ field "workspace"
      @@ let+ dirs = repeat relative_file in
         (* [None] means that all directories should be accepted,
            whereas [Some l] means that only the directories in the
            list [l] should be accepted. The checks on whether the
            paths exist and whether they are directories are performed
            later in the [describe] function. *)
         let dirs = if List.is_empty dirs then None else Some dirs in
         dirs
    in
    let ast =
      Dune_lang.Ast.add_loc
        ~loc:Loc.none
        (List (List.map args ~f:Dune_lang.atom_or_quoted_string))
    in
    Dune_lang.Decoder.parse parse Univ_map.empty ast
  in
  Scheduler_setup.go_with_rpc_server ~common ~config
  @@ fun () ->
  Build.build_memo_exn
  @@ fun () ->
  let open Memo.O in
  let* setup = Util.setup () in
  let super_context = Dune_rules.Main.find_scontext_exn setup ~name:context_name in
  let context = Super_context.context super_context in
  let* findlib_paths = Context.findlib_paths context in
  (* prefix directories with the workspace root, so that the
     command also works correctly when it is run from a
     subdirectory *)
  Memo.Option.map dirs ~f:(Memo.List.map ~f:(find_dir common))
  >>= Crawl.workspace options setup context
  >>| Sanitize_for_tests.Workspace.sanitize ~findlib_paths
  >>| Descr.Workspace.to_dyn options
  >>| Describe_format.print_dyn format
;;

let command =
  let doc =
    "Print a description of the workspace's structure. If some directories DIRS are \
     provided, then only those directories of the workspace are considered."
  in
  let info = Cmd.info ~doc "workspace" in
  Cmd.v info term
;;
